Merge pull request 'phase-5/security-hardening' (#14) from phase-5/security-hardening into main
CI / verify (push) Successful in 27s
CI / infrastructure-smoke (push) Successful in 21s
CI / migration-smoke (push) Successful in 42s

Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/14
This commit was merged in pull request #14.
This commit is contained in:
The_River
2026-07-30 03:04:36 +00:00
88 changed files with 13127 additions and 756 deletions
+7
View File
@@ -1,4 +1,11 @@
# Local development only. Replace every credential outside a developer workstation. # Local development only. Replace every credential outside a developer workstation.
# Required for a local workstation, and only ever correct there. Anything else — including an
# unset value — is validated as production, which requires certificate-verified PostgreSQL TLS
# (EKKLESIA_DATABASE_TLS_MODE defaults to verify-full) and rejects loopback/http identity origins.
# The plaintext loopback DATABASE_URL below is therefore only usable with this set.
NODE_ENV=development
POSTGRES_DB=ekklesia POSTGRES_DB=ekklesia
POSTGRES_USER=ekklesia POSTGRES_USER=ekklesia
POSTGRES_PASSWORD=ekklesia-development-only POSTGRES_PASSWORD=ekklesia-development-only
+32
View File
@@ -1,5 +1,22 @@
name: CI name: CI
# IMPORTANT — the currently inventoried server is Gitea 1.24.2, which *ignores* several keys used
# below. Per https://docs.gitea.com/1.24/usage/actions/comparison, its unsupported workflow syntax
# includes `permissions` / `jobs.<job_id>.permissions`, `concurrency`, and
# `jobs.<job_id>.timeout-minutes`. They are declared here deliberately, so the workflow is already
# correct on a supported release and on GitHub Actions, but on this server they are inert:
#
# * `permissions:` does NOT restrict the in-job `GITEA_TOKEN`. Job-token permission controls,
# repository/owner defaults, and server-side maximum-permission clamping arrive in the 1.26
# feature line (https://docs.gitea.com/1.26/usage/actions/token-permissions). Until the server
# is upgraded and configured, the token's authority is whatever the server grants — not what
# this file requests.
# * `concurrency:` and `timeout-minutes:` are NOT bounded-execution guarantees here. Cancellation,
# timeout, and teardown must be enforced by the trusted runner provisioner/reaper.
#
# `persist-credentials: false` below is unaffected by any of this and is enforced by the action
# itself. See docs/development/security.md.
on: on:
pull_request: pull_request:
push: push:
@@ -20,6 +37,11 @@ jobs:
steps: steps:
- name: Check out repository - name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
# step — including one a pull request added to this file — could read or reuse it. Nothing
# in this workflow pushes, so the credential is never needed after checkout.
persist-credentials: false
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -44,6 +66,11 @@ jobs:
steps: steps:
- name: Check out repository - name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
# step — including one a pull request added to this file — could read or reuse it. Nothing
# in this workflow pushes, so the credential is never needed after checkout.
persist-credentials: false
- name: Exercise PostgreSQL and S3 infrastructure - name: Exercise PostgreSQL and S3 infrastructure
run: bash scripts/smoke-infrastructure.sh run: bash scripts/smoke-infrastructure.sh
@@ -55,6 +82,11 @@ jobs:
steps: steps:
- name: Check out repository - name: Check out repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# `actions/checkout` defaults to leaving the job token in `.git/config`, where any later
# step — including one a pull request added to this file — could read or reuse it. Nothing
# in this workflow pushes, so the credential is never needed after checkout.
persist-credentials: false
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+153 -8
View File
@@ -8,7 +8,14 @@ surfaces.
Phase 3 is complete, and Phase 4A (Core identity foundation), Phase 4B (identity HTTP transport), Phase 3 is complete, and Phase 4A (Core identity foundation), Phase 4B (identity HTTP transport),
Phase 4C (Core authorization), Phase 5A (module authorization integration), Phase 5B (module HTTP Phase 4C (Core authorization), Phase 5A (module authorization integration), Phase 5B (module HTTP
transport), and Phase 5C (module database access) have landed. The repository includes transport), and Phase 5C (module database access) have landed. **Phase 5D (security hardening
against an independent security audit) is complete: all five passes have landed and the final
independent whole-repository re-audit has closed, under the current trusted-contributor,
not-yet-deployed threat model — and Phase 6 (Surface work) is unblocked.** The operational
requirements that remain are prerequisites for two specific *future* activities — executing
untrusted CI contributions, and a public production launch — and neither gates Phase 6; see the
pass 5 closure below and
[Security model and deployment trust boundaries](docs/development/security.md). The repository includes
public module contracts, independently versioned capabilities, static module manifests, public module contracts, independently versioned capabilities, static module manifests,
deterministic automatic discovery, compiled server loading, a staged capability registry, deterministic automatic discovery, compiled server loading, a staged capability registry,
dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic
@@ -32,8 +39,10 @@ in `@ekklesia/contracts`, and an explicit Better Auth construction boundary in C
Phase 4B adds identity HTTP transport: a minimal Hono-based `apps/api` composition root Phase 4B adds identity HTTP transport: a minimal Hono-based `apps/api` composition root
(`pnpm api:start`) mounts Better Auth at `/api/auth/*` and exposes `GET /api/v1/principal` behind (`pnpm api:start`) mounts Better Auth at `/api/auth/*` and exposes `GET /api/v1/principal` behind
Core's own Fetch-compatible identity runtime — Core still does not depend on Hono, and modules Core's own Fetch-compatible identity runtime — Core still does not depend on Hono, and modules
still cannot reach any of this. Public email/password registration is intentional; every account still cannot reach any of this. Public email/password registration is the intended eventual
receives identical lowest-privilege baseline status and signup order never grants elevation. See product policy — every account receives identical lowest-privilege baseline status and signup
order never grants elevation — but is now **disabled by default, and cannot be enabled in
production at all** until durable abuse controls exist (Phase 5D pass 1, below). See
[API](docs/development/api.md) and [API](docs/development/api.md) and
[ADR 0011](docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md). [ADR 0011](docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md).
**No email verification, password recovery, or Dashboard exists yet** — that, along with **No email verification, password recovery, or Dashboard exists yet** — that, along with
@@ -91,13 +100,140 @@ startup. See [API](docs/development/api.md#module-database-access),
[Module development](docs/module-development/README.md#database-access), and [Module development](docs/module-development/README.md#database-access), and
[ADR 0016](docs/architecture/decisions/0016-module-database-access.md). [ADR 0016](docs/architecture/decisions/0016-module-database-access.md).
Phase 5D pass 1 of 5 hardens the public request perimeter against an independent security audit
of the Phase 5C checkpoint, without adding a Surface, a product module, or any new capability.
Better Auth's authentication rate limiter now resolves client identity from a server-owned
client-address boundary (`core/src/identity/client-address.ts`, wired in at the Node/API transport
boundary) — direct mode trusts only the real socket peer by default, and an explicit trusted-proxy
mode is required before any forwarded header is ever consulted, closing a rotating-header
rate-limit bypass. Public sign-up is now **disabled by default, and a production configuration that
tries to enable it fails closed** (`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION`, reported by `pnpm
doctor` as a blocking failure) — the durable abuse-control prerequisites do not exist yet, so only
development/test composition may enable it. Every public body-consuming route — Better Auth,
Core's grant-mutation endpoint, and every module route (via a new, required `maxBodyBytes` field on
the executable route contract) — now has an explicit, platform-enforced byte ceiling with one
sanitized `413` contract. The Node HTTP server now has finite header/request timeouts, whose real
transport-level failures are observed and logged in sanitized form. See
[API](docs/development/api.md), [Module development](docs/module-development/README.md#http-routes),
and [ADR 0017](docs/architecture/decisions/0017-public-request-perimeter-hardening.md). **This pass
does not add distributed rate limiting, durable public-signup abuse control, or any deployment/TLS
guarantee, and it did not by itself complete Phase 5D** — at the close of this pass, passes 2–5
(identity/database transport hardening, authorization integrity, module/runtime/migration
correctness, and deployment/supply-chain closure plus a final independent re-audit) were still
outstanding, and Phase 6 (Surface work) was blocked until they landed.
Phase 5D pass 2 of 5 corrects identity session resolution and enforces a database transport policy.
A request carrying no cookies, or only unrelated cookies, now performs **no** identity database
work at all; only a request presenting the configured session cookie that Better Auth resolved to
`null` is disambiguated, by a real read of the session relation and the exact columns the adapter
depends on (under `where false`, so no row is ever read) rather than the previous `select 1`. A
missing table, dropped column, revoked privilege, unreachable database, or query timeout therefore
produces a sanitized `503`, never a silent anonymous downgrade. The identity base URL and every
trusted origin must now be exactly one canonical origin, and production rejects the full
loopback/unspecified address space rather than three literal spellings. **Production PostgreSQL
connections now require TLS with certificate *and* hostname verification** — from the API, both
migration commands, the bootstrap CLI, and `pnpm doctor` alike — with no encryption-only mode, no
`rejectUnauthorized: false` escape hatch, no plaintext exception (not even for a loopback
database), and an explicit refusal of a `DATABASE_URL` that tries to carry its own TLS parameters,
which the pinned driver would otherwise let silently override the policy. See
[Identity](docs/development/identity.md), [API](docs/development/api.md#database-transport-tls), and
[ADR 0018](docs/architecture/decisions/0018-identity-session-resolution-and-database-transport.md).
**This pass adds no secret store, no certificate rotation, no client-certificate authentication, and
no distributed rate limiting; TLS protects the connection, not the exposure, so the network boundary
remains the deployment's own responsibility. This pass did not by itself complete Phase 5D** — at
its close, passes 3–5 (authorization integrity, module/runtime/migration correctness, and
deployment/supply-chain closure plus a final independent re-audit) were still outstanding.
Phase 5D pass 3 of 5 preserves authorization security history and makes explicit-grant management
unambiguous and bounded. Authorization **audit history now survives user deletion**: the audit
subject is a retained identifier snapshot with no cascading foreign key, so deleting a user still
removes their live grants and platform-owner row but no longer destroys the record of what they had
been granted and by whom. Platform-owner bootstrap's single use moved to a dedicated singleton with
no user foreign key — previously deleting the bootstrapped owner's user row silently reopened the
one operation that establishes a platform's first administrator; the operational consequence is that
**deleting the last owner does not reactivate bootstrap**, and existing installations are backfilled
as consumed by the new migration. Two equivalent grants can no longer both be effective: creation
conflicts with a sanitized `409`, serialized under real PostgreSQL concurrency, and one revocation
now clears every unrevoked equivalent with a per-row audit entry, so revoking a grant can no longer
report success while a duplicate keeps authorizing the user. Grant ids must be canonical UUIDs and
expirations one exact future-only format, both decided before any database call; the generic HTTP
surface creates unscoped grants only; and listing is bounded keyset pagination with an opaque,
unsigned position cursor. One forward Core migration (`0002_authorization_integrity`,
`{"mode":"none"}`) must be applied before the new code starts. See
[Authorization](docs/development/authorization.md) and
[ADR 0019](docs/architecture/decisions/0019-durable-authorization-history-and-grant-management.md).
**This pass adds no roles, teams, tenants, service accounts, scope resolver, user-deletion endpoint,
or owner-recovery workflow; audit append-only behavior remains an application convention, not a
database or cryptographic guarantee. This pass did not by itself complete Phase 5D** — at its close,
passes 4 (module/runtime/migration correctness) and 5 (deployment/supply chain plus a final
independent re-audit) were still outstanding, and Phase 6 was blocked until they landed.
Phase 5D pass 4 of 5 hardens the module runtime and the migration commands. **Two routes of one
module that could both match the same request path are now rejected at registration**
(`MODULE_ROUTE_OVERLAP`) instead of being resolved by declaration order — with different permissions
on `/events/:id` and `/events/new`, registration order alone previously decided which permission
guarded `/events/new`. Decoded route parameters are documented as untrusted, module-owned input that
handlers must validate against their own domain grammar; no generic validator or route field was
added. A module whose `start` hook fails is now rolled back through **its own** `stop` hook first,
so partially acquired resources are released, and every stop/rollback hook runs under a fixed
10-second deadline while HTTP shutdown drains for at most 30 seconds before force-closing
connections through Node's supported public APIs — shutdown is finite at every stage, reports a
forced drain or a hook failure as an unsuccessful shutdown, and always still reaches pool closure.
`migrations:plan`/`migrations:apply` now connect with fixed 10-second connection, query, and
`statement_timeout` defaults that a `DATABASE_URL` cannot weaken (the pinned driver lets a
connection string override explicit options — verified), and each migration's SQL is sent to
PostgreSQL **intact, as one parsed batch** inside the existing transaction rather than split on the
literal `--> statement-breakpoint` bytes, which corrupted any migration containing those bytes in a
string literal, dollar-quoted body, quoted identifier, or comment. See
[Module development](docs/module-development/README.md#http-routes),
[API](docs/development/api.md#graceful-shutdown), and
[ADR 0020](docs/architecture/decisions/0020-module-runtime-and-migration-execution-hardening.md).
**Deadlines bound how long the platform waits; they do not cancel or sandbox trusted in-process
module code, and closing a socket does not prove a handler promise ended. No custom SQL parser
exists. Modules remain trusted compiled backend code with no process, role, schema, or tenant
isolation. This pass did not by itself complete Phase 5D** — at its close, pass 5
(deployment/supply chain plus an independent whole-repository re-audit) was still outstanding, and
Phase 6 was blocked until it landed.
Phase 5D pass 5 of 5 closes the phase with an independent whole-repository re-audit and the
repository-local half of deployment and supply-chain hardening. The re-audit swept every layer —
Core, contracts, `apps/api`, scripts, the CI workflow, and documentation — and its repository-local
findings are resolved here: every dependency advisory is remediated (both `pnpm audit` and
`pnpm audit --prod` report no known vulnerabilities), every CI action reference is pinned to a full
immutable commit SHA, installs run `--frozen-lockfile`, no workflow references a repository or
instance secret, every checkout step sets `persist-credentials: false`, a checksum-verified secret
scan found no committed credential in the history, and one canonical
[security document](docs/development/security.md) now states the trusted-module model, the
undeployed state, and the launch checklist in a single place. **CI is accepted as a
trusted-code-only boundary**: the self-hosted Gitea runner executes only code controlled and
reviewed by trusted repository collaborators, and unknown or untrusted pull requests must not
execute on it automatically. What remains is operational and cannot be proven from this repository —
upgrading Gitea past 1.24.2 (which *ignores* the workflow's `permissions`, `concurrency`, and
`timeout-minutes` declarations), clamping job-token authority server-side, enforcing timeout and
teardown in a trusted provisioner/reaper, and proving disposable runner isolation. Those are
prerequisites for **executing untrusted contributions**, alongside the deployment checklist that
gates a **public launch**; moving future public contribution workflows to a service with isolated
hosted runners is an acceptable alternative. **Neither set of requirements blocks Phase 6** — Surface
development proceeds under the current trusted-contributor, not-yet-deployed model. Runner topology
and job-token authority remain unverified and are not described as isolated. See
[Security model and deployment trust boundaries](docs/development/security.md).
**Security model.** Installed modules are trusted compiled backend code that runs arbitrary
JavaScript in the API process and shares its PostgreSQL role — table prefixes, manifests, permission
declarations, typed facades, and frozen contexts are correctness boundaries, not a sandbox. Ekklesia
is also **not currently deployed**: there is no production instance, reverse proxy, public listener,
or deployment automation, so the origin/proxy/throttling properties a public launch requires are
unproven launch-blockers rather than existing guarantees. Read
[Security model and deployment trust boundaries](docs/development/security.md) before installing a
module or exposing the API.
## Quick start ## Quick start
Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose. Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose.
```bash ```bash
pnpm install --frozen-lockfile pnpm install --frozen-lockfile
cp .env.example .env cp .env.example .env # keep its NODE_ENV=development: see below
pnpm infrastructure:up pnpm infrastructure:up
pnpm modules:validate pnpm modules:validate
pnpm migrations:validate pnpm migrations:validate
@@ -109,9 +245,15 @@ pnpm verify
``` ```
`pnpm doctor` builds the workspace first, then reports the supported Node.js runtime, installed `pnpm doctor` builds the workspace first, then reports the supported Node.js runtime, installed
modules and dependency order, schema and migration inventories, optional read-only PostgreSQL modules and dependency order, schema and migration inventories, the effective PostgreSQL transport
planning, and the current object-storage integration state. It never applies migrations, creates a (TLS) policy, optional read-only PostgreSQL planning, and the current object-storage integration
history table, starts module servers, or performs storage operations. Schema discovery does import state. `.env.example` sets `NODE_ENV=development` deliberately: anything else, including an unset
value, is validated as production, and production requires certificate-verified PostgreSQL TLS and
rejects loopback/`http` identity origins — so the plaintext loopback database this repository's own
development infrastructure provides is only usable with it set.
Doctor never applies migrations, creates a history table, starts module servers, or performs
storage operations. Schema discovery does import
compiled schema-definition entrypoints; those entrypoints are contractually side-effect-free. compiled schema-definition entrypoints; those entrypoints are contractually side-effect-free.
With no installed modules and no `DATABASE_URL`, the current repository produces a report shaped With no installed modules and no `DATABASE_URL`, the current repository produces a report shaped
@@ -136,11 +278,14 @@ Data definitions
✓ Schemas validated ✓ Schemas validated
0 modules · 0 tables 0 modules · 0 tables
✓ Migrations validated ✓ Migrations validated
2 migrations · checksums valid 3 migrations · checksums valid
core:0000_identity_core_tables · f8f2086ef06f core:0000_identity_core_tables · f8f2086ef06f
core:0001_authorization_core_tables · 830e1fbc26d0 core:0001_authorization_core_tables · 830e1fbc26d0
core:0002_authorization_integrity · 634294731180
PostgreSQL PostgreSQL
✓ Database TLS verified
TLS required with certificate and hostname verification.
○ Connection check skipped ○ Connection check skipped
DATABASE_URL is not configured. DATABASE_URL is not configured.
○ Migration plan skipped ○ Migration plan skipped
+15 -2
View File
@@ -12,6 +12,7 @@ import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/co
import { Hono } from "hono"; import { Hono } from "hono";
import type { Context, MiddlewareHandler } from "hono"; import type { Context, MiddlewareHandler } from "hono";
import { cors } from "hono/cors"; import { cors } from "hono/cors";
import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
import { createGrantsRoutes } from "./grants-routes.js"; import { createGrantsRoutes } from "./grants-routes.js";
import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js"; import { canonicalizeVaryHeader, mountModuleHttpRoutes } from "./module-http-routes.js";
@@ -117,9 +118,21 @@ export function createApiApp(options: CreateApiAppOptions): Hono {
app.use("/api/auth/*", corsMiddleware); app.use("/api/auth/*", corsMiddleware);
app.use("/api/v1/*", corsMiddleware); app.use("/api/v1/*", corsMiddleware);
app.on(["POST", "GET"], "/api/auth/*", async (c) => { const authBodyLimit = platformBodyLimit(AUTH_REQUEST_MAX_BODY_BYTES);
app.on(["POST", "GET"], "/api/auth/*", authBodyLimit, async (c) => {
try { try {
return await runtime.handleAuthRequest(c.req.raw); const response = await runtime.handleAuthRequest(c.req.raw);
// Every Better Auth response — including a session cookie — must never be cached by an
// intermediary; constructing a fresh Response (rather than relying on `c.header()`, which
// only affects `c.res`) guarantees this regardless of how the handler produced its result,
// and never reads or buffers the body.
const headers = new Headers(response.headers);
headers.set("Cache-Control", "no-store");
return new Response(response.body, {
headers,
status: response.status,
statusText: response.statusText,
});
} catch (error) { } catch (error) {
return sanitizedErrorResponse(c, error, 500); return sanitizedErrorResponse(c, error, 500);
} }
+48
View File
@@ -0,0 +1,48 @@
import { MODULE_ROUTE_MAX_BODY_BYTES_CEILING } from "@ekklesia/contracts";
import type { Context } from "hono";
import { bodyLimit } from "hono/body-limit";
/**
* The platform's one request-body policy, centralized here rather than scattered as magic numbers
* across every route file. Every limit below is deliberately small relative to the actual payload
* shapes it guards:
*
* - `AUTH_REQUEST_MAX_BODY_BYTES` (8 KiB): every enabled Better Auth route
* (`/api/auth/sign-up/email`, `/sign-in/email`, `/sign-out`, ...) accepts only an email, a
* password (bounded to 128 characters — `core/src/identity/runtime.ts`), and an optional short
* display name; the pinned release adds no other enabled provider or field (ADR 0011). A real
* payload is well under 200 bytes; 8 KiB leaves generous headroom for a verbose email address or
* name while remaining two orders of magnitude below the module ceiling.
* - `CORE_MUTATION_MAX_BODY_BYTES` (16 KiB): the one Core JSON mutation body, explicit-grant
* creation (`POST /api/v1/grants`), accepts `subjectUserId` (<=128 chars), `permission` (a bounded
* identifier), an optional `scope` (<=256 chars), and an optional ISO `expiresAt` string — every
* field individually bounded well under 1 KiB combined; 16 KiB leaves headroom without inviting
* abuse.
* - `MODULE_ROUTE_MAX_BODY_BYTES_CEILING` (1 MiB, re-exported from `@ekklesia/contracts` — the same
* constant a module's own route declaration is validated against at registration) is the hard
* ceiling no module route may exceed and doubles as the platform's overall request-body ceiling:
* nothing in this platform ever declares, or enforces, a larger single request body than this.
* Large media transfer is explicitly out of scope for ordinary module HTTP bodies — it belongs to
* a future dedicated storage design, not to widening this ceiling.
*/
export const AUTH_REQUEST_MAX_BODY_BYTES = 8 * 1024;
export const CORE_MUTATION_MAX_BODY_BYTES = 16 * 1024;
export { MODULE_ROUTE_MAX_BODY_BYTES_CEILING };
/** The one stable, sanitized public contract for every request-body-size rejection across the
* platform — auth, Core, and module routes alike. Never leaks the configured limit, the offending
* size, a declared Content-Length, or any body content. */
export function requestBodyTooLargeResponse(c: Context): Response {
c.header("Cache-Control", "no-store");
return c.json({ code: "REQUEST_BODY_TOO_LARGE" }, 413);
}
/** A single shared `hono/body-limit` factory so every call site uses the identical sanitized 413
* contract rather than redefining `onError` per route. Covers both a declared oversized
* `Content-Length` and an oversized chunked/streamed body with no declared length — see
* `hono/body-limit`'s pinned implementation, which checks `Content-Length` synchronously before
* ever reading the stream, and otherwise reads and counts the stream incrementally, rejecting as
* soon as the running total exceeds `maxSize` rather than buffering the whole body first. */
export function platformBodyLimit(maxSize: number) {
return bodyLimit({ maxSize, onError: requestBodyTooLargeResponse });
}
+66
View File
@@ -0,0 +1,66 @@
import { type ClientAddressResolver, TRUSTED_CLIENT_ADDRESS_HEADER } from "@ekklesia/core/identity";
import type { Http2Bindings, HttpBindings } from "@hono/node-server";
type NodeServerBindings = HttpBindings | Http2Bindings;
export {
type ClientAddressConfig,
ClientAddressConfigError,
type ClientAddressConfigErrorCode,
type ClientAddressConfigInput,
type ClientAddressFailureReason,
type ClientAddressMode,
type ClientAddressResolution,
type ClientAddressResolver,
createClientAddressConfig,
createClientAddressResolver,
type DirectClientAddressConfig,
findInvalidTrustedProxies,
normalizeClientAddress,
type TrustedProxyClientAddressConfig,
} from "@ekklesia/core/identity";
/** A fixed, generic diagnostic — never the peer address, forwarded header content, request path, or
* any other request detail — logged only when client-address resolution fails closed. */
export const CLIENT_ADDRESS_UNRESOLVED_LOG_MESSAGE =
"[client-address] A request's caller address could not be resolved safely; rejecting with 503.";
function sanitizedClientAddressUnresolvedResponse(): Response {
return new Response(JSON.stringify({ code: "CLIENT_ADDRESS_UNRESOLVED" }), {
headers: { "cache-control": "no-store", "content-type": "application/json" },
status: 503,
});
}
/**
* Wraps a `@hono/node-server` fetch callback at the exact Node/API transport boundary — the one
* place in this repository below `apps/api` that reads the raw Node `IncomingMessage` — with
* Core's framework-agnostic client-address resolution policy (`@ekklesia/core/identity`). Resolves
* the caller's trusted address from the real socket peer (`env.incoming.socket.remoteAddress`)
* before the wrapped callback — and therefore Better Auth's own rate limiter — ever sees the
* request, then overwrites (never merely appends) any externally supplied copy of
* `TRUSTED_CLIENT_ADDRESS_HEADER` with that resolved value. A caller can therefore never choose its
* own resolved address, whether by spoofing a forwarded header or by sending the internal header
* directly. Resolution failure never reaches the wrapped callback at all — no Better Auth logic, no
* Core mutation, and no module handler ever runs for a request whose caller address could not be
* resolved safely.
*/
export function wrapFetchWithClientAddress(
fetchCallback: (request: Request, env: NodeServerBindings) => Response | Promise<Response>,
resolver: ClientAddressResolver,
): (request: Request, env: NodeServerBindings) => Promise<Response> {
return async (request: Request, env: NodeServerBindings): Promise<Response> => {
const resolution = resolver.resolve({
headers: request.headers,
peerAddress: env.incoming.socket.remoteAddress,
});
if (!resolution.ok) {
console.error(CLIENT_ADDRESS_UNRESOLVED_LOG_MESSAGE);
return sanitizedClientAddressUnresolvedResponse();
}
const headers = new Headers(request.headers);
headers.set(TRUSTED_CLIENT_ADDRESS_HEADER, resolution.address);
const sanitizedRequest = new Request(request, { headers });
return fetchCallback(sanitizedRequest, env);
};
}
+37 -1
View File
@@ -1,9 +1,20 @@
import {
assertProductionDatabaseTransport,
type DatabaseTransportPolicy,
type DatabaseTransportPolicyInput,
createDatabaseTransportPolicy,
} from "@ekklesia/core/database";
import { import {
type CreateIdentityConfigOptions, type CreateIdentityConfigOptions,
createIdentityConfig, createIdentityConfig,
type IdentityConfig, type IdentityConfig,
type IdentityConfigInput, type IdentityConfigInput,
} from "@ekklesia/core/identity"; } from "@ekklesia/core/identity";
import {
type ClientAddressConfig,
type ClientAddressConfigInput,
createClientAddressConfig,
} from "./client-address.js";
export type ApiConfigErrorCode = export type ApiConfigErrorCode =
| "DATABASE_URL_INVALID" | "DATABASE_URL_INVALID"
@@ -37,14 +48,23 @@ const defaultHost = "127.0.0.1";
const defaultPort = 3000; const defaultPort = 3000;
export interface ApiRuntimeConfig { export interface ApiRuntimeConfig {
readonly clientAddress: ClientAddressConfig;
readonly databaseUrl: string; readonly databaseUrl: string;
/** The one validated PostgreSQL transport policy this runtime's single pool uses. */
readonly databaseTransport: DatabaseTransportPolicy;
readonly host: string; readonly host: string;
readonly identity: IdentityConfig; readonly identity: IdentityConfig;
readonly port: number; readonly port: number;
} }
export interface ApiRuntimeConfigInput { export interface ApiRuntimeConfigInput {
/** Optional. Defaults to direct mode (trusts only the actual immediate socket peer) — no proxy
* trust unless explicitly configured. See `./client-address.js`. */
readonly clientAddress?: ClientAddressConfigInput;
readonly databaseUrl?: unknown; readonly databaseUrl?: unknown;
/** Optional. Defaults to certificate-verified TLS in production and plaintext in development;
* production can never select plaintext. See `@ekklesia/core/database`'s transport policy. */
readonly databaseTransport?: DatabaseTransportPolicyInput;
readonly host?: unknown; readonly host?: unknown;
readonly identity: IdentityConfigInput; readonly identity: IdentityConfigInput;
readonly port?: unknown; readonly port?: unknown;
@@ -112,6 +132,22 @@ export function createApiRuntimeConfig(
input.identity, input.identity,
options.environment === undefined ? {} : { environment: options.environment }, options.environment === undefined ? {} : { environment: options.environment },
); );
const clientAddress = createClientAddressConfig(input.clientAddress);
const environment = options.environment ?? "production";
const databaseTransport = createDatabaseTransportPolicy(input.databaseTransport, {
environment,
});
// Fails here — before `startApi` constructs a pool, Better Auth, the module runtime, or a
// listener — if a production deployment would connect without certificate-verified TLS, or if
// the URL carries TLS parameters that could override the policy.
assertProductionDatabaseTransport(databaseUrl, databaseTransport, environment);
return Object.freeze({ databaseUrl, host, identity, port }); return Object.freeze({
clientAddress,
databaseTransport,
databaseUrl,
host,
identity,
port,
});
} }
+234 -37
View File
@@ -1,16 +1,17 @@
import type { AuthorizationScope, EkklesiaUserId, PermissionId } from "@ekklesia/contracts"; import type { EkklesiaUserId, GrantId, PermissionId } from "@ekklesia/contracts";
import { import {
createUserActor, createUserActor,
isAuthenticatedPrincipal, isAuthenticatedPrincipal,
safeParseAuthorizationScope,
safeParseEkklesiaUserId, safeParseEkklesiaUserId,
safeParseGrantId, safeParseGrantId,
safeParsePermissionId, safeParsePermissionId,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import { import {
AUTHORIZATION_GRANT_LIST_MAX_LIMIT,
type AuthorizationStore, type AuthorizationStore,
AuthorizationStoreError, AuthorizationStoreError,
CORE_AUTHORIZATION_GRANTS_MANAGE, CORE_AUTHORIZATION_GRANTS_MANAGE,
type GrantPageBoundary,
type GrantRecord, type GrantRecord,
} from "@ekklesia/core/authorization"; } from "@ekklesia/core/authorization";
import { Hono } from "hono"; import { Hono } from "hono";
@@ -21,17 +22,41 @@ import {
requirePermission, requirePermission,
requireTrustedOrigin, requireTrustedOrigin,
} from "./authorization-middleware.js"; } from "./authorization-middleware.js";
import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js";
export interface CreateGrantsRoutesOptions extends AuthorizationMiddlewareOptions { export interface CreateGrantsRoutesOptions extends AuthorizationMiddlewareOptions {
readonly store: AuthorizationStore; readonly store: AuthorizationStore;
readonly trustedOrigins: ReadonlySet<string>; readonly trustedOrigins: ReadonlySet<string>;
/** Injected only so expiration validation is deterministic under test. Production composition
* leaves this unset and the routes read real time. */
readonly now?: () => Date;
} }
const DEFAULT_LIST_LIMIT = 50;
function sanitizedStoreErrorResponse(c: Context, error: unknown): Response { function sanitizedStoreErrorResponse(c: Context, error: unknown): Response {
c.header("Cache-Control", "no-store"); c.header("Cache-Control", "no-store");
if (error instanceof AuthorizationStoreError) { if (error instanceof AuthorizationStoreError) {
const status = error.code === "AUTHORIZATION_GRANT_NOT_FOUND" ? 404 : 503; if (error.code === "AUTHORIZATION_GRANT_NOT_FOUND") {
return c.json({ code: error.code }, status); return c.json({ code: error.code }, 404);
}
// The one conflict the caller can act on. It deliberately reveals nothing about the grant that
// already exists — not its id, actor, scope, source reference, or expiry — only that an
// equivalent authorization is already effective for this subject.
if (error.code === "AUTHORIZATION_GRANT_ALREADY_EXISTS") {
return c.json({ code: error.code }, 409);
}
// Defense in depth: the routes below reject both of these before ever reaching the store, so
// arriving here means a request-shaped problem slipped through. It is still a client input
// failure, never an infrastructure one, and it reports exactly like any other malformed
// request rather than leaking the store's internal distinction.
if (
error.code === "AUTHORIZATION_GRANT_EXPIRATION_INVALID" ||
error.code === "AUTHORIZATION_GRANT_LIMIT_INVALID"
) {
return c.json({ code: "INVALID_REQUEST" }, 400);
}
return c.json({ code: error.code }, 503);
} }
return c.json({ code: "INTERNAL_ERROR" }, 503); return c.json({ code: "INTERNAL_ERROR" }, 503);
} }
@@ -39,20 +64,55 @@ function sanitizedStoreErrorResponse(c: Context, error: unknown): Response {
interface ParsedCreateGrantBody { interface ParsedCreateGrantBody {
readonly expiresAt?: Date; readonly expiresAt?: Date;
readonly permission: PermissionId; readonly permission: PermissionId;
readonly scope?: AuthorizationScope;
readonly subjectUserId: EkklesiaUserId; readonly subjectUserId: EkklesiaUserId;
} }
const CREATE_GRANT_ALLOWED_KEYS = new Set(["subjectUserId", "permission", "scope", "expiresAt"]); const CREATE_GRANT_ALLOWED_KEYS = new Set(["subjectUserId", "permission", "expiresAt"]);
/** /**
* Accepts only `subjectUserId`, `permission`, an optional `scope`, and an optional `expiresAt`. * The one accepted expiration representation: `YYYY-MM-DDTHH:mm:ss.sssZ`, byte-for-byte equal to
* Any other key present — `actor`, `sourceKind`, `sourceRef`, any audit field, `createdAt`, * the parsed `Date`'s own `toISOString()`.
* `revokedAt`, a grant id, or anything claiming owner/effective-permission state — makes the *
* whole body invalid rather than being silently dropped, so a caller cannot smuggle * `new Date(value)` alone was far too permissive. It silently *normalized* impossible dates
* server-derived fields through a request that otherwise looks well-formed. * (`2026-02-31T00:00:00.000Z` became March 3rd) and accepted locale-shaped input
* (`"March 5, 2027"`), date-only values, offsets, and missing milliseconds — so what an operator
* typed and what the platform stored could differ without any error. The regular expression pins
* the lexical form and the round-trip comparison rejects every value the parser would otherwise
* quietly rewrite.
*/ */
function parseCreateGrantBody(raw: unknown): ParsedCreateGrantBody | undefined { const CANONICAL_INSTANT_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u;
function parseCanonicalFutureInstant(value: unknown, now: Date): Date | undefined {
if (typeof value !== "string" || !CANONICAL_INSTANT_PATTERN.test(value)) {
return undefined;
}
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime()) || parsed.toISOString() !== value) {
return undefined;
}
// Strictly future: an expiration equal to or before the acceptance clock produces a grant that
// is already dead, which is never what the caller meant.
if (parsed.getTime() <= now.getTime()) {
return undefined;
}
return parsed;
}
/**
* Accepts only `subjectUserId`, `permission`, and an optional `expiresAt`. Any other key present —
* `actor`, `sourceKind`, `sourceRef`, any audit field, `createdAt`, `revokedAt`, a grant id, or
* anything claiming owner/effective-permission state — makes the whole body invalid rather than
* being silently dropped, so a caller cannot smuggle server-derived fields through a request that
* otherwise looks well-formed.
*
* **`scope` is deliberately among the rejected keys**, including when supplied as `null`, empty, or
* an otherwise valid scope string. Core's scoped-grant semantics remain intact for trusted internal
* callers, but no HTTP route middleware supplies a scope to the evaluator today, so a scope
* accepted here would produce a grant that looks meaningful, persists, appears in listings, and can
* authorize nothing at all. Accepting it would be offering a switch that is not connected to
* anything. When a server-owned resolver exists, this is the one place that changes.
*/
function parseCreateGrantBody(raw: unknown, now: Date): ParsedCreateGrantBody | undefined {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
return undefined; return undefined;
} }
@@ -70,31 +130,138 @@ function parseCreateGrantBody(raw: unknown): ParsedCreateGrantBody | undefined {
return undefined; return undefined;
} }
let scope: AuthorizationScope | undefined;
if (body.scope !== undefined) {
const parsedScope = safeParseAuthorizationScope(body.scope);
if (!parsedScope.success) {
return undefined;
}
scope = parsedScope.data;
}
let expiresAt: Date | undefined; let expiresAt: Date | undefined;
if (body.expiresAt !== undefined) { if (body.expiresAt !== undefined) {
if (typeof body.expiresAt !== "string") { expiresAt = parseCanonicalFutureInstant(body.expiresAt, now);
if (expiresAt === undefined) {
return undefined; return undefined;
} }
const parsedDate = new Date(body.expiresAt);
if (Number.isNaN(parsedDate.getTime())) {
return undefined;
}
expiresAt = parsedDate;
} }
return Object.freeze({ return Object.freeze({
...(expiresAt === undefined ? {} : { expiresAt }), ...(expiresAt === undefined ? {} : { expiresAt }),
permission: permission.data, permission: permission.data,
...(scope === undefined ? {} : { scope }), subjectUserId: subjectUserId.data,
});
}
const CURSOR_VERSION = 1;
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/u;
/**
* An opaque pagination *position*, not an authorization token. It carries only the subject user id,
* the boundary row's creation instant, and the boundary grant id — never a permission, scope,
* source reference, actor, credential, or secret — so there is nothing in it worth protecting.
*
* It is deliberately unsigned. Signing would imply the cursor carries authority it does not have:
* the permission check and the server-side subject filter remain authoritative on every page, and
* a forged cursor can at most name a different position within grants the caller is already
* entitled to list. The strict decode below still refuses anything that is not exactly what this
* function would emit.
*/
function encodeCursor(userId: EkklesiaUserId, boundary: GrantPageBoundary): string {
return Buffer.from(
JSON.stringify({
g: boundary.id,
t: boundary.createdAt.toISOString(),
u: userId,
v: CURSOR_VERSION,
}),
"utf8",
).toString("base64url");
}
/**
* Strict in every dimension: canonical unpadded base64url, valid UTF-8 JSON, an object with exactly
* the four expected keys, the exact version, a subject matching the *requested* one, a canonical
* `toISOString()` timestamp, a canonical `GrantId`, and — the catch-all — a re-encoding that
* reproduces the supplied string byte-for-byte, which rejects padding, trailing data, and any
* alternative encoding of the same payload. A rejected cursor reveals nothing it decoded.
*/
function decodeCursor(raw: string, userId: EkklesiaUserId): GrantPageBoundary | undefined {
if (!BASE64URL_PATTERN.test(raw)) {
return undefined;
}
let parsed: unknown;
try {
parsed = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
} catch {
return undefined;
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return undefined;
}
const payload = parsed as Record<string, unknown>;
if (Object.keys(payload).sort().join(",") !== "g,t,u,v") {
return undefined;
}
if (payload.v !== CURSOR_VERSION || payload.u !== userId || typeof payload.t !== "string") {
return undefined;
}
const createdAt = new Date(payload.t);
if (Number.isNaN(createdAt.getTime()) || createdAt.toISOString() !== payload.t) {
return undefined;
}
const id = safeParseGrantId(payload.g);
if (!id.success) {
return undefined;
}
const boundary: GrantPageBoundary = Object.freeze({ createdAt, id: id.data });
if (encodeCursor(userId, boundary) !== raw) {
return undefined;
}
return boundary;
}
const LIST_ALLOWED_QUERY_KEYS = new Set(["userId", "limit", "cursor"]);
/** Canonical base-10, no sign, no leading zero, no decimal point, no whitespace, no exponent. */
const CANONICAL_POSITIVE_INTEGER_PATTERN = /^[1-9][0-9]*$/u;
interface ParsedListQuery {
readonly after?: GrantPageBoundary;
readonly limit: number;
readonly subjectUserId: EkklesiaUserId;
}
/** Fully validated before any store or database work happens, so an invalid page request never
* becomes a query, and never becomes a way to probe which subjects or grants exist. */
function parseListQuery(url: string): ParsedListQuery | undefined {
const searchParams = new URL(url).searchParams;
for (const key of searchParams.keys()) {
if (!LIST_ALLOWED_QUERY_KEYS.has(key) || searchParams.getAll(key).length > 1) {
return undefined;
}
}
const subjectUserId = safeParseEkklesiaUserId(searchParams.get("userId"));
if (!subjectUserId.success) {
return undefined;
}
let limit = DEFAULT_LIST_LIMIT;
const rawLimit = searchParams.get("limit");
if (rawLimit !== null) {
if (!CANONICAL_POSITIVE_INTEGER_PATTERN.test(rawLimit)) {
return undefined;
}
limit = Number(rawLimit);
if (!Number.isSafeInteger(limit) || limit < 1 || limit > AUTHORIZATION_GRANT_LIST_MAX_LIMIT) {
return undefined;
}
}
let after: GrantPageBoundary | undefined;
const rawCursor = searchParams.get("cursor");
if (rawCursor !== null) {
after = decodeCursor(rawCursor, subjectUserId.data);
if (after === undefined) {
return undefined;
}
}
return Object.freeze({
...(after === undefined ? {} : { after }),
limit,
subjectUserId: subjectUserId.data, subjectUserId: subjectUserId.data,
}); });
} }
@@ -124,29 +291,55 @@ function serializeGrant(grant: GrantRecord) {
* `sourceKind` at all), and revocation is constrained to `sourceKind: "explicit"`, so attempting * `sourceKind` at all), and revocation is constrained to `sourceKind: "explicit"`, so attempting
* to revoke a relationship-derived grant by id behaves exactly like the grant does not exist — * to revoke a relationship-derived grant by id behaves exactly like the grant does not exist —
* those grants remain owned by the module operation that established the relationship. * those grants remain owned by the module operation that established the relationship.
*
* Creation is unscoped-only; see `parseCreateGrantBody`.
*/ */
export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono { export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono {
const routes = new Hono(); const routes = new Hono();
const now = options.now ?? ((): Date => new Date());
const requireManagePermission = requirePermission(options, CORE_AUTHORIZATION_GRANTS_MANAGE); const requireManagePermission = requirePermission(options, CORE_AUTHORIZATION_GRANTS_MANAGE);
const requireOrigin = requireTrustedOrigin(options.trustedOrigins); const requireOrigin = requireTrustedOrigin(options.trustedOrigins);
// Origin is checked first (a rejected cross-origin mutation must never have its body read), then
// the body-size limit runs before permission evaluation, so an oversized mutation never reaches
// authorization work or the store.
const mutationBodyLimit = platformBodyLimit(CORE_MUTATION_MAX_BODY_BYTES);
routes.get("/api/v1/grants", requireManagePermission, async (c) => { routes.get("/api/v1/grants", requireManagePermission, async (c) => {
c.header("Cache-Control", "no-store"); c.header("Cache-Control", "no-store");
const subjectUserId = safeParseEkklesiaUserId(c.req.query("userId")); // Reached only after `requireManagePermission` allowed the caller, so query validation can
if (!subjectUserId.success) { // never disclose a validation distinction — or the existence of any subject or grant — to an
// unauthorized caller.
const query = parseListQuery(c.req.url);
if (!query) {
return c.json({ code: "INVALID_REQUEST" }, 400); return c.json({ code: "INVALID_REQUEST" }, 400);
} }
try { try {
const grants = await options.store.listGrantsForUser(subjectUserId.data, { const page = await options.store.listGrantsForUser(query.subjectUserId, {
...(query.after === undefined ? {} : { after: query.after }),
limit: query.limit,
sourceKind: "explicit", sourceKind: "explicit",
}); });
return c.json({ grants: grants.map(serializeGrant) }, 200); return c.json(
{
grants: page.grants.map(serializeGrant),
nextCursor:
page.nextBoundary === undefined
? null
: encodeCursor(query.subjectUserId, page.nextBoundary),
},
200,
);
} catch (error) { } catch (error) {
return sanitizedStoreErrorResponse(c, error); return sanitizedStoreErrorResponse(c, error);
} }
}); });
routes.post("/api/v1/grants", requireOrigin, requireManagePermission, async (c) => { routes.post(
"/api/v1/grants",
requireOrigin,
mutationBodyLimit,
requireManagePermission,
async (c) => {
c.header("Cache-Control", "no-store"); c.header("Cache-Control", "no-store");
const principal = getAuthorizedPrincipal(c); const principal = getAuthorizedPrincipal(c);
if (!isAuthenticatedPrincipal(principal)) { if (!isAuthenticatedPrincipal(principal)) {
@@ -159,7 +352,7 @@ export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono {
} catch { } catch {
return c.json({ code: "INVALID_REQUEST" }, 400); return c.json({ code: "INVALID_REQUEST" }, 400);
} }
const parsed = parseCreateGrantBody(rawBody); const parsed = parseCreateGrantBody(rawBody, now());
if (!parsed) { if (!parsed) {
return c.json({ code: "INVALID_REQUEST" }, 400); return c.json({ code: "INVALID_REQUEST" }, 400);
} }
@@ -179,7 +372,6 @@ export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono {
actor: createUserActor(principal.userId), actor: createUserActor(principal.userId),
...(parsed.expiresAt === undefined ? {} : { expiresAt: parsed.expiresAt }), ...(parsed.expiresAt === undefined ? {} : { expiresAt: parsed.expiresAt }),
permission: parsed.permission, permission: parsed.permission,
...(parsed.scope === undefined ? {} : { scope: parsed.scope }),
sourceKind: "explicit", sourceKind: "explicit",
userId: parsed.subjectUserId, userId: parsed.subjectUserId,
}); });
@@ -187,11 +379,13 @@ export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono {
} catch (error) { } catch (error) {
return sanitizedStoreErrorResponse(c, error); return sanitizedStoreErrorResponse(c, error);
} }
}); },
);
routes.post( routes.post(
"/api/v1/grants/:grantId/revoke", "/api/v1/grants/:grantId/revoke",
requireOrigin, requireOrigin,
mutationBodyLimit,
requireManagePermission, requireManagePermission,
async (c) => { async (c) => {
c.header("Cache-Control", "no-store"); c.header("Cache-Control", "no-store");
@@ -200,6 +394,9 @@ export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono {
return c.json({ code: "AUTHORIZATION_DENIED" }, 403); return c.json({ code: "AUTHORIZATION_DENIED" }, 403);
} }
// Canonical-UUID validation happens after authorization but strictly before any store or
// database call: a malformed id previously reached PostgreSQL's own UUID comparison and came
// back as an indistinguishable infrastructure failure (503) rather than a request problem.
const grantId = safeParseGrantId(c.req.param("grantId")); const grantId = safeParseGrantId(c.req.param("grantId"));
if (!grantId.success) { if (!grantId.success) {
return c.json({ code: "INVALID_REQUEST" }, 400); return c.json({ code: "INVALID_REQUEST" }, 400);
@@ -208,7 +405,7 @@ export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono {
try { try {
await options.store.revokeGrant({ await options.store.revokeGrant({
actor: createUserActor(principal.userId), actor: createUserActor(principal.userId),
grantId: grantId.data, grantId: grantId.data satisfies GrantId,
sourceKind: "explicit", sourceKind: "explicit",
}); });
return c.json({ success: true }, 200); return c.json({ success: true }, 200);
+14 -2
View File
@@ -11,6 +11,7 @@ import {
requirePermission, requirePermission,
requireTrustedOrigin, requireTrustedOrigin,
} from "./authorization-middleware.js"; } from "./authorization-middleware.js";
import { platformBodyLimit } from "./body-limits.js";
const MODULE_ROUTE_PREFIX = "/api/v1/modules"; const MODULE_ROUTE_PREFIX = "/api/v1/modules";
const STATE_CHANGING_METHODS: ReadonlySet<string> = new Set(["DELETE", "PATCH", "POST", "PUT"]); const STATE_CHANGING_METHODS: ReadonlySet<string> = new Set(["DELETE", "PATCH", "POST", "PUT"]);
@@ -187,6 +188,10 @@ export function mountModuleHttpRoutes(
} }
const requireRoutePermission = requirePermission(middlewareOptions, route.permission); const requireRoutePermission = requirePermission(middlewareOptions, route.permission);
// The route's own conscious body contract (`route.maxBodyBytes` — 0 means no body accepted at
// all) is enforced before permission evaluation ever runs, so an oversized request never
// reaches authorization work, let alone the handler or the module database.
const routeBodyLimit = platformBodyLimit(route.maxBodyBytes);
const path = moduleRoutePath(route.moduleId, route.path); const path = moduleRoutePath(route.moduleId, route.path);
const finalHandler = async (c: Context): Promise<Response> => { const finalHandler = async (c: Context): Promise<Response> => {
const context: ModuleRouteHandlerContext = Object.freeze({ const context: ModuleRouteHandlerContext = Object.freeze({
@@ -212,9 +217,16 @@ export function mountModuleHttpRoutes(
// array — each branch below is therefore a distinct, fixed-arity call rather than one call // array — each branch below is therefore a distinct, fixed-arity call rather than one call
// spreading a variable-length handlers array. // spreading a variable-length handlers array.
if (STATE_CHANGING_METHODS.has(route.method)) { if (STATE_CHANGING_METHODS.has(route.method)) {
app.on(route.method, path, requireOrigin, requireRoutePermission, finalHandler); app.on(
route.method,
path,
requireOrigin,
routeBodyLimit,
requireRoutePermission,
finalHandler,
);
} else { } else {
app.on(route.method, path, requireRoutePermission, finalHandler); app.on(route.method, path, routeBodyLimit, requireRoutePermission, finalHandler);
} }
} }
} }
+288 -9
View File
@@ -1,3 +1,4 @@
import type { Socket } from "node:net";
import path from "node:path"; import path from "node:path";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import type { ModuleRuntimeServices } from "@ekklesia/contracts"; import type { ModuleRuntimeServices } from "@ekklesia/contracts";
@@ -14,6 +15,12 @@ import {
import { serve, type ServerType } from "@hono/node-server"; import { serve, type ServerType } from "@hono/node-server";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { createApiApp } from "./app.js"; import { createApiApp } from "./app.js";
import {
type ClientAddressConfigInput,
type ClientAddressResolver,
createClientAddressResolver,
wrapFetchWithClientAddress,
} from "./client-address.js";
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js"; import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
import { type ApiDatabaseHandle, createApiDatabase } from "./database.js"; import { type ApiDatabaseHandle, createApiDatabase } from "./database.js";
import { prepareStartupModuleInventory } from "./platform-permissions.js"; import { prepareStartupModuleInventory } from "./platform-permissions.js";
@@ -21,6 +28,86 @@ import { prepareStartupModuleInventory } from "./platform-permissions.js";
const connectionTimeoutMs = 10_000; const connectionTimeoutMs = 10_000;
const operationTimeoutMs = 10_000; const operationTimeoutMs = 10_000;
export interface NodeServerTimeouts {
readonly headersTimeoutMs: number;
readonly requestTimeoutMs: number;
readonly keepAliveTimeoutMs: number;
/**
* How often Node re-scans open connections for a header/request timeout violation
* (`http.Server`'s `connectionsCheckingInterval`). This is not merely a performance knob: Node
* enforces `headersTimeout`/`requestTimeout` only when this periodic check runs, and its own
* default (30s) is verified (`node --version` 24.15.0, direct `http.createServer` reproduction)
* to let a violating connection sit for up to a full additional interval past its actual
* timeout before Node ever closes it — so leaving this at Node's default would silently widen
* both timeouts above by up to 30s in the worst case. Kept small relative to `headersTimeoutMs`
* so a violation is detected promptly after it actually elapses.
*/
readonly connectionsCheckingIntervalMs: number;
}
/**
* Finite Node HTTP server timeouts for this JSON-only API. Node's own defaults
* (`headersTimeout`: 60s, `requestTimeout`: 300s) are generous enough to let a slow-header or
* slow-body connection hold a socket for minutes with no reverse proxy in front; these values bound
* that exposure while remaining comfortable for the small JSON payloads this API ever accepts (see
* `./body-limits.js`). `headersTimeoutMs` must not exceed `requestTimeoutMs` — Node validates this
* relationship itself at server construction — so headers alone may take up to 20s, and the
* complete request (headers plus body) up to 30s, before Node itself responds `408` and closes the
* connection without ever invoking a handler (subject to `connectionsCheckingIntervalMs`'s own
* detection latency, above). `keepAliveTimeoutMs` matches Node's own default (5s) made explicit and
* typed rather than left implicit; it bounds only the idle window between requests on a reused
* connection, never an in-flight one.
*/
const PRODUCTION_NODE_SERVER_TIMEOUTS: NodeServerTimeouts = Object.freeze({
connectionsCheckingIntervalMs: 5_000,
headersTimeoutMs: 20_000,
keepAliveTimeoutMs: 5_000,
requestTimeoutMs: 30_000,
});
/**
* The two fixed operational lines a transport-level client failure may produce. Neither ever
* carries the remote address, request path, query, headers, cookies, body, forwarding chain, the
* caught error's own message, or the malformed bytes that caused it — an attacker controls all of
* those, and a parse failure's message in particular is derived directly from the rejected input.
*/
const NODE_SERVER_TIMEOUT_LOG_MESSAGE =
"[server] A connection was closed for exceeding a configured header/request timeout.";
const NODE_SERVER_CLIENT_ERROR_LOG_MESSAGE =
"[server] A connection was closed after a malformed or oversized client request.";
/**
* Node's own documented default `clientError` responses, reproduced byte-for-byte (verified
* directly against the pinned Node 24 runtime by driving each case through a real socket with no
* listener attached). Registering *any* `clientError` listener suppresses that default entirely —
* `_http_server.js` only falls back when `server.emit("clientError", ...)` returns `false` — so a
* listener that merely observes would silently turn every 408/400/431 into a bare connection reset.
* Reproducing the exact bytes is what lets the platform log the event without changing what a
* caller actually receives.
*/
const NODE_CLIENT_ERROR_RESPONSES: Readonly<Record<string, string>> = Object.freeze({
ERR_HTTP_REQUEST_TIMEOUT: "HTTP/1.1 408 Request Timeout\r\nConnection: close\r\n\r\n",
HPE_HEADER_OVERFLOW: "HTTP/1.1 431 Request Header Fields Too Large\r\nConnection: close\r\n\r\n",
});
const NODE_CLIENT_ERROR_DEFAULT_RESPONSE = "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n";
/**
* Mirrors Node's own `socketOnError` fallback exactly: write the code-appropriate response only
* when the socket is still writable *and* no response has already begun for the in-flight request,
* then destroy the socket. The `_httpMessage` guard is what prevents a second, corrupting status
* line when a handler already responded and only the (unread) request body subsequently timed out.
*/
function writeNodeClientErrorResponse(error: NodeJS.ErrnoException, socket: Socket): void {
const inFlightResponse = (socket as Socket & { _httpMessage?: { headersSent?: boolean } })
._httpMessage;
const alreadyResponding = inFlightResponse?.headersSent === true;
if (socket.writable && !alreadyResponding) {
const code = typeof error.code === "string" ? error.code : "";
socket.write(NODE_CLIENT_ERROR_RESPONSES[code] ?? NODE_CLIENT_ERROR_DEFAULT_RESPONSE);
}
socket.destroy();
}
export interface RunningApi { export interface RunningApi {
readonly app: Hono; readonly app: Hono;
readonly server: ServerType; readonly server: ServerType;
@@ -39,6 +126,17 @@ export interface StartApiOptions {
* runtime startup. Never set by the executable entry point. See * runtime startup. Never set by the executable entry point. See
* `prepareStartupModuleInventory`. */ * `prepareStartupModuleInventory`. */
readonly discoveredModulesOverride?: readonly DiscoveredModule[]; readonly discoveredModulesOverride?: readonly DiscoveredModule[];
/** Test-only escape hatch: narrows the Node header/request/keep-alive timeouts so a transport
* test can prove real timeout behavior deterministically without waiting out the production
* values. Never set by the executable entry point. */
readonly nodeServerTimeoutOverride?: Partial<NodeServerTimeouts>;
/** Test-only escape hatch: narrows the graceful HTTP drain deadline
* (`HTTP_DRAIN_TIMEOUT_MS`) so a forced close can be proven in milliseconds. Never set by the
* executable entry point. */
readonly drainTimeoutMsOverride?: number;
/** Test-only escape hatch: narrows the per-module stop/rollback hook deadline
* (`MODULE_STOP_HOOK_TIMEOUT_MS`). Never set by the executable entry point. */
readonly moduleHookTimeoutMsOverride?: number;
} }
async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise<void> { async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise<void> {
@@ -61,14 +159,146 @@ async function safeStopModuleRuntime(moduleRuntime: StartedModuleRuntime): Promi
} }
} }
function bindServer(app: Hono, host: string, port: number): Promise<ServerType> { /**
* The platform's fixed graceful-drain deadline. In-flight HTTP work has this long to finish after
* the listener stops accepting new connections; after it, connections are closed so process
* shutdown stays bounded. Not configurable in this slice.
*/
export const HTTP_DRAIN_TIMEOUT_MS = 30_000;
/**
* A shutdown that did not complete cleanly. Carries only fixed booleans and the already-sanitized
* module-runtime error — never a request, socket, URL, credential, SQL, or stack detail — so an
* operator can see *which* stages failed without any of it becoming a disclosure channel.
*/
export class ApiShutdownError extends Error {
readonly drainTimedOut: boolean;
readonly moduleStopFailed: boolean;
readonly moduleStopError: unknown;
constructor(drainTimedOut: boolean, moduleStopError: unknown) {
super("The API did not shut down cleanly.");
this.name = "ApiShutdownError";
this.drainTimedOut = drainTimedOut;
this.moduleStopFailed = moduleStopError !== undefined;
this.moduleStopError = moduleStopError;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
/**
* Stops accepting new connections, then waits up to `timeoutMs` for existing HTTP work to finish.
*
* At the deadline it closes idle and then still-active connections using Node's own supported
* public `Server` APIs (`closeIdleConnections`/`closeAllConnections`, both called only after
* `server.close()`), and stops waiting for the close callback. No private socket or server field is
* touched.
*
* Force-closing a connection ends the transport. It does **not** prove that arbitrary in-process
* handler JavaScript was canceled — a handler promise may still be running and may still settle.
* What is guaranteed is that shutdown no longer waits for it, and that the pool closes afterwards,
* so its late database work fails.
*/
/** `ServerType` is a union that also covers HTTP/2 servers, which do not expose these two methods.
* `serve()` here always creates a plain `http.Server`, which does; narrowing with a real capability
* check keeps that honest instead of asserting a cast. */
interface ConnectionClosingServer {
closeAllConnections(): void;
closeIdleConnections(): void;
}
function supportsConnectionClosing(
server: ServerType,
): server is ServerType & ConnectionClosingServer {
const candidate = server as Partial<ConnectionClosingServer>;
return (
typeof candidate.closeIdleConnections === "function" &&
typeof candidate.closeAllConnections === "function"
);
}
async function drainHttpServer(
server: ServerType,
timeoutMs: number,
): Promise<"drained" | "forced"> {
let timer: ReturnType<typeof setTimeout> | undefined;
const closed = new Promise<void>((resolve) => {
server.close(() => resolve());
});
try {
const outcome = await Promise.race([
closed.then(() => "drained" as const),
new Promise<"forced">((resolve) => {
timer = setTimeout(() => resolve("forced"), timeoutMs);
}),
]);
if (outcome === "drained") {
return "drained";
}
if (supportsConnectionClosing(server)) {
server.closeIdleConnections();
server.closeAllConnections();
}
return "forced";
} finally {
clearTimeout(timer);
// The close callback may still fire later; nothing awaits it, and it carries no rejection.
}
}
/**
* Binds the Node HTTP server. Two platform-owned transport concerns are applied at this exact
* boundary, below Hono entirely: `wrapFetchWithClientAddress` resolves and sanitizes the caller's
* address from the real socket peer before any route (including Better Auth's own rate limiter)
* ever sees the request (see `./client-address.js`), and `serverOptions` gives Node finite
* header/request/keep-alive timeouts so a slow-header or slow-body connection cannot hold a socket
* indefinitely. A connection Node itself closes for exceeding one of these timeouts never reaches
* `app.fetch` and never receives a second response — both are Node's own documented behavior for
* `headersTimeout`/`requestTimeout`, not something this function re-implements.
*/
function bindServer(
app: Hono,
host: string,
port: number,
clientAddressResolver: ClientAddressResolver,
timeouts: NodeServerTimeouts,
): Promise<ServerType> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
let settled = false; let settled = false;
const server = serve({ fetch: app.fetch, hostname: host, port }, () => { const server = serve(
{
fetch: wrapFetchWithClientAddress(app.fetch, clientAddressResolver),
hostname: host,
port,
serverOptions: {
connectionsCheckingInterval: timeouts.connectionsCheckingIntervalMs,
headersTimeout: timeouts.headersTimeoutMs,
keepAliveTimeout: timeouts.keepAliveTimeoutMs,
requestTimeout: timeouts.requestTimeoutMs,
},
},
() => {
if (!settled) { if (!settled) {
settled = true; settled = true;
resolve(server); resolve(server);
} }
},
);
// `headersTimeout`/`requestTimeout` do NOT surface as the server's general `'timeout'` event —
// that event only fires for `server.setTimeout()`'s idle-socket timer, which this server never
// arms. Verified directly against the pinned Node 24 runtime by driving a real slow-header and
// slow-body connection through a real socket: the only server-level event either produces is
// `'clientError'`, carrying `err.code === "ERR_HTTP_REQUEST_TIMEOUT"`. Observing the failure
// therefore has to happen here, and — because registering a listener suppresses Node's own
// default response — this handler must reproduce that default itself.
server.on("clientError", (error: NodeJS.ErrnoException, socket: Socket) => {
console.error(
error.code === "ERR_HTTP_REQUEST_TIMEOUT"
? NODE_SERVER_TIMEOUT_LOG_MESSAGE
: NODE_SERVER_CLIENT_ERROR_LOG_MESSAGE,
);
writeNodeClientErrorResponse(error, socket);
}); });
server.once("error", () => { server.once("error", () => {
if (!settled) { if (!settled) {
@@ -125,6 +355,10 @@ export async function startApi(
const databaseHandle = createApiDatabase(config.databaseUrl, { const databaseHandle = createApiDatabase(config.databaseUrl, {
connectionTimeoutMs, connectionTimeoutMs,
operationTimeoutMs, operationTimeoutMs,
// The one validated transport policy reaches this runtime's single physical pool here; the
// identity, authorization, and module Drizzle views are all typed views over that same pool,
// so TLS never introduces a second connection.
transport: config.databaseTransport,
}); });
let services: ModuleRuntimeServices; let services: ModuleRuntimeServices;
@@ -153,7 +387,11 @@ export async function startApi(
// handle to stop here, only the pool this failure leaves open. // handle to stop here, only the pool this failure leaves open.
let moduleRuntime: StartedModuleRuntime; let moduleRuntime: StartedModuleRuntime;
try { try {
moduleRuntime = await startDiscoveredModuleRuntime(inventory.modules, services); moduleRuntime = await startDiscoveredModuleRuntime(inventory.modules, services, {
...(options.moduleHookTimeoutMsOverride === undefined
? {}
: { hookTimeoutMs: options.moduleHookTimeoutMsOverride }),
});
} catch (error) { } catch (error) {
await safeCloseDatabase(databaseHandle); await safeCloseDatabase(databaseHandle);
throw error; throw error;
@@ -178,9 +416,15 @@ export async function startApi(
throw error; throw error;
} }
const clientAddressResolver = createClientAddressResolver(config.clientAddress);
const timeouts: NodeServerTimeouts = {
...PRODUCTION_NODE_SERVER_TIMEOUTS,
...options.nodeServerTimeoutOverride,
};
let server: ServerType; let server: ServerType;
try { try {
server = await bindServer(app, config.host, config.port); server = await bindServer(app, config.host, config.port, clientAddressResolver, timeouts);
} catch (error) { } catch (error) {
await safeStopModuleRuntime(moduleRuntime); await safeStopModuleRuntime(moduleRuntime);
await safeCloseDatabase(databaseHandle); await safeCloseDatabase(databaseHandle);
@@ -200,13 +444,12 @@ export async function startApi(
// code can run against it afterward. Unlike the startup-cleanup paths above, a module-stop // code can run against it afterward. Unlike the startup-cleanup paths above, a module-stop
// failure here is not swallowed — it is a genuine shutdown result a caller should observe — but // failure here is not swallowed — it is a genuine shutdown result a caller should observe — but
// database cleanup still always runs first, so a stop failure never leaves the pool open. // database cleanup still always runs first, so a stop failure never leaves the pool open.
const drainTimeoutMs = options.drainTimeoutMsOverride ?? HTTP_DRAIN_TIMEOUT_MS;
let shutdownPromise: Promise<void> | undefined; let shutdownPromise: Promise<void> | undefined;
const shutdown = (): Promise<void> => { const shutdown = (): Promise<void> => {
if (!shutdownPromise) { if (!shutdownPromise) {
shutdownPromise = (async () => { shutdownPromise = (async () => {
await new Promise<void>((resolve) => { const drainOutcome = await drainHttpServer(server, drainTimeoutMs);
server.close(() => resolve());
});
let moduleStopError: unknown; let moduleStopError: unknown;
try { try {
await moduleRuntime.stop(); await moduleRuntime.stop();
@@ -214,8 +457,14 @@ export async function startApi(
moduleStopError = error; moduleStopError = error;
} }
await safeCloseDatabase(databaseHandle); await safeCloseDatabase(databaseHandle);
if (moduleStopError) { // Every stage above ran regardless of what the earlier ones did, so a drain timeout or a
throw moduleStopError; // module-stop failure can never leave the pool open. Both failures are reported together,
// as fixed metadata: a forced drain is a genuine shutdown failure, not a success.
if (drainOutcome === "forced" || moduleStopError) {
throw new ApiShutdownError(
drainOutcome === "forced",
moduleStopError === undefined ? undefined : moduleStopError,
);
} }
})(); })();
} }
@@ -250,6 +499,25 @@ export function installShutdownSignalHandlers(
process.on("SIGTERM", handleSignal); process.on("SIGTERM", handleSignal);
} }
function readClientAddressInputFromEnvironment(): ClientAddressConfigInput | undefined {
const mode = process.env.EKKLESIA_CLIENT_ADDRESS_MODE;
if (mode === undefined) {
// Omitted entirely: `createClientAddressConfig` defaults to direct mode. Reading `header`/
// `trustedProxies` only when `mode` is actually set keeps an operator from accidentally
// half-configuring proxy mode by setting only one of the three variables.
return undefined;
}
const trustedProxiesValue = process.env.EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES;
return {
header: process.env.EKKLESIA_CLIENT_ADDRESS_HEADER,
mode,
trustedProxies: trustedProxiesValue
?.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0),
};
}
function readConfigFromEnvironment(): { function readConfigFromEnvironment(): {
readonly input: Parameters<typeof createApiRuntimeConfig>[0]; readonly input: Parameters<typeof createApiRuntimeConfig>[0];
readonly environment: "development" | "production"; readonly environment: "development" | "production";
@@ -259,14 +527,25 @@ function readConfigFromEnvironment(): {
?.split(",") ?.split(",")
.map((origin) => origin.trim()) .map((origin) => origin.trim())
.filter((origin) => origin.length > 0); .filter((origin) => origin.length > 0);
const clientAddress = readClientAddressInputFromEnvironment();
return { return {
environment: process.env.NODE_ENV === "development" ? "development" : "production", environment: process.env.NODE_ENV === "development" ? "development" : "production",
input: { input: {
...(clientAddress === undefined ? {} : { clientAddress }),
databaseTransport: {
...(process.env.EKKLESIA_DATABASE_CA === undefined
? {}
: { certificateAuthority: process.env.EKKLESIA_DATABASE_CA }),
...(process.env.EKKLESIA_DATABASE_TLS_MODE === undefined
? {}
: { mode: process.env.EKKLESIA_DATABASE_TLS_MODE }),
},
databaseUrl: process.env.DATABASE_URL, databaseUrl: process.env.DATABASE_URL,
host: process.env.EKKLESIA_API_HOST, host: process.env.EKKLESIA_API_HOST,
identity: { identity: {
baseUrl: process.env.EKKLESIA_IDENTITY_BASE_URL, baseUrl: process.env.EKKLESIA_IDENTITY_BASE_URL,
publicSignupEnabled: process.env.EKKLESIA_PUBLIC_SIGNUP_ENABLED,
secret: process.env.EKKLESIA_IDENTITY_SECRET, secret: process.env.EKKLESIA_IDENTITY_SECRET,
trustedOrigins, trustedOrigins,
}, },
File diff suppressed because it is too large Load Diff
+131 -1
View File
@@ -14,6 +14,7 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { IdentityRuntimeError } from "@ekklesia/core/identity"; import { IdentityRuntimeError } from "@ekklesia/core/identity";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { createApiApp, type CreateApiAppAuthorizationOptions } from "../src/app.js"; import { createApiApp, type CreateApiAppAuthorizationOptions } from "../src/app.js";
import { AUTH_REQUEST_MAX_BODY_BYTES } from "../src/body-limits.js";
const trustedOrigin = "https://dashboard.example.test"; const trustedOrigin = "https://dashboard.example.test";
const untrustedOrigin = "https://evil.example.test"; const untrustedOrigin = "https://evil.example.test";
@@ -61,7 +62,7 @@ function fakeAuthorization(overrides: {
grantPlatformOwner: vi.fn(), grantPlatformOwner: vi.fn(),
hasActiveGrant: vi.fn().mockResolvedValue(false), hasActiveGrant: vi.fn().mockResolvedValue(false),
isPlatformOwner: vi.fn().mockResolvedValue(false), isPlatformOwner: vi.fn().mockResolvedValue(false),
listGrantsForUser: vi.fn().mockResolvedValue([]), listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
revokeGrant: vi.fn(), revokeGrant: vi.fn(),
revokePlatformOwner: vi.fn(), revokePlatformOwner: vi.fn(),
...overrides.store, ...overrides.store,
@@ -207,6 +208,135 @@ describe("Ekklesia API Hono application", () => {
expect(JSON.stringify(body)).not.toContain("postgresql://"); expect(JSON.stringify(body)).not.toContain("postgresql://");
}); });
it("forces Cache-Control: no-store on every Better Auth response, including a successful one", async () => {
const runtime = fakeRuntime({
handleAuthRequest: vi.fn().mockResolvedValue(new Response("auth-ok", { status: 200 })),
});
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/auth/sign-in/email", { method: "POST" });
expect(response.status).toBe(200);
expect(await response.text()).toBe("auth-ok");
expect(response.headers.get("cache-control")).toBe("no-store");
});
describe("auth request body limit", () => {
it("accepts a request exactly at the auth body limit", async () => {
const runtime = fakeRuntime();
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/auth/sign-in/email", {
body: "a".repeat(AUTH_REQUEST_MAX_BODY_BYTES),
headers: { "content-length": String(AUTH_REQUEST_MAX_BODY_BYTES) },
method: "POST",
});
expect(response.status).toBe(200);
expect(runtime.handleAuthRequest).toHaveBeenCalledTimes(1);
});
it("rejects a request one byte over the auth body limit with a sanitized 413, never invoking Better Auth", async () => {
const runtime = fakeRuntime();
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
});
const response = await app.request("/api/auth/sign-in/email", {
body: "a".repeat(AUTH_REQUEST_MAX_BODY_BYTES + 1),
headers: { "content-length": String(AUTH_REQUEST_MAX_BODY_BYTES + 1) },
method: "POST",
});
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
expect(response.headers.get("cache-control")).toBe("no-store");
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
});
it("rejects an oversized declared Content-Length from its header alone, without invoking Better Auth", async () => {
// A declared-oversized Content-Length is rejected synchronously from the header value itself
// (`hono/body-limit`'s pinned implementation never reads the stream for this case) — proven
// here by the outcome (413, Better Auth never invoked) rather than by instrumenting the
// stream, since this in-memory test harness's own `Request`/body plumbing may touch the
// stream for reasons unrelated to `bodyLimit`'s own logic.
const runtime = fakeRuntime();
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
});
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode("a"));
controller.close();
},
});
const response = await app.request("/api/auth/sign-in/email", {
body: stream,
duplex: "half",
headers: { "content-length": String(AUTH_REQUEST_MAX_BODY_BYTES * 10) },
method: "POST",
} as RequestInit);
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
});
it("rejects an oversized streamed body with no declared Content-Length", async () => {
const runtime = fakeRuntime();
const app = createApiApp({
moduleRoutes: Object.freeze([]),
moduleServices: fakeModuleServices(),
authorization: fakeAuthorization({}),
runtime,
trustedOrigins: [trustedOrigin],
});
const chunk = new Uint8Array(1024).fill(97);
let enqueued = 0;
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
if (enqueued > AUTH_REQUEST_MAX_BODY_BYTES) {
controller.close();
return;
}
enqueued += chunk.length;
controller.enqueue(chunk);
},
});
const response = await app.request("/api/auth/sign-in/email", {
body: stream,
duplex: "half",
method: "POST",
} as RequestInit);
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
});
});
it("has deterministic 404 behavior for an unknown route", async () => { it("has deterministic 404 behavior for an unknown route", async () => {
const app = createApiApp({ const app = createApiApp({
moduleRoutes: Object.freeze([]), moduleRoutes: Object.freeze([]),
+113
View File
@@ -0,0 +1,113 @@
import { TRUSTED_CLIENT_ADDRESS_HEADER } from "@ekklesia/core/identity";
import { describe, expect, it, vi } from "vitest";
import {
CLIENT_ADDRESS_UNRESOLVED_LOG_MESSAGE,
createClientAddressResolver,
wrapFetchWithClientAddress,
} from "../src/client-address.js";
function fakeEnv(remoteAddress: string | undefined) {
return {
incoming: { socket: { remoteAddress } },
outgoing: {},
} as unknown as Parameters<typeof wrapFetchWithClientAddress>[0] extends (
request: Request,
env: infer Env,
) => unknown
? Env
: never;
}
describe("wrapFetchWithClientAddress", () => {
it("resolves the trusted address from the real socket peer and sets the internal header before calling the wrapped callback", async () => {
const resolver = createClientAddressResolver({ mode: "direct" });
const wrappedFetch = vi.fn().mockResolvedValue(new Response("ok"));
const wrapped = wrapFetchWithClientAddress(wrappedFetch, resolver);
const request = new Request("https://example.test/api/auth/sign-in/email");
await wrapped(request, fakeEnv("203.0.113.9"));
expect(wrappedFetch).toHaveBeenCalledTimes(1);
const [forwardedRequest] = wrappedFetch.mock.calls[0] as [Request];
expect(forwardedRequest.headers.get(TRUSTED_CLIENT_ADDRESS_HEADER)).toBe("203.0.113.9");
});
it("overwrites — never appends to — a caller-supplied copy of the internal header", async () => {
const resolver = createClientAddressResolver({ mode: "direct" });
const wrappedFetch = vi.fn().mockResolvedValue(new Response("ok"));
const wrapped = wrapFetchWithClientAddress(wrappedFetch, resolver);
const request = new Request("https://example.test/api/auth/sign-in/email", {
headers: { [TRUSTED_CLIENT_ADDRESS_HEADER]: "203.0.113.250" },
});
await wrapped(request, fakeEnv("192.0.2.1"));
const [forwardedRequest] = wrappedFetch.mock.calls[0] as [Request];
expect(forwardedRequest.headers.get(TRUSTED_CLIENT_ADDRESS_HEADER)).toBe("192.0.2.1");
// A single value, never a comma-joined "caller-value, resolved-value" — set(), not append().
expect(forwardedRequest.headers.get(TRUSTED_CLIENT_ADDRESS_HEADER)).not.toContain(",");
});
it("returns a sanitized 503 and never invokes the wrapped callback when resolution fails", async () => {
const resolver = createClientAddressResolver({ mode: "direct" });
const wrappedFetch = vi.fn().mockResolvedValue(new Response("should not be reached"));
const wrapped = wrapFetchWithClientAddress(wrappedFetch, resolver);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
try {
const response = await wrapped(
new Request("https://example.test/api/auth/sign-in/email"),
fakeEnv(undefined),
);
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ code: "CLIENT_ADDRESS_UNRESOLVED" });
expect(response.headers.get("cache-control")).toBe("no-store");
expect(wrappedFetch).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith(CLIENT_ADDRESS_UNRESOLVED_LOG_MESSAGE);
} finally {
errorSpy.mockRestore();
}
});
it("never logs the peer address, headers, or request path on a resolution failure", async () => {
const resolver = createClientAddressResolver({ mode: "direct" });
const wrapped = wrapFetchWithClientAddress(vi.fn(), resolver);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
try {
await wrapped(
new Request("https://example.test/api/auth/sign-in/email", {
headers: { cookie: "super-secret-session", "x-forwarded-for": "203.0.113.1" },
}),
fakeEnv("not-a-valid-ip"),
);
const emitted = errorSpy.mock.calls.flat().map(String).join("\n");
expect(emitted).not.toContain("super-secret-session");
expect(emitted).not.toContain("203.0.113.1");
expect(emitted).not.toContain("sign-in");
expect(emitted).not.toContain("not-a-valid-ip");
} finally {
errorSpy.mockRestore();
}
});
it("preserves the request method, body, and other headers unrelated to the internal address header", async () => {
const resolver = createClientAddressResolver({ mode: "direct" });
const wrappedFetch = vi.fn().mockResolvedValue(new Response("ok"));
const wrapped = wrapFetchWithClientAddress(wrappedFetch, resolver);
const request = new Request("https://example.test/api/auth/sign-up/email", {
body: JSON.stringify({ email: "[email protected]" }),
headers: { "content-type": "application/json", origin: "https://dashboard.example.test" },
method: "POST",
});
await wrapped(request, fakeEnv("192.0.2.1"));
const [forwardedRequest] = wrappedFetch.mock.calls[0] as [Request];
expect(forwardedRequest.method).toBe("POST");
expect(forwardedRequest.headers.get("origin")).toBe("https://dashboard.example.test");
expect(await forwardedRequest.text()).toBe(JSON.stringify({ email: "[email protected]" }));
});
});
+185
View File
@@ -21,10 +21,14 @@ describe("API runtime configuration", () => {
const config = createApiRuntimeConfig(validInput(), { environment: "production" }); const config = createApiRuntimeConfig(validInput(), { environment: "production" });
expect(config).toEqual({ expect(config).toEqual({
clientAddress: { mode: "direct" },
// Production defaults to certificate-verified TLS with no configuration at all.
databaseTransport: { mode: "verify-full" },
databaseUrl: validInput().databaseUrl, databaseUrl: validInput().databaseUrl,
host: "127.0.0.1", host: "127.0.0.1",
identity: { identity: {
baseUrl: "https://identity.example.test", baseUrl: "https://identity.example.test",
publicSignupEnabled: false,
secret: validSecret, secret: validSecret,
trustedOrigins: ["https://identity.example.test"], trustedOrigins: ["https://identity.example.test"],
}, },
@@ -131,6 +135,187 @@ describe("API runtime configuration", () => {
expect(config.identity.baseUrl).toBe("http://localhost:3000"); expect(config.identity.baseUrl).toBe("http://localhost:3000");
}); });
it("defaults public signup to disabled and threads an explicit development enablement through to identity config", () => {
expect(createApiRuntimeConfig(validInput()).identity.publicSignupEnabled).toBe(false);
expect(
createApiRuntimeConfig(
{
...validInput(),
identity: {
baseUrl: "http://localhost:3000",
publicSignupEnabled: true,
secret: validSecret,
trustedOrigins: ["http://localhost:3000"],
},
},
{ environment: "development" },
).identity.publicSignupEnabled,
).toBe(true);
});
it("refuses a production runtime configuration that enables public signup, before any resource is constructed", () => {
let captured: unknown;
try {
createApiRuntimeConfig(
{
...validInput(),
identity: { ...validInput().identity, publicSignupEnabled: true },
},
{ environment: "production" },
);
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION" });
});
it("defaults client-address configuration to direct mode and threads explicit trusted-proxy configuration through", () => {
expect(createApiRuntimeConfig(validInput()).clientAddress).toEqual({ mode: "direct" });
const config = createApiRuntimeConfig({
...validInput(),
clientAddress: {
header: "x-forwarded-for",
mode: "trusted-proxy",
trustedProxies: ["10.0.0.1"],
},
});
expect(config.clientAddress).toEqual({
header: "x-forwarded-for",
mode: "trusted-proxy",
trustedProxies: ["10.0.0.1"],
});
});
describe("PostgreSQL transport policy", () => {
it("defaults to certificate-verified TLS in production and plaintext in development", () => {
expect(createApiRuntimeConfig(validInput()).databaseTransport).toEqual({
mode: "verify-full",
});
expect(
createApiRuntimeConfig(
{
...validInput(),
identity: {
baseUrl: "http://localhost:3000",
secret: validSecret,
trustedOrigins: ["http://localhost:3000"],
},
},
{ environment: "development" },
).databaseTransport,
).toEqual({ mode: "disable" });
});
it("refuses a production configuration that disables TLS", () => {
let captured: unknown;
try {
createApiRuntimeConfig(
{ ...validInput(), databaseTransport: { mode: "disable" } },
{ environment: "production" },
);
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "DATABASE_TLS_REQUIRED_IN_PRODUCTION" });
});
it("rejects a malformed or loosely spelled TLS mode in every environment, without coercion", () => {
for (const mode of [
"VERIFY-FULL",
" verify-full ",
"require",
"verify-ca",
"prefer",
"no-verify",
"true",
true,
1,
"",
]) {
for (const environment of ["production", "development"] as const) {
let captured: unknown;
try {
createApiRuntimeConfig(
{
...validInput(),
databaseTransport: { mode },
...(environment === "development"
? {
identity: {
baseUrl: "http://localhost:3000",
secret: validSecret,
trustedOrigins: ["http://localhost:3000"],
},
}
: {}),
},
{ environment },
);
} catch (error) {
captured = error;
}
expect(captured, `expected mode ${JSON.stringify(mode)} to be rejected`).toMatchObject({
code: "DATABASE_TLS_MODE_INVALID",
});
}
}
});
it("rejects a database URL carrying its own TLS parameters, which the pinned driver would let override the policy", () => {
for (const parameter of [
"sslmode=disable",
"sslmode=require",
"sslmode=verify-full",
"ssl=true",
"sslrootcert=/tmp/ca.crt",
"sslcert=/tmp/c.crt",
"sslkey=/tmp/c.key",
"uselibpqcompat=true",
]) {
let captured: unknown;
try {
createApiRuntimeConfig(
{
...validInput(),
databaseUrl: `postgresql://ekklesia:[email protected]:5432/ekklesia?${parameter}`,
},
{ environment: "production" },
);
} catch (error) {
captured = error;
}
expect(captured, `expected ${parameter} to be rejected`).toMatchObject({
code: "DATABASE_TLS_CONNECTION_STRING_CONFLICT",
});
}
});
it("never leaks the database URL, credentials, host, or CA material through a transport error", () => {
let captured: unknown;
try {
createApiRuntimeConfig(
{
...validInput(),
databaseTransport: { certificateAuthority: "-----BEGIN CERTIFICATE-----SECRET" },
databaseUrl:
"postgresql://admin:[email protected]:5432/ekklesia?sslmode=disable",
},
{ environment: "production" },
);
} catch (error) {
captured = error;
}
const publicText = `${String(captured)}\n${
captured instanceof Error ? (captured.stack ?? "") : ""
}\n${JSON.stringify(captured)}`;
expect(publicText).not.toContain("hunter2");
expect(publicText).not.toContain("db.internal.example");
expect(publicText).not.toContain("SECRET");
expect(publicText).not.toContain("sslmode");
});
});
it("never leaks the database URL, secret, or trusted origins through a thrown error", () => { it("never leaks the database URL, secret, or trusted origins through a thrown error", () => {
const hostileUrl = "postgresql://admin:[email protected]:5432/ekklesia"; const hostileUrl = "postgresql://admin:[email protected]:5432/ekklesia";
let captured: unknown; let captured: unknown;
+513 -12
View File
@@ -1,6 +1,5 @@
import { import {
createAuthenticatedPrincipal, createAuthenticatedPrincipal,
parseAuthorizationScope,
parseEkklesiaUserId, parseEkklesiaUserId,
parseGrantId, parseGrantId,
parsePermissionId, parsePermissionId,
@@ -15,6 +14,7 @@ import {
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
import { Hono } from "hono"; import { Hono } from "hono";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { CORE_MUTATION_MAX_BODY_BYTES } from "../src/body-limits.js";
import { createGrantsRoutes } from "../src/grants-routes.js"; import { createGrantsRoutes } from "../src/grants-routes.js";
const trustedOrigin = "https://dashboard.example.test"; const trustedOrigin = "https://dashboard.example.test";
@@ -49,7 +49,7 @@ function fakeStore(overrides: Partial<AuthorizationStore> = {}): AuthorizationSt
grantPlatformOwner: vi.fn(), grantPlatformOwner: vi.fn(),
hasActiveGrant: vi.fn().mockResolvedValue(false), hasActiveGrant: vi.fn().mockResolvedValue(false),
isPlatformOwner: vi.fn().mockResolvedValue(false), isPlatformOwner: vi.fn().mockResolvedValue(false),
listGrantsForUser: vi.fn().mockResolvedValue([]), listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
revokeGrant: vi.fn(), revokeGrant: vi.fn(),
revokePlatformOwner: vi.fn(), revokePlatformOwner: vi.fn(),
...overrides, ...overrides,
@@ -58,6 +58,7 @@ function fakeStore(overrides: Partial<AuthorizationStore> = {}): AuthorizationSt
function buildApp(options: { function buildApp(options: {
readonly allowed?: boolean; readonly allowed?: boolean;
readonly now?: () => Date;
readonly runtime?: EkklesiaIdentityRuntime; readonly runtime?: EkklesiaIdentityRuntime;
readonly store?: AuthorizationStore; readonly store?: AuthorizationStore;
}) { }) {
@@ -71,6 +72,7 @@ function buildApp(options: {
"/", "/",
createGrantsRoutes({ createGrantsRoutes({
evaluator, evaluator,
...(options.now === undefined ? {} : { now: options.now }),
registry: CORE_PERMISSION_REGISTRY, registry: CORE_PERMISSION_REGISTRY,
runtime, runtime,
store, store,
@@ -102,15 +104,20 @@ describe("GET /api/v1/grants", () => {
userId: subjectUserId, userId: subjectUserId,
}, },
]; ];
const store = fakeStore({ listGrantsForUser: vi.fn().mockResolvedValue(grants) }); const store = fakeStore({ listGrantsForUser: vi.fn().mockResolvedValue({ grants }) });
const { app } = buildApp({ store }); const { app } = buildApp({ store });
const response = await app.request(`/api/v1/grants?userId=${subjectUserId}`); const response = await app.request(`/api/v1/grants?userId=${subjectUserId}`);
const body = (await response.json()) as { grants: unknown[] }; const body = (await response.json()) as { grants: unknown[]; nextCursor: string | null };
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(body.grants).toHaveLength(1); expect(body.grants).toHaveLength(1);
expect(store.listGrantsForUser).toHaveBeenCalledWith(subjectUserId, { sourceKind: "explicit" }); expect(body.nextCursor).toBeNull();
// The default page size is applied server-side; `sourceKind` filtering stays server-side too.
expect(store.listGrantsForUser).toHaveBeenCalledWith(subjectUserId, {
limit: 50,
sourceKind: "explicit",
});
}); });
it("rejects a missing or malformed userId query parameter", async () => { it("rejects a missing or malformed userId query parameter", async () => {
@@ -183,7 +190,6 @@ describe("POST /api/v1/grants", () => {
createdAt: new Date("2026-01-01T00:00:00.000Z"), createdAt: new Date("2026-01-01T00:00:00.000Z"),
id: grantId, id: grantId,
permission: registeredPermission, permission: registeredPermission,
scope: parseAuthorizationScope("calendar-event:123"),
sourceKind: "explicit" as const, sourceKind: "explicit" as const,
userId: subjectUserId, userId: subjectUserId,
}; };
@@ -191,11 +197,7 @@ describe("POST /api/v1/grants", () => {
const { app } = buildApp({ store }); const { app } = buildApp({ store });
const response = await app.request("/api/v1/grants", { const response = await app.request("/api/v1/grants", {
body: JSON.stringify({ body: JSON.stringify({ permission: registeredPermission, subjectUserId }),
permission: registeredPermission,
scope: "calendar-event:123",
subjectUserId,
}),
headers: { "content-type": "application/json", origin: trustedOrigin }, headers: { "content-type": "application/json", origin: trustedOrigin },
method: "POST", method: "POST",
}); });
@@ -207,11 +209,14 @@ describe("POST /api/v1/grants", () => {
expect.objectContaining({ expect.objectContaining({
actor: { type: "user", userId: manager.userId }, actor: { type: "user", userId: manager.userId },
permission: registeredPermission, permission: registeredPermission,
scope: "calendar-event:123",
sourceKind: "explicit", sourceKind: "explicit",
userId: subjectUserId, userId: subjectUserId,
}), }),
); );
// The generic HTTP surface creates unscoped explicit grants only.
expect(store.createGrant).toHaveBeenCalledWith(
expect.not.objectContaining({ scope: expect.anything() }),
);
}); });
it("rejects a well-formed but unregistered permission with the same sanitized denial as an unauthorized caller, and never calls the store", async () => { it("rejects a well-formed but unregistered permission with the same sanitized denial as an unauthorized caller, and never calls the store", async () => {
@@ -378,3 +383,499 @@ describe("POST /api/v1/grants/:grantId/revoke", () => {
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_REVOKE_FAILED" }); expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_REVOKE_FAILED" });
}); });
}); });
describe("Core grant mutation request body limit", () => {
it("accepts a comfortably-under-limit grant-creation body and rejects one byte over the exact limit", async () => {
const store = fakeStore();
const { app } = buildApp({ store });
const overSizedBody = "a".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1);
const overResponse = await app.request("/api/v1/grants", {
body: overSizedBody,
headers: {
"content-length": String(overSizedBody.length),
"content-type": "application/json",
origin: trustedOrigin,
},
method: "POST",
});
expect(overResponse.status).toBe(413);
expect(await overResponse.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
expect(store.createGrant).not.toHaveBeenCalled();
// A well-formed, comfortably-under-limit payload still succeeds through the same route.
const underResponse = await app.request("/api/v1/grants", {
body: JSON.stringify({ permission: registeredPermission, subjectUserId }),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "POST",
});
expect(underResponse.status).not.toBe(413);
});
it("rejects an oversized declared Content-Length on grant creation without invoking permission evaluation", async () => {
const evaluatorAuthorize = vi.fn().mockResolvedValue({ allowed: true });
const evaluator: AuthorizationEvaluator = { authorize: evaluatorAuthorize };
const store = fakeStore();
const app = new Hono();
app.route(
"/",
createGrantsRoutes({
evaluator,
registry: CORE_PERMISSION_REGISTRY,
runtime: fakeRuntime(),
store,
trustedOrigins: new Set([trustedOrigin]),
}),
);
const response = await app.request("/api/v1/grants", {
body: "a",
headers: {
"content-length": String(CORE_MUTATION_MAX_BODY_BYTES * 10),
"content-type": "application/json",
origin: trustedOrigin,
},
method: "POST",
});
expect(response.status).toBe(413);
expect(evaluatorAuthorize).not.toHaveBeenCalled();
expect(store.createGrant).not.toHaveBeenCalled();
});
it("rejects an oversized streamed grant-creation body with no declared Content-Length", async () => {
const store = fakeStore();
const { app } = buildApp({ store });
const chunk = new Uint8Array(1024).fill(97);
let enqueued = 0;
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
if (enqueued > CORE_MUTATION_MAX_BODY_BYTES) {
controller.close();
return;
}
enqueued += chunk.length;
controller.enqueue(chunk);
},
});
const response = await app.request("/api/v1/grants", {
body: stream,
duplex: "half",
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
expect(store.createGrant).not.toHaveBeenCalled();
});
it("does not read an oversized body from an untrusted Origin: the trusted-origin rejection wins first", async () => {
const store = fakeStore();
const { app } = buildApp({ store });
const chunk = new Uint8Array(1024).fill(97);
let pullCount = 0;
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
pullCount += 1;
if (pullCount > 3) {
controller.close();
return;
}
controller.enqueue(chunk);
},
});
const response = await app.request("/api/v1/grants", {
body: stream,
duplex: "half",
headers: { "content-type": "application/json", origin: untrustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
expect(store.createGrant).not.toHaveBeenCalled();
});
it("also bounds the revoke mutation body", async () => {
const store = fakeStore();
const { app } = buildApp({ store });
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
body: "a".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1),
headers: {
"content-length": String(CORE_MUTATION_MAX_BODY_BYTES + 1),
origin: trustedOrigin,
},
method: "POST",
});
expect(response.status).toBe(413);
expect(store.revokeGrant).not.toHaveBeenCalled();
});
});
/**
* Pass 3 request-contract regressions. Every case here asserts both the sanitized public response
* *and* that the store was never reached, because the point of each fix is that a malformed or
* unsupported request is decided at the request boundary rather than turning into database work or
* an indistinguishable infrastructure failure.
*/
describe("explicit-grant request invariants", () => {
const validExpiry = "2099-01-01T00:00:00.000Z";
async function post(app: Hono, body: unknown) {
return await app.request("/api/v1/grants", {
body: JSON.stringify(body),
headers: { "content-type": "application/json", origin: trustedOrigin },
method: "POST",
});
}
it("rejects a scope field in any form, before any store mutation", async () => {
// Core keeps scoped-grant semantics for trusted internal callers, but no HTTP middleware
// supplies a scope to the evaluator, so a scope accepted here could never authorize anything.
for (const scope of ["calendar-event:123", "", " ", null, 42, {}, []]) {
const { app, store } = buildApp({});
const response = await post(app, { permission: registeredPermission, scope, subjectUserId });
expect(response.status, `scope ${JSON.stringify(scope)}`).toBe(400);
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
expect(response.headers.get("cache-control")).toBe("no-store");
expect(store.createGrant).not.toHaveBeenCalled();
}
});
it("accepts exactly one canonical expiration representation", async () => {
const createdGrant = {
createdAt: new Date("2026-01-01T00:00:00.000Z"),
expiresAt: new Date(validExpiry),
id: grantId,
permission: registeredPermission,
sourceKind: "explicit" as const,
userId: subjectUserId,
};
const store = fakeStore({ createGrant: vi.fn().mockResolvedValue(createdGrant) });
const { app } = buildApp({ store });
const response = await post(app, {
expiresAt: validExpiry,
permission: registeredPermission,
subjectUserId,
});
expect(response.status).toBe(201);
expect(store.createGrant).toHaveBeenCalledWith(
expect.objectContaining({ expiresAt: new Date(validExpiry) }),
);
});
it("rejects normalized-impossible, locale, offset, imprecise, padded, and past expirations", async () => {
const now = new Date("2026-07-26T12:00:00.000Z");
for (const expiresAt of [
// Normalized impossible date: `new Date` silently rewrote this to March 3rd.
"2026-02-31T00:00:00.000Z",
"2026-13-01T00:00:00.000Z",
// Locale-shaped and date-only forms.
"March 5, 2027",
"2027-01-01",
// Missing milliseconds, excess fractional precision, offsets, lowercase separators.
"2027-01-01T00:00:00Z",
"2027-01-01T00:00:00.123456Z",
"2027-01-01T00:00:00.000+02:00",
"2027-01-01t00:00:00.000z",
"2027-01-01T00:00:00.000+00:00",
// Whitespace padding and trailing text.
" 2027-01-01T00:00:00.000Z",
"2027-01-01T00:00:00.000Z ",
"2027-01-01T00:00:00.000Z extra",
// Past and exactly-now are both dead on arrival.
"2020-01-01T00:00:00.000Z",
"2026-07-26T12:00:00.000Z",
// Non-strings.
42,
null,
true,
]) {
const { app, store } = buildApp({ now: () => now });
const response = await post(app, {
expiresAt,
permission: registeredPermission,
subjectUserId,
});
expect(response.status, `expiresAt ${JSON.stringify(expiresAt)}`).toBe(400);
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
expect(store.createGrant).not.toHaveBeenCalled();
}
});
it("returns a sanitized 409 for an equivalent grant that is already effective", async () => {
const store = fakeStore({
createGrant: vi
.fn()
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_ALREADY_EXISTS")),
});
const { app } = buildApp({ store });
const response = await post(app, { permission: registeredPermission, subjectUserId });
const raw = await response.text();
expect(response.status).toBe(409);
expect(JSON.parse(raw)).toEqual({ code: "AUTHORIZATION_GRANT_ALREADY_EXISTS" });
expect(response.headers.get("cache-control")).toBe("no-store");
// The body is the stable code and nothing else — no existing grant id, actor, scope, source
// reference, expiry, SQL, constraint name, or driver text.
expect(Object.keys(JSON.parse(raw))).toEqual(["code"]);
for (const leaked of [
grantId,
manager.userId,
subjectUserId,
"calendar-event",
"constraint",
"select ",
"insert ",
"ekklesia_core_grant",
"Error",
"at ",
]) {
expect(raw, `must not leak ${leaked}`).not.toContain(leaked);
}
});
it("rejects a non-canonical grant id on revoke without ever calling the store", async () => {
for (const raw of [
"not-a-uuid",
"0B36B1B6-9A29-4C4E-9E2D-4C0E4C5E6F70",
"%200b36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f70",
"0b36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f7",
"0b36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f700",
"0b36b1b69a294c4e9e2d4c0e4c5e6f70",
]) {
const { app, store } = buildApp({});
const response = await app.request(`/api/v1/grants/${raw}/revoke`, {
headers: { origin: trustedOrigin },
method: "POST",
});
expect(response.status, `grantId ${raw}`).toBe(400);
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
expect(response.headers.get("cache-control")).toBe("no-store");
expect(store.revokeGrant).not.toHaveBeenCalled();
}
});
it("still returns the sanitized 404 for a canonical but unknown grant id", async () => {
const store = fakeStore({
revokeGrant: vi
.fn()
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND")),
});
const { app } = buildApp({ store });
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
headers: { origin: trustedOrigin },
method: "POST",
});
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_NOT_FOUND" });
expect(store.revokeGrant).toHaveBeenCalled();
});
});
describe("explicit-grant listing pagination", () => {
function grantAt(index: number, iso: string) {
return {
createdAt: new Date(iso),
id: parseGrantId(`00000000-0000-4000-8000-${String(index).padStart(12, "0")}`),
permission: registeredPermission,
sourceKind: "explicit" as const,
userId: subjectUserId,
};
}
it("applies the default limit of 50 and accepts the 1 and 100 boundaries", async () => {
for (const [query, expected] of [
["", 50],
["&limit=1", 1],
["&limit=50", 50],
["&limit=100", 100],
] as const) {
const { app, store } = buildApp({});
const response = await app.request(`/api/v1/grants?userId=${subjectUserId}${query}`);
expect(response.status, query).toBe(200);
expect(store.listGrantsForUser).toHaveBeenCalledWith(subjectUserId, {
limit: expected,
sourceKind: "explicit",
});
}
});
it("rejects every non-canonical or out-of-range limit before any store work", async () => {
for (const limit of [
"0",
"101",
"-1",
"+5",
"1.0",
"1.5",
"01",
"005",
" 5",
"5 ",
"1e2",
"",
"abc",
"0x10",
"Infinity",
"NaN",
]) {
const { app, store } = buildApp({});
const response = await app.request(
`/api/v1/grants?userId=${subjectUserId}&limit=${encodeURIComponent(limit)}`,
);
expect(response.status, `limit ${JSON.stringify(limit)}`).toBe(400);
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
expect(response.headers.get("cache-control")).toBe("no-store");
expect(store.listGrantsForUser).not.toHaveBeenCalled();
}
});
it("rejects unknown and duplicated query keys", async () => {
for (const query of [
`userId=${subjectUserId}&sort=asc`,
`userId=${subjectUserId}&offset=10`,
`userId=${subjectUserId}&sourceKind=relationship`,
`userId=${subjectUserId}&userId=${subjectUserId}`,
`userId=${subjectUserId}&limit=5&limit=6`,
`userId=${subjectUserId}&cursor=a&cursor=b`,
`userId=${subjectUserId}&cursor=`,
]) {
const { app, store } = buildApp({});
const response = await app.request(`/api/v1/grants?${query}`);
expect(response.status, query).toBe(400);
expect(store.listGrantsForUser).not.toHaveBeenCalled();
}
});
it("emits a cursor only when another page exists, and traverses without duplicates or omissions", async () => {
// Three rows sharing one `created_at`, which is exactly the case a timestamp-only keyset
// boundary would skip or repeat.
const sameInstant = "2026-01-01T00:00:00.000Z";
const all = [grantAt(3, sameInstant), grantAt(2, sameInstant), grantAt(1, sameInstant)];
const seen: string[] = [];
let cursor: string | null = null;
let pages = 0;
do {
const store = fakeStore({
listGrantsForUser: vi.fn().mockImplementation((_userId, listOptions) => {
const startIndex =
listOptions.after === undefined
? 0
: all.findIndex((grant) => grant.id === listOptions.after.id) + 1;
const slice = all.slice(startIndex, startIndex + listOptions.limit);
const more = startIndex + listOptions.limit < all.length;
const last = slice.at(-1);
return Promise.resolve({
grants: slice,
...(more && last ? { nextBoundary: { createdAt: last.createdAt, id: last.id } } : {}),
});
}),
});
const { app } = buildApp({ store });
const url: string = `/api/v1/grants?userId=${subjectUserId}&limit=1${
cursor === null ? "" : `&cursor=${encodeURIComponent(cursor)}`
}`;
const response = await app.request(url);
const body = (await response.json()) as {
grants: { id: string }[];
nextCursor: string | null;
};
expect(response.status).toBe(200);
expect(response.headers.get("cache-control")).toBe("no-store");
expect(body.grants).toHaveLength(1);
seen.push(body.grants[0]?.id as string);
cursor = body.nextCursor;
pages += 1;
} while (cursor !== null && pages < 10);
expect(pages).toBe(3);
expect(seen).toEqual(all.map((grant) => grant.id));
expect(new Set(seen).size).toBe(3);
// The final page ends the traversal explicitly.
expect(cursor).toBeNull();
});
it("refuses a cursor issued for a different subject, and every malformed encoding", async () => {
const boundary = { createdAt: new Date("2026-01-01T00:00:00.000Z"), id: grantId };
const store = fakeStore({
listGrantsForUser: vi.fn().mockResolvedValue({
grants: [grantAt(1, boundary.createdAt.toISOString())],
nextBoundary: boundary,
}),
});
const { app: issuingApp } = buildApp({ store });
const issued = (await (
await issuingApp.request(`/api/v1/grants?userId=${subjectUserId}&limit=1`)
).json()) as { nextCursor: string };
expect(issued.nextCursor).toMatch(/^[A-Za-z0-9_-]+$/u);
const encode = (payload: unknown) =>
Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
const canonical = {
g: grantId,
t: "2026-01-01T00:00:00.000Z",
u: subjectUserId,
v: 1,
};
for (const [label, cursor] of [
// A cursor minted for a different subject must not be replayable against this one.
["other subject", encode({ ...canonical, u: "someone_else_1" })],
["wrong version", encode({ ...canonical, v: 2 })],
["extra field", encode({ ...canonical, extra: "x" })],
["missing field", encode({ g: canonical.g, t: canonical.t, u: canonical.u })],
["invalid timestamp", encode({ ...canonical, t: "2026-01-01" })],
["non-canonical timestamp", encode({ ...canonical, t: "2026-01-01T00:00:00Z" })],
["invalid grant id", encode({ ...canonical, g: "not-a-uuid" })],
["array payload", encode([canonical])],
["not json", Buffer.from("nonsense", "utf8").toString("base64url")],
["padded base64", `${Buffer.from(JSON.stringify(canonical), "utf8").toString("base64")}`],
["malformed base64url", "!!!not-base64!!!"],
["trailing data", `${encode(canonical)}AA`],
] as const) {
const { app, store: pagedStore } = buildApp({});
const response = await app.request(
`/api/v1/grants?userId=${subjectUserId}&cursor=${encodeURIComponent(cursor)}`,
);
const raw = await response.text();
expect(response.status, label).toBe(400);
expect(JSON.parse(raw)).toEqual({ code: "INVALID_REQUEST" });
// A rejected cursor reveals nothing it decoded, and does zero store/database work.
expect(raw).not.toContain(grantId);
expect(raw).not.toContain("someone_else_1");
expect(pagedStore.listGrantsForUser).not.toHaveBeenCalled();
}
// The cursor the API itself issued is accepted.
const { app: acceptingApp, store: acceptingStore } = buildApp({});
const accepted = await acceptingApp.request(
`/api/v1/grants?userId=${subjectUserId}&cursor=${encodeURIComponent(issued.nextCursor)}`,
);
expect(accepted.status).toBe(200);
expect(acceptingStore.listGrantsForUser).toHaveBeenCalledWith(subjectUserId, {
after: boundary,
limit: 50,
sourceKind: "explicit",
});
});
});
+463 -2
View File
@@ -130,6 +130,9 @@ interface WorkingFixtureModuleOptions {
readonly registerThrows?: boolean; readonly registerThrows?: boolean;
readonly startThrows?: boolean; readonly startThrows?: boolean;
readonly stopThrows?: boolean; readonly stopThrows?: boolean;
/** Test-only: the stop hook records its marker and then never settles, so an API-level deadline
* can be exercised deterministically without any sleep. */
readonly stopNeverSettles?: boolean;
readonly dependencies?: Record<string, unknown>; readonly dependencies?: Record<string, unknown>;
} }
@@ -155,6 +158,7 @@ async function createWorkingFixtureModule(
method: "GET", method: "GET",
path: "/ping", path: "/ping",
permission: ${JSON.stringify(permissionId)}, permission: ${JSON.stringify(permissionId)},
maxBodyBytes: 0,
handler: async () => new Response("pong", { status: 200 }), handler: async () => new Response("pong", { status: 200 }),
});` });`
: ""; : "";
@@ -173,6 +177,7 @@ async function createWorkingFixtureModule(
stop() { stop() {
${recorder.expression}.push(${JSON.stringify(`${id}:stop`)}); ${recorder.expression}.push(${JSON.stringify(`${id}:stop`)});
${options.stopThrows ? 'throw new Error("stop failed");' : ""} ${options.stopThrows ? 'throw new Error("stop failed");' : ""}
${options.stopNeverSettles ? "return new Promise(() => {});" : ""}
}, },
};`; };`;
await writeFile(path.join(modulesDirectory, id, "dist/server/index.js"), serverSource); await writeFile(path.join(modulesDirectory, id, "dist/server/index.js"), serverSource);
@@ -212,8 +217,11 @@ function fakeDiscoveredModule(
/** A minimal fake `ServerType`: enough of Node's `http.Server` shape for `bindServer`/`shutdown` /** A minimal fake `ServerType`: enough of Node's `http.Server` shape for `bindServer`/`shutdown`
* to drive without a real socket. `close` and `once` are spies so tests can assert call counts; * to drive without a real socket. `close` and `once` are spies so tests can assert call counts;
* `triggerError` simulates the server's own `'error'` event (e.g. a bind failure). */ * `triggerError` simulates the server's own `'error'` event (e.g. a bind failure). */
type ClientErrorHandler = (error: NodeJS.ErrnoException, socket: import("node:net").Socket) => void;
function fakeServer() { function fakeServer() {
let errorHandler: ((error: Error) => void) | undefined; let errorHandler: ((error: Error) => void) | undefined;
let clientErrorHandler: ClientErrorHandler | undefined;
const server = { const server = {
address: () => ({ address: "127.0.0.1", family: "IPv4", port: 4_000 }), address: () => ({ address: "127.0.0.1", family: "IPv4", port: 4_000 }),
close: vi.fn((callback?: () => void) => { close: vi.fn((callback?: () => void) => {
@@ -221,6 +229,12 @@ function fakeServer() {
return server; return server;
}), }),
listening: true, listening: true,
on: vi.fn((event: string, handler: ClientErrorHandler) => {
if (event === "clientError") {
clientErrorHandler = handler;
}
return server;
}),
once: vi.fn((event: string, handler: (error: Error) => void) => { once: vi.fn((event: string, handler: (error: Error) => void) => {
if (event === "error") { if (event === "error") {
errorHandler = handler; errorHandler = handler;
@@ -229,6 +243,9 @@ function fakeServer() {
}), }),
}; };
return { return {
/** The `clientError` handler `bindServer` registered — the real event path Node uses for a
* header/request timeout, a parse failure, and a header overflow alike. */
clientErrorHandler: () => clientErrorHandler,
server, server,
triggerError: (error: Error) => errorHandler?.(error), triggerError: (error: Error) => errorHandler?.(error),
}; };
@@ -239,12 +256,12 @@ function fakeServer() {
* resolving synchronously would reference `bindServer`'s own `server` const before it is * resolving synchronously would reference `bindServer`'s own `server` const before it is
* initialized. */ * initialized. */
function mockSuccessfulServe() { function mockSuccessfulServe() {
const { server, triggerError } = fakeServer(); const { clientErrorHandler, server, triggerError } = fakeServer();
serveMock.mockImplementation((_options: unknown, listeningListener?: () => void) => { serveMock.mockImplementation((_options: unknown, listeningListener?: () => void) => {
queueMicrotask(() => listeningListener?.()); queueMicrotask(() => listeningListener?.());
return server; return server;
}); });
return { server, triggerError }; return { clientErrorHandler, server, triggerError };
} }
function mockFailingServe(error: Error) { function mockFailingServe(error: Error) {
@@ -409,6 +426,97 @@ describe("Ekklesia API shutdown lifecycle", () => {
expect(serveMock).not.toHaveBeenCalled(); expect(serveMock).not.toHaveBeenCalled();
}); });
it("refuses a production configuration that enables public signup before any pool, Better Auth instance, module runtime, or server exists", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
// The rejection happens in `createApiRuntimeConfig` itself — the same boundary the executable
// entry point calls before `startApi` — so `startApi` is never even reachable with such a
// configuration, and no runtime resource is ever constructed to leak.
let captured: unknown;
try {
createApiRuntimeConfig(
{
databaseUrl: "postgresql://ekklesia:[email protected]:5432/ekklesia",
host: "127.0.0.1",
identity: {
baseUrl: "https://identity.example.test",
publicSignupEnabled: true,
secret: "correct-horse-battery-staple-0123456789",
trustedOrigins: ["https://identity.example.test"],
},
port: 0,
},
{ environment: "production" },
);
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION" });
expect(poolConstructor).not.toHaveBeenCalled();
expect(betterAuthMock).not.toHaveBeenCalled();
expect(serveMock).not.toHaveBeenCalled();
expect(poolEnd).not.toHaveBeenCalled();
});
it("refuses an unsafe production database transport before any pool, Better Auth instance, module runtime, or server exists", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
// Both an explicit production plaintext selection and a URL that would silently downgrade the
// policy are rejected in `createApiRuntimeConfig` — the same boundary the executable entry
// point calls before `startApi` — so no runtime resource is ever constructed to leak.
for (const [label, input] of [
[
"explicit production plaintext",
{
databaseTransport: { mode: "disable" },
databaseUrl: "postgresql://ekklesia:[email protected]:5432/ekklesia",
},
],
[
"connection-string TLS downgrade",
{
databaseUrl:
"postgresql://ekklesia:[email protected]:5432/ekklesia?sslmode=disable",
},
],
] as const) {
let captured: unknown;
try {
createApiRuntimeConfig(
{
host: "127.0.0.1",
identity: {
baseUrl: "https://identity.example.test",
secret: "correct-horse-battery-staple-0123456789",
trustedOrigins: ["https://identity.example.test"],
},
port: 0,
...input,
},
{ environment: "production" },
);
} catch (error) {
captured = error;
}
expect(captured, `expected ${label} to be rejected`).toBeInstanceOf(Error);
expect(poolConstructor).not.toHaveBeenCalled();
expect(betterAuthMock).not.toHaveBeenCalled();
expect(serveMock).not.toHaveBeenCalled();
expect(poolEnd).not.toHaveBeenCalled();
}
});
it("closes an already-created database pool when the server fails to bind its listen address", async () => { it("closes an already-created database pool when the server fails to bind its listen address", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined); const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() { poolConstructor.mockImplementation(function fakePool() {
@@ -465,6 +573,199 @@ describe("Ekklesia API shutdown lifecycle", () => {
}); });
}); });
describe("Ekklesia API Node server timeouts", () => {
beforeEach(() => {
poolConstructor.mockReset();
betterAuthMock.mockReset();
drizzleAdapterMock.mockClear();
serveMock.mockReset();
});
it("configures finite, typed header/request/keep-alive timeouts on the underlying Node server", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
const running = await startApi(validConfig());
expect(serveMock).toHaveBeenCalledTimes(1);
const [options] = serveMock.mock.calls[0] as [
{
serverOptions?: {
connectionsCheckingInterval?: number;
headersTimeout?: number;
keepAliveTimeout?: number;
requestTimeout?: number;
};
},
];
expect(options.serverOptions?.headersTimeout).toBeGreaterThan(0);
expect(options.serverOptions?.requestTimeout).toBeGreaterThan(0);
expect(options.serverOptions?.keepAliveTimeout).toBeGreaterThan(0);
// Node only enforces headersTimeout/requestTimeout when this periodic check runs (verified
// directly against the pinned Node runtime — its own default, 30s, would otherwise let a
// violating connection sit for up to an additional 30s past its actual timeout).
expect(options.serverOptions?.connectionsCheckingInterval).toBeGreaterThan(0);
expect(options.serverOptions?.connectionsCheckingInterval).toBeLessThan(30_000);
// Node requires headersTimeout not to exceed requestTimeout.
expect(options.serverOptions?.headersTimeout).toBeLessThanOrEqual(
options.serverOptions?.requestTimeout ?? 0,
);
await running.shutdown();
});
it("allows a test-only override to narrow the timeouts without changing the production defaults", async () => {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
mockSuccessfulServe();
const running = await startApi(validConfig(), {
nodeServerTimeoutOverride: { headersTimeoutMs: 50, requestTimeoutMs: 100 },
});
const [options] = serveMock.mock.calls[0] as [
{
serverOptions?: {
headersTimeout?: number;
keepAliveTimeout?: number;
requestTimeout?: number;
};
},
];
expect(options.serverOptions?.headersTimeout).toBe(50);
expect(options.serverOptions?.requestTimeout).toBe(100);
// The un-overridden keep-alive timeout keeps its production default.
expect(options.serverOptions?.keepAliveTimeout).toBeGreaterThan(0);
await running.shutdown();
});
// Whether Node actually *emits* anything for a real header/request timeout — and that exactly one
// 408 is written, the socket closes, and no handler runs — is proven only at the real transport
// boundary, by the raw-socket scenarios in `apps/api/tests/api-http.integration.ts`. Node's
// general `'timeout'` event is NOT that path (it belongs to `server.setTimeout()`, which this
// server never arms); `'clientError'` is. The tests below therefore deliberately do not claim to
// prove the event path at all — they cover only the response-reproduction logic that Node's own
// default would otherwise have performed, including the duplicate-response guard, which is
// impractical to drive from a real socket.
function fakeErrorSocket(overrides: { headersSent?: boolean; writable?: boolean } = {}) {
const socket = {
_httpMessage:
overrides.headersSent === undefined ? undefined : { headersSent: overrides.headersSent },
destroy: vi.fn(),
destroyed: false,
writable: overrides.writable ?? true,
write: vi.fn(),
};
return socket as unknown as import("node:net").Socket & {
destroy: ReturnType<typeof vi.fn>;
write: ReturnType<typeof vi.fn>;
};
}
async function startAndCaptureClientErrorHandler() {
const poolEnd = vi.fn().mockResolvedValue(undefined);
poolConstructor.mockImplementation(function fakePool() {
return { end: poolEnd, on: vi.fn() };
});
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
const { clientErrorHandler } = mockSuccessfulServe();
const running = await startApi(validConfig());
const handler = clientErrorHandler();
if (!handler) {
throw new Error("Expected startApi to register a clientError handler.");
}
return { handler, running };
}
it("reproduces Node's own client-error responses byte-for-byte, since registering a listener suppresses them", async () => {
const { handler, running } = await startAndCaptureClientErrorHandler();
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
try {
for (const [code, expected] of [
["ERR_HTTP_REQUEST_TIMEOUT", "HTTP/1.1 408 Request Timeout\r\nConnection: close\r\n\r\n"],
[
"HPE_HEADER_OVERFLOW",
"HTTP/1.1 431 Request Header Fields Too Large\r\nConnection: close\r\n\r\n",
],
["HPE_INVALID_METHOD", "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"],
[undefined, "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"],
] as const) {
const socket = fakeErrorSocket();
const error = Object.assign(new Error("parser detail that must never be logged"), { code });
handler(error, socket);
expect(socket.write).toHaveBeenCalledTimes(1);
expect(socket.write).toHaveBeenCalledWith(expected);
expect(socket.destroy).toHaveBeenCalledTimes(1);
}
await running.shutdown();
} finally {
errorSpy.mockRestore();
}
});
it("never writes a second response when a handler already began responding, but still closes the socket", async () => {
const { handler, running } = await startAndCaptureClientErrorHandler();
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
try {
const socket = fakeErrorSocket({ headersSent: true });
handler(Object.assign(new Error("timeout"), { code: "ERR_HTTP_REQUEST_TIMEOUT" }), socket);
expect(socket.write).not.toHaveBeenCalled();
expect(socket.destroy).toHaveBeenCalledTimes(1);
// An unwritable socket is likewise never written to, only destroyed.
const closedSocket = fakeErrorSocket({ writable: false });
handler(
Object.assign(new Error("timeout"), { code: "ERR_HTTP_REQUEST_TIMEOUT" }),
closedSocket,
);
expect(closedSocket.write).not.toHaveBeenCalled();
expect(closedSocket.destroy).toHaveBeenCalledTimes(1);
await running.shutdown();
} finally {
errorSpy.mockRestore();
}
});
it("logs one fixed sanitized line per client error, never the caught error's own message", async () => {
const { handler, running } = await startAndCaptureClientErrorHandler();
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
try {
handler(
Object.assign(new Error("Parse Error: /secret-path?token=abc123"), {
code: "HPE_INVALID_METHOD",
}),
fakeErrorSocket(),
);
expect(errorSpy).toHaveBeenCalledTimes(1);
const emitted = errorSpy.mock.calls.flat().map(String).join("\n");
expect(emitted).not.toContain("secret-path");
expect(emitted).not.toContain("abc123");
expect(emitted).not.toContain("Parse Error");
expect(emitted).not.toContain("HPE_");
expect(emitted).not.toMatch(/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/u);
await running.shutdown();
} finally {
errorSpy.mockRestore();
}
});
});
describe("Ekklesia API module permission composition at startup", () => { describe("Ekklesia API module permission composition at startup", () => {
afterEach(async () => { afterEach(async () => {
await Promise.all( await Promise.all(
@@ -688,6 +989,7 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
method: "GET", method: "GET",
path: "/ping", path: "/ping",
permission: "db-identity.ping.read", permission: "db-identity.ping.read",
maxBodyBytes: 0,
handler: async (_request, context) => { handler: async (_request, context) => {
${refs.expression}.push(context.services.database); ${refs.expression}.push(context.services.database);
return new Response(null, { status: 204 }); return new Response(null, { status: 204 });
@@ -769,11 +1071,14 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
issues: [expect.objectContaining({ code: "MODULE_START_FAILED" })], issues: [expect.objectContaining({ code: "MODULE_START_FAILED" })],
}); });
// The module whose start hook failed is rolled back first through its own stop hook, then the
// module that had already started, in reverse order.
expect(recorder.events).toEqual([ expect(recorder.events).toEqual([
"one:register", "one:register",
"two:register", "two:register",
"one:start", "one:start",
"two:start", "two:start",
"two:stop",
"one:stop", "one:stop",
]); ]);
expect(poolConstructor).toHaveBeenCalledTimes(1); expect(poolConstructor).toHaveBeenCalledTimes(1);
@@ -879,8 +1184,14 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory }); const running = await startApi(validConfig(), { modulesDirectoryOverride: modulesDirectory });
// Shutdown now aggregates which stages failed; the module-runtime error is carried intact.
await expect(running.shutdown()).rejects.toMatchObject({ await expect(running.shutdown()).rejects.toMatchObject({
drainTimedOut: false,
moduleStopError: {
issues: [expect.objectContaining({ code: "MODULE_STOP_FAILED", moduleId: "stop-fails" })], issues: [expect.objectContaining({ code: "MODULE_STOP_FAILED", moduleId: "stop-fails" })],
},
moduleStopFailed: true,
name: "ApiShutdownError",
}); });
expect(poolEnd).toHaveBeenCalledTimes(1); expect(poolEnd).toHaveBeenCalledTimes(1);
}); });
@@ -923,4 +1234,154 @@ describe("Ekklesia API module runtime startup and shutdown", () => {
expect(betterAuthMock).not.toHaveBeenCalled(); expect(betterAuthMock).not.toHaveBeenCalled();
expect(serveMock).not.toHaveBeenCalled(); expect(serveMock).not.toHaveBeenCalled();
}); });
/**
* Pass 4 correction: the API composition's own bounded-cleanup guarantees.
*
* Core proves an individual hook deadline in isolation; these prove that `startApi`/`shutdown`
* actually reach pool closure through those deadlines, which is where the platform's promise ("the
* pool always closes") is either kept or broken. Every deadline here is a short, test-only
* override; production keeps the 10-second hook and 30-second drain defaults.
*/
describe("Ekklesia API bounded module cleanup", () => {
it("bounds a never-settling startup rollback, still rolls back earlier modules, closes the pool, and binds no listener", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "one", recorder);
await createWorkingFixtureModule(modulesDirectory, "two", recorder, {
dependencies: { modules: { required: { one: "^0.1.0" } } },
startThrows: true,
stopNeverSettles: true,
});
const { poolEnd } = mockPoolAndAuth();
await expect(
startApi(validConfig(), {
moduleHookTimeoutMsOverride: 25,
modulesDirectoryOverride: modulesDirectory,
}),
).rejects.toMatchObject({
issues: [
expect.objectContaining({ code: "MODULE_START_FAILED", moduleId: "two" }),
expect.objectContaining({ code: "MODULE_ROLLBACK_TIMEOUT", moduleId: "two" }),
],
});
// The failing module's own stop hook was attempted first and timed out; the earlier module
// still received its rollback afterwards.
expect(recorder.events).toEqual([
"one:register",
"two:register",
"one:start",
"two:start",
"two:stop",
"one:stop",
]);
expect(poolConstructor).toHaveBeenCalledTimes(1);
expect(poolEnd).toHaveBeenCalledTimes(1);
expect(serveMock).not.toHaveBeenCalled();
});
it("bounds a never-settling normal stop, attempts the earlier module, and still closes the pool", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "one", recorder);
await createWorkingFixtureModule(modulesDirectory, "two", recorder, {
dependencies: { modules: { required: { one: "^0.1.0" } } },
stopNeverSettles: true,
});
const { poolEnd } = mockPoolAndAuth();
mockSuccessfulServe();
const running = await startApi(validConfig(), {
moduleHookTimeoutMsOverride: 25,
modulesDirectoryOverride: modulesDirectory,
});
await expect(running.shutdown()).rejects.toMatchObject({
drainTimedOut: false,
moduleStopError: {
issues: [expect.objectContaining({ code: "MODULE_STOP_TIMEOUT", moduleId: "two" })],
},
moduleStopFailed: true,
name: "ApiShutdownError",
});
expect(recorder.events.slice(-2)).toEqual(["two:stop", "one:stop"]);
expect(poolEnd).toHaveBeenCalledTimes(1);
// Repeated and concurrent callers share the one memoized result and restart nothing.
const [first, second] = await Promise.allSettled([running.shutdown(), running.shutdown()]);
expect(first.status).toBe("rejected");
expect(second.status).toBe("rejected");
expect(recorder.events.filter((event) => event === "two:stop")).toHaveLength(1);
expect(recorder.events.filter((event) => event === "one:stop")).toHaveLength(1);
expect(poolEnd).toHaveBeenCalledTimes(1);
});
it("reports a drain timeout and a module-stop timeout together, after the pool has closed", async () => {
const modulesDirectory = await createTemporaryModulesDirectory();
const recorder = createEventRecorder();
await createWorkingFixtureModule(modulesDirectory, "stalls", recorder, {
stopNeverSettles: true,
});
const { poolEnd } = mockPoolAndAuth();
// A server whose `close` callback never fires stands in for a connection that never drains,
// deterministically and without a real socket. `closeIdleConnections`/`closeAllConnections`
// are the supported public APIs the platform calls at the deadline.
const closeAllConnections = vi.fn();
const closeIdleConnections = vi.fn();
const { server } = fakeServer();
// `close` deliberately never invokes its callback: nothing drains, which is exactly what the
// drain deadline exists for — deterministic, with no real socket and no sleep.
const stalled = Object.assign(server, {
close: vi.fn(() => server),
closeAllConnections,
closeIdleConnections,
});
serveMock.mockImplementation((_options: unknown, listeningListener?: () => void) => {
// Deferred exactly like `mockSuccessfulServe`: `bindServer` resolves with `server`, which
// is still in its temporal dead zone during the synchronous `serve()` call.
queueMicrotask(() => listeningListener?.());
return stalled;
});
const running = await startApi(validConfig(), {
drainTimeoutMsOverride: 25,
moduleHookTimeoutMsOverride: 25,
modulesDirectoryOverride: modulesDirectory,
});
let captured: unknown;
try {
await running.shutdown();
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({
drainTimedOut: true,
moduleStopFailed: true,
name: "ApiShutdownError",
});
const issues = (captured as { moduleStopError: { issues: Array<{ code: string }> } })
.moduleStopError.issues;
expect(issues).toEqual([
expect.objectContaining({ code: "MODULE_STOP_TIMEOUT", moduleId: "stalls" }),
]);
// The platform force-closed through Node's public APIs, then still stopped modules and closed
// the pool — neither deadline skipped a later stage.
expect(closeIdleConnections).toHaveBeenCalledTimes(1);
expect(closeAllConnections).toHaveBeenCalledTimes(1);
expect(recorder.events).toContain("stalls:stop");
expect(poolEnd).toHaveBeenCalledTimes(1);
// Nothing about the request, socket, connection, or module exception leaks.
const publicText = `${String(captured)}\n${(captured as Error).stack ?? ""}\n${JSON.stringify(
captured,
)}`;
for (const secret of ["127.0.0.1", "postgres", "password", "select ", "Error: stop"]) {
expect(publicText.toLowerCase()).not.toContain(secret.toLowerCase());
}
});
});
}); });
+25 -2
View File
@@ -219,6 +219,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([ expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
"core:0000_identity_core_tables", "core:0000_identity_core_tables",
"core:0001_authorization_core_tables", "core:0001_authorization_core_tables",
"core:0002_authorization_integrity",
]); ]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally { } finally {
@@ -239,9 +240,11 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
platformVersion: "0.1.0", platformVersion: "0.1.0",
}, },
}); });
// Core migrations aggregate first, exactly once each, and always before any module migration.
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([ expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
"core:0000_identity_core_tables", "core:0000_identity_core_tables",
"core:0001_authorization_core_tables", "core:0001_authorization_core_tables",
"core:0002_authorization_integrity",
"db-facade:0000_initial", "db-facade:0000_initial",
]); ]);
expect(aggregate.migrations[0]?.checksum).toBe( expect(aggregate.migrations[0]?.checksum).toBe(
@@ -250,6 +253,9 @@ async function applyMigrationsWithModule(url: string): Promise<void> {
expect(aggregate.migrations[1]?.checksum).toBe( expect(aggregate.migrations[1]?.checksum).toBe(
"830e1fbc26d05af08df8d8710c3d10e1f3016d32c41b8363cb661c49afcbe81e", "830e1fbc26d05af08df8d8710c3d10e1f3016d32c41b8363cb661c49afcbe81e",
); );
expect(aggregate.migrations[2]?.checksum).toBe(
"63429473118065dfe3877a1baec61b8f61663b88127d9cb17707c859f27f6036",
);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} }
@@ -263,12 +269,19 @@ async function startMigratedApi(
host: "127.0.0.1", host: "127.0.0.1",
identity: { identity: {
baseUrl: identityBaseUrl, baseUrl: identityBaseUrl,
publicSignupEnabled: true,
secret: "integration-test-secret-0123456789abcdefgh", secret: "integration-test-secret-0123456789abcdefgh",
trustedOrigins: [identityBaseUrl, dashboardOrigin], trustedOrigins: [identityBaseUrl, dashboardOrigin],
}, },
port: 0, port: 0,
}, },
{ environment: "production" }, // Development validation: this suite signs an owner account up over HTTP to exercise real
// authorized module database access, and production configuration refuses to enable public
// signup at all until durable abuse controls exist
// (`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION`, `core/src/identity/config.ts`). `environment`
// affects only Ekklesia's own config validation — it never reaches Better Auth — and the base
// URL and trusted origins here are identical either way.
{ environment: "development" },
); );
return startApi(config, { return startApi(config, {
rateLimitOverride: { max: 1000, window: 1 }, rateLimitOverride: { max: 1000, window: 1 },
@@ -423,6 +436,9 @@ describe.sequential("live API — module database explicit migration ownership",
expect(await migrationHistoryCount(database.url, "core:0001_authorization_core_tables")).toBe( expect(await migrationHistoryCount(database.url, "core:0001_authorization_core_tables")).toBe(
1, 1,
); );
expect(await migrationHistoryCount(database.url, "core:0002_authorization_integrity")).toBe(
1,
);
} finally { } finally {
await dropDatabase(database.name); await dropDatabase(database.name);
} }
@@ -434,8 +450,15 @@ describe.sequential("live API — module database explicit migration ownership",
await applyCoreMigrations(database.url); await applyCoreMigrations(database.url);
resetRecorder(); resetRecorder();
// The module whose `start` failed is now rolled back through its own `stop` hook first. That
// hook also writes to the table its missing migration would have created, so it fails too —
// which is exactly the documented reason a `stop` hook must tolerate partial initialization.
// Both outcomes are reported, in order, and neither is swallowed.
await expect(startMigratedApi(database.url, fixtureModulesDirectory)).rejects.toMatchObject({ await expect(startMigratedApi(database.url, fixtureModulesDirectory)).rejects.toMatchObject({
issues: [expect.objectContaining({ code: "MODULE_START_FAILED", moduleId: "db-facade" })], issues: [
expect.objectContaining({ code: "MODULE_START_FAILED", moduleId: "db-facade" }),
expect.objectContaining({ code: "MODULE_ROLLBACK_FAILED", moduleId: "db-facade" }),
],
}); });
// No schema inference, no automatic table creation as a side effect of the failed attempt. // No schema inference, no automatic table creation as a side effect of the failed attempt.
+416 -3
View File
@@ -5,6 +5,7 @@ import {
type ModuleRouteHandler, type ModuleRouteHandler,
type ModuleRouteHandlerContext, type ModuleRouteHandlerContext,
type ModuleRuntimeServices, type ModuleRuntimeServices,
moduleRoutesOverlap,
parsePermissionId, parsePermissionId,
type PermissionId, type PermissionId,
type RegisteredModuleHttpRoute, type RegisteredModuleHttpRoute,
@@ -43,14 +44,20 @@ function fakeStore(): AuthorizationStore {
grantPlatformOwner: vi.fn(), grantPlatformOwner: vi.fn(),
hasActiveGrant: vi.fn().mockResolvedValue(false), hasActiveGrant: vi.fn().mockResolvedValue(false),
isPlatformOwner: vi.fn().mockResolvedValue(false), isPlatformOwner: vi.fn().mockResolvedValue(false),
listGrantsForUser: vi.fn().mockResolvedValue([]), listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }),
revokeGrant: vi.fn(), revokeGrant: vi.fn(),
revokePlatformOwner: vi.fn(), revokePlatformOwner: vi.fn(),
}; };
} }
function moduleRoute(fields: RegisteredModuleHttpRoute): RegisteredModuleHttpRoute { /** Defaults `maxBodyBytes` to a generous value for tests that are not themselves exercising the
return Object.freeze({ ...fields }); * body-size contract, so the many pre-existing routing/authorization/CORS scenarios below do not
* each need to restate an unrelated body-size choice; the dedicated "module route body limits"
* suite further down overrides it explicitly to prove the contract itself. */
function moduleRoute(
fields: Omit<RegisteredModuleHttpRoute, "maxBodyBytes"> & { maxBodyBytes?: number },
): RegisteredModuleHttpRoute {
return Object.freeze({ maxBodyBytes: 1024, ...fields });
} }
/** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting, /** A small typed fake `ModuleRuntimeServices` — these tests exercise route mounting,
@@ -1147,6 +1154,262 @@ describe("module route HEAD semantics", () => {
}); });
}); });
describe("module route body limits", () => {
it("rejects a non-empty declared body on a route declaring maxBodyBytes: 0", async () => {
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
const { app } = buildModuleApp({
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
routes: [
moduleRoute({
handler,
maxBodyBytes: 0,
method: "POST",
moduleId: "calendar",
path: "/events",
permission: writePermission,
}),
],
});
const response = await app.request("/api/v1/modules/calendar/events", {
body: "a",
headers: { "content-length": "1", origin: trustedOrigin },
method: "POST",
});
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
expect(handler).not.toHaveBeenCalled();
});
it("rejects a non-empty streamed body on a route declaring maxBodyBytes: 0", async () => {
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
const { app } = buildModuleApp({
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
routes: [
moduleRoute({
handler,
maxBodyBytes: 0,
method: "POST",
moduleId: "calendar",
path: "/events",
permission: writePermission,
}),
],
});
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode("a"));
controller.close();
},
});
const response = await app.request("/api/v1/modules/calendar/events", {
body: stream,
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(413);
expect(handler).not.toHaveBeenCalled();
});
it("accepts a genuinely empty body on a route declaring maxBodyBytes: 0", async () => {
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
const { app } = buildModuleApp({
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
routes: [
moduleRoute({
handler,
maxBodyBytes: 0,
method: "POST",
moduleId: "calendar",
path: "/events",
permission: writePermission,
}),
],
});
const response = await app.request("/api/v1/modules/calendar/events", {
headers: { origin: trustedOrigin },
method: "POST",
});
expect(response.status).toBe(200);
expect(handler).toHaveBeenCalledTimes(1);
});
it("accepts a body exactly at an explicit maxBodyBytes limit and rejects one byte over", async () => {
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
const limit = 32;
const { app } = buildModuleApp({
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
routes: [
moduleRoute({
handler,
maxBodyBytes: limit,
method: "POST",
moduleId: "calendar",
path: "/events",
permission: writePermission,
}),
],
});
const atLimit = "a".repeat(limit);
const atLimitResponse = await app.request("/api/v1/modules/calendar/events", {
body: atLimit,
headers: { "content-length": String(limit), origin: trustedOrigin },
method: "POST",
});
expect(atLimitResponse.status).toBe(200);
const overLimit = "a".repeat(limit + 1);
const overLimitResponse = await app.request("/api/v1/modules/calendar/events", {
body: overLimit,
headers: { "content-length": String(limit + 1), origin: trustedOrigin },
method: "POST",
});
expect(overLimitResponse.status).toBe(413);
expect(await overLimitResponse.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
expect(handler).toHaveBeenCalledTimes(1);
});
it("rejects an oversized streamed body on a route with an explicit limit, with no declared Content-Length", async () => {
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
const limit = 64;
const { app } = buildModuleApp({
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
routes: [
moduleRoute({
handler,
maxBodyBytes: limit,
method: "POST",
moduleId: "calendar",
path: "/events",
permission: writePermission,
}),
],
});
const chunk = new Uint8Array(16).fill(97);
let enqueued = 0;
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
if (enqueued > limit) {
controller.close();
return;
}
enqueued += chunk.length;
controller.enqueue(chunk);
},
});
const response = await app.request("/api/v1/modules/calendar/events", {
body: stream,
duplex: "half",
headers: { origin: trustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(413);
expect(handler).not.toHaveBeenCalled();
});
it("enforces the body limit before authorization runs, so an oversized denied request never reaches the evaluator", async () => {
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
const evaluator: AuthorizationEvaluator = {
authorize: vi.fn().mockResolvedValue({ allowed: false }),
};
const { app } = buildModuleApp({
evaluator,
registry: permissionRegistry([{ id: writePermission, baseline: "restricted" }]),
routes: [
moduleRoute({
handler,
maxBodyBytes: 8,
method: "POST",
moduleId: "calendar",
path: "/events",
permission: writePermission,
}),
],
});
const response = await app.request("/api/v1/modules/calendar/events", {
body: "a".repeat(9),
headers: { "content-length": "9", origin: trustedOrigin },
method: "POST",
});
expect(response.status).toBe(413);
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
expect(evaluator.authorize).not.toHaveBeenCalled();
expect(handler).not.toHaveBeenCalled();
});
it("never reads an oversized body from an untrusted Origin: the trusted-origin rejection wins first", async () => {
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
const { app } = buildModuleApp({
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
routes: [
moduleRoute({
handler,
maxBodyBytes: 8,
method: "POST",
moduleId: "calendar",
path: "/events",
permission: writePermission,
}),
],
});
const chunk = new Uint8Array(16).fill(97);
let pullCount = 0;
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
pullCount += 1;
if (pullCount > 3) {
controller.close();
return;
}
controller.enqueue(chunk);
},
});
const response = await app.request("/api/v1/modules/calendar/events", {
body: stream,
duplex: "half",
headers: { origin: untrustedOrigin },
method: "POST",
} as RequestInit);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
expect(handler).not.toHaveBeenCalled();
});
it("applies the route's own declared limit to a GET route too", async () => {
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
const { app } = buildModuleApp({
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
routes: [
moduleRoute({
handler,
maxBodyBytes: 0,
method: "GET",
moduleId: "calendar",
path: "/events",
permission: readPermission,
}),
],
});
const response = await app.request("/api/v1/modules/calendar/events");
expect(response.status).toBe(200);
expect(handler).toHaveBeenCalledTimes(1);
});
});
describe("module routes and the rest of the API", () => { describe("module routes and the rest of the API", () => {
it("does not invoke a handler for an unmatched module path", async () => { it("does not invoke a handler for an unmatched module path", async () => {
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true })); const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
@@ -1192,3 +1455,153 @@ describe("module routes and the rest of the API", () => {
expect(response.status).toBe(200); expect(response.status).toBe(200);
}); });
}); });
/**
* Pass 4: the decoded-parameter boundary and the routing ambiguity that made it dangerous.
*
* These drive the pinned Hono matcher with real requests rather than asserting what a mock returns,
* because the whole point is what the real router actually hands a module handler.
*/
describe("module route parameters are decoded, untrusted input", () => {
function paramsApp() {
const seen: Array<Readonly<Record<string, string>>> = [];
const handler = vi.fn<ModuleRouteHandler>(async (_request, context) => {
seen.push(context.params);
return Response.json({ id: context.params.id });
});
const { app } = buildModuleApp({
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
routes: [
moduleRoute({
handler,
method: "GET",
moduleId: "calendar",
path: "/items/:id",
permission: readPermission,
}),
],
});
return { app, handler, seen };
}
it.each([
// A single encoded segment can carry characters the declaration grammar forbids outright.
["an encoded path separator and dot segments", "%2F..%2Fadmin", "/../admin"],
["an encoded backslash", "a%5Cb", "a\\b"],
["an embedded NUL", "a%00b", "a\u0000b"],
["encoded CR/LF", "a%0D%0Ab", "a\r\nb"],
["a percent sign", "100%25", "100%"],
["non-ASCII text", "%C3%A9v%C3%A9nement", "événement"],
["a statement separator", "1%3B%20drop", "1; drop"],
])("delivers %s decoded, exactly as the router produced it", async (_label, encoded, decoded) => {
const { app, seen } = paramsApp();
const response = await app.request(`/api/v1/modules/calendar/items/${encoded}`);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ id: decoded });
// The declared path grammar (`/items/:id`) permits none of these characters — validating the
// *declaration* says nothing about what a request may send, which is exactly why a handler
// must validate the decoded value against its own domain grammar before using it.
expect(seen[0]?.id).toBe(decoded);
expect(Object.isFrozen(seen[0])).toBe(true);
});
it("keeps parameter delivery behind authorization", async () => {
const handler = vi.fn<ModuleRouteHandler>(async () => new Response("never"));
const { app, evaluator } = buildModuleApp({
evaluator: { authorize: vi.fn().mockResolvedValue({ allowed: false }) },
registry: permissionRegistry([{ id: readPermission, baseline: "restricted" }]),
routes: [
moduleRoute({
handler,
method: "GET",
moduleId: "calendar",
path: "/items/:id",
permission: readPermission,
}),
],
});
const response = await app.request("/api/v1/modules/calendar/items/%2F..%2Fadmin");
expect(response.status).toBe(403);
expect(evaluator.authorize).toHaveBeenCalled();
// An unauthorized caller can never observe how the handler would treat a parameter, because
// the handler never runs.
expect(handler).not.toHaveBeenCalled();
});
it("lets a handler reject a decoded value against its own domain grammar", async () => {
// The platform supplies no generic parameter validator; this is the module's own check, which
// is exactly the documented contract.
const canonicalId = /^[0-9a-f]{8}$/u;
const handler = vi.fn<ModuleRouteHandler>(async (_request, context) => {
const id = context.params.id;
if (id === undefined || !canonicalId.test(id)) {
return Response.json({ code: "INVALID_REQUEST" }, { status: 400 });
}
return Response.json({ id });
});
const { app } = buildModuleApp({
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
routes: [
moduleRoute({
handler,
method: "GET",
moduleId: "calendar",
path: "/items/:id",
permission: readPermission,
}),
],
});
expect((await app.request("/api/v1/modules/calendar/items/deadbeef")).status).toBe(200);
const rejected = await app.request("/api/v1/modules/calendar/items/%2F..%2Fadmin");
expect(rejected.status).toBe(400);
expect(await rejected.json()).toEqual({ code: "INVALID_REQUEST" });
});
});
describe("module route overlap can never reach mounting", () => {
it("would otherwise let registration order choose the governing permission", async () => {
// Both orders are mounted directly here — bypassing the runtime rejection — to show precisely
// what `MODULE_ROUTE_OVERLAP` prevents: the same request reaching a different handler *and* a
// different permission depending only on the order the routes were registered in.
const createPermission = parsePermissionId("calendar.events.create");
const byId = vi.fn<ModuleRouteHandler>(async () => Response.json({ handler: "byId" }));
const create = vi.fn<ModuleRouteHandler>(async () => Response.json({ handler: "new" }));
const registry = permissionRegistry([
{ id: readPermission, baseline: "public" },
{ id: createPermission, baseline: "public" },
]);
const paramRoute = moduleRoute({
handler: byId,
method: "GET",
moduleId: "calendar",
path: "/events/:id",
permission: readPermission,
});
const staticRoute = moduleRoute({
handler: create,
method: "GET",
moduleId: "calendar",
path: "/events/new",
permission: createPermission,
});
const parameterFirst = buildModuleApp({ registry, routes: [paramRoute, staticRoute] });
const staticFirst = buildModuleApp({ registry, routes: [staticRoute, paramRoute] });
expect(
await (await parameterFirst.app.request("/api/v1/modules/calendar/events/new")).json(),
).toEqual({ handler: "byId" });
expect(
await (await staticFirst.app.request("/api/v1/modules/calendar/events/new")).json(),
).toEqual({ handler: "new" });
// Core rejects this pair during staged registration, so no such snapshot can ever be published
// to `mountModuleHttpRoutes` — see `core/tests/module-runtime.test.ts`.
expect(moduleRoutesOverlap(paramRoute, staticRoute)).toBe(true);
});
});
@@ -0,0 +1,13 @@
CREATE TABLE "ekklesia_core_platform_owner_bootstrap" (
"id" text PRIMARY KEY NOT NULL,
"consumed_at" timestamp with time zone DEFAULT now() NOT NULL,
"consumed_for_user_id" text,
CONSTRAINT "ekklesia_core_platform_owner_bootstrap_singleton_check" CHECK ("ekklesia_core_platform_owner_bootstrap"."id" = 'singleton')
);
--> statement-breakpoint
ALTER TABLE "ekklesia_core_authorization_audit" DROP CONSTRAINT "ekklesia_core_authorization_audit_subject_user_id_ekklesia_core_user_id_fk";
--> statement-breakpoint
INSERT INTO "ekklesia_core_platform_owner_bootstrap" ("id")
SELECT 'singleton'
WHERE EXISTS (SELECT 1 FROM "ekklesia_core_platform_owner")
ON CONFLICT ("id") DO NOTHING;
+710
View File
@@ -0,0 +1,710 @@
{
"id": "fac36f3d-7551-42df-a967-aad65c2fe7f9",
"prevId": "dfd53f55-6e2a-45e1-9962-469ea6eca6e7",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.ekklesia_core_account": {
"name": "ekklesia_core_account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"ekklesia_core_account_user_id_idx": {
"name": "ekklesia_core_account_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_account_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_account",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_session": {
"name": "ekklesia_core_session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"ekklesia_core_session_user_id_idx": {
"name": "ekklesia_core_session_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_session_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_session",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"ekklesia_core_session_token_unique": {
"name": "ekklesia_core_session_token_unique",
"nullsNotDistinct": false,
"columns": ["token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_user": {
"name": "ekklesia_core_user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"ekklesia_core_user_email_unique": {
"name": "ekklesia_core_user_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_verification": {
"name": "ekklesia_core_verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_verification_identifier_idx": {
"name": "ekklesia_core_verification_identifier_idx",
"columns": [
{
"expression": "identifier",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ekklesia_core_authorization_audit": {
"name": "ekklesia_core_authorization_audit",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true
},
"actor_type": {
"name": "actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"actor_id": {
"name": "actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"subject_user_id": {
"name": "subject_user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"permission_id": {
"name": "permission_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"grant_id": {
"name": "grant_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ekklesia_core_authorization_audit_subject_idx": {
"name": "ekklesia_core_authorization_audit_subject_idx",
"columns": [
{
"expression": "subject_user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_authorization_audit_action_check": {
"name": "ekklesia_core_authorization_audit_action_check",
"value": "\"ekklesia_core_authorization_audit\".\"action\" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')"
},
"ekklesia_core_authorization_audit_actor_type_check": {
"name": "ekklesia_core_authorization_audit_actor_type_check",
"value": "\"ekklesia_core_authorization_audit\".\"actor_type\" in ('user', 'system')"
},
"ekklesia_core_authorization_audit_scope_check": {
"name": "ekklesia_core_authorization_audit_scope_check",
"value": "\"ekklesia_core_authorization_audit\".\"scope\" is null or length(\"ekklesia_core_authorization_audit\".\"scope\") > 0"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_grant": {
"name": "ekklesia_core_grant",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"permission_id": {
"name": "permission_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"source_kind": {
"name": "source_kind",
"type": "text",
"primaryKey": false,
"notNull": true
},
"source_ref": {
"name": "source_ref",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"created_by_actor_type": {
"name": "created_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_by_actor_id": {
"name": "created_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_type": {
"name": "revoked_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_id": {
"name": "revoked_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"ekklesia_core_grant_user_permission_idx": {
"name": "ekklesia_core_grant_user_permission_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "permission_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"ekklesia_core_grant_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_grant",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_grant_source_kind_check": {
"name": "ekklesia_core_grant_source_kind_check",
"value": "\"ekklesia_core_grant\".\"source_kind\" in ('explicit', 'relationship')"
},
"ekklesia_core_grant_created_actor_type_check": {
"name": "ekklesia_core_grant_created_actor_type_check",
"value": "\"ekklesia_core_grant\".\"created_by_actor_type\" in ('user', 'system')"
},
"ekklesia_core_grant_revoked_actor_type_check": {
"name": "ekklesia_core_grant_revoked_actor_type_check",
"value": "\"ekklesia_core_grant\".\"revoked_by_actor_type\" is null or \"ekklesia_core_grant\".\"revoked_by_actor_type\" in ('user', 'system')"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_platform_owner": {
"name": "ekklesia_core_platform_owner",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"granted_at": {
"name": "granted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"granted_by_actor_type": {
"name": "granted_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"granted_by_actor_id": {
"name": "granted_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_type": {
"name": "revoked_by_actor_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"revoked_by_actor_id": {
"name": "revoked_by_actor_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk": {
"name": "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk",
"tableFrom": "ekklesia_core_platform_owner",
"tableTo": "ekklesia_core_user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_platform_owner_granted_actor_type_check": {
"name": "ekklesia_core_platform_owner_granted_actor_type_check",
"value": "\"ekklesia_core_platform_owner\".\"granted_by_actor_type\" in ('user', 'system')"
},
"ekklesia_core_platform_owner_revoked_actor_type_check": {
"name": "ekklesia_core_platform_owner_revoked_actor_type_check",
"value": "\"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" is null or \"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" in ('user', 'system')"
}
},
"isRLSEnabled": false
},
"public.ekklesia_core_platform_owner_bootstrap": {
"name": "ekklesia_core_platform_owner_bootstrap",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"consumed_at": {
"name": "consumed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"consumed_for_user_id": {
"name": "consumed_for_user_id",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"ekklesia_core_platform_owner_bootstrap_singleton_check": {
"name": "ekklesia_core_platform_owner_bootstrap_singleton_check",
"value": "\"ekklesia_core_platform_owner_bootstrap\".\"id\" = 'singleton'"
}
},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+6
View File
@@ -13,6 +13,12 @@
"destructive": { "destructive": {
"mode": "none" "mode": "none"
} }
},
{
"tag": "0002_authorization_integrity",
"destructive": {
"mode": "none"
}
} }
] ]
} }
+7
View File
@@ -15,6 +15,13 @@
"when": 1784819635300, "when": 1784819635300,
"tag": "0001_authorization_core_tables", "tag": "0001_authorization_core_tables",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1785104670124,
"tag": "0002_authorization_integrity",
"breakpoints": true
} }
] ]
} }
+33 -11
View File
@@ -2,7 +2,12 @@ import type { EkklesiaUserId } from "@ekklesia/contracts";
import { eq, sql } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import { ekklesiaCoreUser } from "../identity/schema.js"; import { ekklesiaCoreUser } from "../identity/schema.js";
import type { AuthorizationDrizzleDatabase } from "./database.js"; import type { AuthorizationDrizzleDatabase } from "./database.js";
import { ekklesiaCoreAuthorizationAudit, ekklesiaCorePlatformOwner } from "./schema.js"; import {
ekklesiaCoreAuthorizationAudit,
ekklesiaCorePlatformOwner,
ekklesiaCorePlatformOwnerBootstrap,
PLATFORM_OWNER_BOOTSTRAP_SINGLETON_ID,
} from "./schema.js";
export type PlatformOwnerBootstrapErrorCode = export type PlatformOwnerBootstrapErrorCode =
| "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED" | "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED"
@@ -54,17 +59,25 @@ export interface BootstrapPlatformOwnerInput {
/** /**
* Core's dedicated, Hono-independent platform-owner bootstrap operation. Deliberately not a * Core's dedicated, Hono-independent platform-owner bootstrap operation. Deliberately not a
* composition of "check, then call the existing grant function": the single-use check, the target * composition of "check, then call the existing grant function": the single-use claim, the target
* user's existence check, the owner-grant insert, and its audit entry are all one PostgreSQL * user's existence check, the owner-grant insert, and its audit entry are all one PostgreSQL
* transaction, serialized by a transaction-scoped advisory lock acquired as the very first * transaction, serialized by a transaction-scoped advisory lock acquired as the very first
* statement — so two concurrent callers can never both observe "no owner yet" and both insert. * statement — so two concurrent callers can never both observe "not yet consumed" and both insert.
* The second caller blocks on the lock until the first's transaction commits or rolls back, then * The second caller blocks on the lock until the first's transaction commits or rolls back, then
* re-evaluates against the now-current state. * re-evaluates against the now-current state.
* *
* "Already consumed" is global and permanent: it is true the moment * **Single-use authority lives in `ekklesia_core_platform_owner_bootstrap`, never in the continued
* `ekklesia_core_platform_owner` has ever held any row for any user, including one whose * existence of a platform-owner row.** The owner table's `user_id` cascades on user deletion, so
* `revoked_at` is set — a later, separate owner-management flow (not part of this slice) is how a * treating "an owner row exists" as the consumed marker meant deleting the bootstrapped owner's
* platform owner would ever be re-established after a revocation, not this command. * user row silently reopened the one operation that establishes a platform's first administrator —
* along with the audit evidence that it had ever run. The dedicated singleton has no user foreign
* key, is never deleted by Core, and therefore survives owner revocation and user deletion alike.
*
* Consumption is deliberately tied to *success*: the sentinel is written inside the same
* transaction as the owner row and its audit entry, so a missing target user, a constraint
* violation, or any other failure rolls the sentinel back with everything else and leaves the one
* legitimate first use still available. There is no reset command, deletion path, HTTP endpoint,
* environment override, or force flag — by design, "consumed" is permanent.
*/ */
export async function bootstrapPlatformOwner( export async function bootstrapPlatformOwner(
db: AuthorizationDrizzleDatabase, db: AuthorizationDrizzleDatabase,
@@ -76,11 +89,11 @@ export async function bootstrapPlatformOwner(
sql`select pg_advisory_xact_lock(${bootstrapLockNamespace}, ${bootstrapLockKey})`, sql`select pg_advisory_xact_lock(${bootstrapLockNamespace}, ${bootstrapLockKey})`,
); );
const existingOwnerRows = await tx const consumedRows = await tx
.select({ userId: ekklesiaCorePlatformOwner.userId }) .select({ id: ekklesiaCorePlatformOwnerBootstrap.id })
.from(ekklesiaCorePlatformOwner) .from(ekklesiaCorePlatformOwnerBootstrap)
.limit(1); .limit(1);
if (existingOwnerRows.length > 0) { if (consumedRows.length > 0) {
throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED"); throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED");
} }
@@ -93,6 +106,15 @@ export async function bootstrapPlatformOwner(
throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND"); throw new PlatformOwnerBootstrapError("PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND");
} }
// The sentinel is claimed inside the same transaction as everything below it. The advisory
// lock already serializes concurrent callers; the singleton primary key is the independent,
// structural backstop that makes a second row impossible even if a future caller reached
// this insert without the lock.
await tx.insert(ekklesiaCorePlatformOwnerBootstrap).values({
consumedForUserId: input.userId,
id: PLATFORM_OWNER_BOOTSTRAP_SINGLETON_ID,
});
await tx.insert(ekklesiaCorePlatformOwner).values({ await tx.insert(ekklesiaCorePlatformOwner).values({
grantedByActorId: BOOTSTRAP_ACTOR_ID, grantedByActorId: BOOTSTRAP_ACTOR_ID,
grantedByActorType: BOOTSTRAP_ACTOR_TYPE, grantedByActorType: BOOTSTRAP_ACTOR_TYPE,
+13
View File
@@ -1,5 +1,9 @@
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
import { Pool, type PoolConfig } from "pg"; import { Pool, type PoolConfig } from "pg";
import {
type DatabaseTransportPolicy,
databaseTlsConnectionOptions,
} from "../database/transport.js";
import { authorizationSchema } from "./schema.js"; import { authorizationSchema } from "./schema.js";
export type AuthorizationDrizzleDatabase = NodePgDatabase<typeof authorizationSchema>; export type AuthorizationDrizzleDatabase = NodePgDatabase<typeof authorizationSchema>;
@@ -27,6 +31,10 @@ export interface AuthorizationDatabaseOptions {
* `core/src/identity/database.ts`: `connectionTimeoutMs` alone only bounds establishing a * `core/src/identity/database.ts`: `connectionTimeoutMs` alone only bounds establishing a
* connection, not a hung query already running on one. */ * connection, not a hung query already running on one. */
readonly operationTimeoutMs?: number; readonly operationTimeoutMs?: number;
/** The validated transport policy (`core/src/database/transport.js`). Passed explicitly by
* every production executable; omitted by single-domain unit/integration factories that use
* the disposable loopback database, which keeps the pre-existing plaintext behavior. */
readonly transport?: DatabaseTransportPolicy;
} }
export interface AuthorizationDatabaseHandle { export interface AuthorizationDatabaseHandle {
@@ -80,8 +88,13 @@ export function createAuthorizationDrizzleDatabase(
const validatedConnectionString = validateConnectionString(connectionString); const validatedConnectionString = validateConnectionString(connectionString);
const validatedConnectionTimeoutMs = validateTimeout(options.connectionTimeoutMs); const validatedConnectionTimeoutMs = validateTimeout(options.connectionTimeoutMs);
const validatedOperationTimeoutMs = validateTimeout(options.operationTimeoutMs); const validatedOperationTimeoutMs = validateTimeout(options.operationTimeoutMs);
const ssl =
options.transport === undefined
? undefined
: databaseTlsConnectionOptions(validatedConnectionString, options.transport);
const poolConfig: PoolConfig = { const poolConfig: PoolConfig = {
connectionString: validatedConnectionString, connectionString: validatedConnectionString,
...(ssl === undefined ? {} : { ssl }),
...(validatedConnectionTimeoutMs === undefined ...(validatedConnectionTimeoutMs === undefined
? {} ? {}
: { connectionTimeoutMillis: validatedConnectionTimeoutMs }), : { connectionTimeoutMillis: validatedConnectionTimeoutMs }),
+53 -17
View File
@@ -72,11 +72,20 @@ export const ekklesiaCorePlatformOwner = pgTable(
); );
/** /**
* Append-only: no update or delete path is ever exposed by Core's authorization store. `scope` * Append-only *by application convention*: Core's authorization store exposes no update or delete
* intentionally rejects an empty (but non-null) string — defense in depth matching the * path for these rows. That is a property of this codebase, not a database guarantee — no
* application-layer `AuthorizationScope` contract, which never produces one — so a coding defect * PostgreSQL role, trigger, or rule prevents a database administrator, a future migration, or
* that slipped an empty scope past that validation fails the whole grant-plus-audit transaction * anything else holding the same credentials from modifying them. `scope` intentionally rejects an
* atomically instead of recording a meaningless audit entry. * empty (but non-null) string — defense in depth matching the application-layer
* `AuthorizationScope` contract, which never produces one — so a coding defect that slipped an
* empty scope past that validation fails the whole grant-plus-audit transaction atomically instead
* of recording a meaningless audit entry.
*
* `subject_user_id` is a **retained historical snapshot**, deliberately carrying no foreign key to
* the live Better Auth user row. It previously cascaded on user deletion, which meant deleting a
* user destroyed exactly the security history describing what that user had been granted and who
* granted it — the audit trail disappeared precisely when it mattered most. The value stays a
* plain, non-null identifier string; no name, email, or other profile data is ever copied here.
*/ */
export const ekklesiaCoreAuthorizationAudit = pgTable( export const ekklesiaCoreAuthorizationAudit = pgTable(
"ekklesia_core_authorization_audit", "ekklesia_core_authorization_audit",
@@ -85,9 +94,7 @@ export const ekklesiaCoreAuthorizationAudit = pgTable(
action: text("action").notNull(), action: text("action").notNull(),
actorType: text("actor_type").notNull(), actorType: text("actor_type").notNull(),
actorId: text("actor_id"), actorId: text("actor_id"),
subjectUserId: text("subject_user_id") subjectUserId: text("subject_user_id").notNull(),
.notNull()
.references(() => ekklesiaCoreUser.id, { onDelete: "cascade" }),
permissionId: text("permission_id"), permissionId: text("permission_id"),
scope: text("scope"), scope: text("scope"),
grantId: uuid("grant_id"), grantId: uuid("grant_id"),
@@ -110,6 +117,40 @@ export const ekklesiaCoreAuthorizationAudit = pgTable(
], ],
); );
/**
* The durable, single-row record meaning "platform-owner bootstrap was successfully consumed."
*
* Bootstrap's single-use authority previously came from the mere existence of a row in
* `ekklesia_core_platform_owner` — which cascades on user deletion, so deleting the bootstrapped
* owner's user row silently reopened the one-time operation that establishes the platform's first
* administrator. This table exists specifically so that authority survives owner revocation and
* user deletion: it has **no foreign key to any user**, and nothing in Core ever deletes from it.
*
* `id` is pinned by a check constraint to the single literal value `'singleton'`, so the primary
* key makes a second row structurally impossible rather than merely unwritten by convention —
* concurrent first attempts collide on the primary key inside their transactions.
*
* `consumed_for_user_id` is an optional historical snapshot of who bootstrap made an owner, in the
* same retained-identifier style as the audit subject and carrying no profile data. It is null for
* an installation whose consumed state was backfilled by migration, because a backfill cannot know
* which of several historical owner rows the original single use actually targeted, and guessing
* one would fabricate history.
*/
export const ekklesiaCorePlatformOwnerBootstrap = pgTable(
"ekklesia_core_platform_owner_bootstrap",
{
id: text("id").primaryKey(),
consumedAt: timestamp("consumed_at", { withTimezone: true }).defaultNow().notNull(),
consumedForUserId: text("consumed_for_user_id"),
},
(table) => [
check("ekklesia_core_platform_owner_bootstrap_singleton_check", sql`${table.id} = 'singleton'`),
],
);
/** The only value `ekklesia_core_platform_owner_bootstrap.id` may ever hold. */
export const PLATFORM_OWNER_BOOTSTRAP_SINGLETON_ID = "singleton";
export const ekklesiaCoreGrantRelations = relations(ekklesiaCoreGrant, ({ one }) => ({ export const ekklesiaCoreGrantRelations = relations(ekklesiaCoreGrant, ({ one }) => ({
user: one(ekklesiaCoreUser, { user: one(ekklesiaCoreUser, {
fields: [ekklesiaCoreGrant.userId], fields: [ekklesiaCoreGrant.userId],
@@ -127,19 +168,14 @@ export const ekklesiaCorePlatformOwnerRelations = relations(
}), }),
); );
export const ekklesiaCoreAuthorizationAuditRelations = relations( // `ekklesiaCoreAuthorizationAudit` deliberately declares no `relations(...)` to the user table:
ekklesiaCoreAuthorizationAudit, // its subject is a retained historical identifier that may name a user row that no longer exists,
({ one }) => ({ // so modelling it as a live relation would misdescribe the data.
subject: one(ekklesiaCoreUser, {
fields: [ekklesiaCoreAuthorizationAudit.subjectUserId],
references: [ekklesiaCoreUser.id],
}),
}),
);
/** Explicit map passed to Drizzle, mirroring `identityAdapterSchema`'s explicit-mapping style. */ /** Explicit map passed to Drizzle, mirroring `identityAdapterSchema`'s explicit-mapping style. */
export const authorizationSchema = { export const authorizationSchema = {
authorizationAudit: ekklesiaCoreAuthorizationAudit, authorizationAudit: ekklesiaCoreAuthorizationAudit,
grant: ekklesiaCoreGrant, grant: ekklesiaCoreGrant,
platformOwner: ekklesiaCorePlatformOwner, platformOwner: ekklesiaCorePlatformOwner,
platformOwnerBootstrap: ekklesiaCorePlatformOwnerBootstrap,
} as const; } as const;
+289 -24
View File
@@ -28,7 +28,10 @@ import {
} from "./schema.js"; } from "./schema.js";
export type AuthorizationStoreErrorCode = export type AuthorizationStoreErrorCode =
| "AUTHORIZATION_GRANT_ALREADY_EXISTS"
| "AUTHORIZATION_GRANT_CREATE_FAILED" | "AUTHORIZATION_GRANT_CREATE_FAILED"
| "AUTHORIZATION_GRANT_EXPIRATION_INVALID"
| "AUTHORIZATION_GRANT_LIMIT_INVALID"
| "AUTHORIZATION_GRANT_LIST_FAILED" | "AUTHORIZATION_GRANT_LIST_FAILED"
| "AUTHORIZATION_GRANT_LOOKUP_FAILED" | "AUTHORIZATION_GRANT_LOOKUP_FAILED"
| "AUTHORIZATION_GRANT_NOT_FOUND" | "AUTHORIZATION_GRANT_NOT_FOUND"
@@ -38,7 +41,12 @@ export type AuthorizationStoreErrorCode =
| "AUTHORIZATION_PLATFORM_OWNER_NOT_FOUND"; | "AUTHORIZATION_PLATFORM_OWNER_NOT_FOUND";
const storeErrorMessages: Readonly<Record<AuthorizationStoreErrorCode, string>> = Object.freeze({ const storeErrorMessages: Readonly<Record<AuthorizationStoreErrorCode, string>> = Object.freeze({
AUTHORIZATION_GRANT_ALREADY_EXISTS:
"An equivalent authorization grant is already effective for this user.",
AUTHORIZATION_GRANT_CREATE_FAILED: "The authorization grant could not be created.", AUTHORIZATION_GRANT_CREATE_FAILED: "The authorization grant could not be created.",
AUTHORIZATION_GRANT_EXPIRATION_INVALID:
"The authorization grant expiration must be a valid instant in the future.",
AUTHORIZATION_GRANT_LIMIT_INVALID: "The authorization grant page size is invalid.",
AUTHORIZATION_GRANT_LIST_FAILED: "The authorization grant list could not be retrieved.", AUTHORIZATION_GRANT_LIST_FAILED: "The authorization grant list could not be retrieved.",
AUTHORIZATION_GRANT_LOOKUP_FAILED: "The authorization grant lookup could not be completed.", AUTHORIZATION_GRANT_LOOKUP_FAILED: "The authorization grant lookup could not be completed.",
AUTHORIZATION_GRANT_NOT_FOUND: "The authorization grant was not found or is already revoked.", AUTHORIZATION_GRANT_NOT_FOUND: "The authorization grant was not found or is already revoked.",
@@ -49,6 +57,10 @@ const storeErrorMessages: Readonly<Record<AuthorizationStoreErrorCode, string>>
"The platform-owner record was not found or is already revoked.", "The platform-owner record was not found or is already revoked.",
}); });
/** The largest page any caller may request from `listGrantsForUser`, enforced identically by the
* store and by the HTTP surface so there is exactly one bound, not two that could drift. */
export const AUTHORIZATION_GRANT_LIST_MAX_LIMIT = 100;
/** Never carries the underlying database error, SQL text, host, port, path, or stack. */ /** Never carries the underlying database error, SQL text, host, port, path, or stack. */
export class AuthorizationStoreError extends Error { export class AuthorizationStoreError extends Error {
readonly code: AuthorizationStoreErrorCode; readonly code: AuthorizationStoreErrorCode;
@@ -101,11 +113,38 @@ export interface RevokeGrantInput {
readonly sourceKind?: GrantSourceKind; readonly sourceKind?: GrantSourceKind;
} }
/** One row's position in the `created_at DESC, id DESC` ordering, used as a strict keyset
* boundary. Both values are required: comparing on the timestamp alone would skip or repeat rows
* whenever two grants share a `created_at`, which concurrent creation makes routine. */
export interface GrantPageBoundary {
readonly createdAt: Date;
readonly id: GrantId;
}
export interface ListGrantsForUserOptions { export interface ListGrantsForUserOptions {
/** Required. The maximum number of rows to return, 1 through
* `AUTHORIZATION_GRANT_LIST_MAX_LIMIT`. There is deliberately no default and no unbounded
* fallback anywhere: an unbounded listing returned a user's entire authorization history in one
* response, and a bound a caller may simply omit is not a bound. */
readonly limit: number;
/** When provided, only grants strictly *after* this position in the ordering are returned. */
readonly after?: GrantPageBoundary;
/** When provided, only grants with this exact `sourceKind` are returned. */ /** When provided, only grants with this exact `sourceKind` are returned. */
readonly sourceKind?: GrantSourceKind; readonly sourceKind?: GrantSourceKind;
} }
export interface GrantPage {
readonly grants: readonly GrantRecord[];
/** The last returned row's position when more rows exist, otherwise `undefined`. */
readonly nextBoundary?: GrantPageBoundary;
}
export interface CreateAuthorizationStoreOptions {
/** Injected only so expiration validation is deterministic under test. Production composition
* leaves this unset and the store reads real time. */
readonly now?: () => Date;
}
/** /**
* Core's Postgres-backed authorization persistence: grants, platform-owner status, and the * Core's Postgres-backed authorization persistence: grants, platform-owner status, and the
* append-only audit log, all behind one explicit factory — no environment reads, no connection * append-only audit log, all behind one explicit factory — no environment reads, no connection
@@ -117,12 +156,9 @@ export interface ListGrantsForUserOptions {
export interface AuthorizationStore extends AuthorizationGrantPort, AuthorizationPlatformOwnerPort { export interface AuthorizationStore extends AuthorizationGrantPort, AuthorizationPlatformOwnerPort {
createGrant(input: CreateGrantInput): Promise<GrantRecord>; createGrant(input: CreateGrantInput): Promise<GrantRecord>;
revokeGrant(input: RevokeGrantInput): Promise<void>; revokeGrant(input: RevokeGrantInput): Promise<void>;
/** Every grant ever recorded for the user, active or revoked, newest first — the caller decides /** One bounded page of the user's grants, active or revoked, ordered `created_at DESC, id DESC`.
* what to display. Never includes another user's grants. */ * Never includes another user's grants. */
listGrantsForUser( listGrantsForUser(userId: EkklesiaUserId, options: ListGrantsForUserOptions): Promise<GrantPage>;
userId: EkklesiaUserId,
options?: ListGrantsForUserOptions,
): Promise<readonly GrantRecord[]>;
grantPlatformOwner(userId: EkklesiaUserId, actor: AuthorizationActor): Promise<void>; grantPlatformOwner(userId: EkklesiaUserId, actor: AuthorizationActor): Promise<void>;
revokePlatformOwner(userId: EkklesiaUserId, actor: AuthorizationActor): Promise<void>; revokePlatformOwner(userId: EkklesiaUserId, actor: AuthorizationActor): Promise<void>;
} }
@@ -160,11 +196,141 @@ function toGrantRecord(row: GrantRow): GrantRecord {
}); });
} }
export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): AuthorizationStore { /**
* The five values that make two grant issuances the *same authorization source*: subject,
* permission, scope, source kind, and source reference — with `NULL` equal only to `NULL`.
* `expires_at`, creation metadata, actor metadata, and the grant id deliberately do **not**
* distinguish them: re-issuing the identical authority with a different expiry or by a different
* administrator is the same grant, not a second one.
*/
interface GrantEquivalence {
readonly userId: string;
readonly permissionId: string;
readonly scope: string | null;
readonly sourceKind: string;
readonly sourceRef: string | null;
}
function equivalenceOf(input: CreateGrantInput): GrantEquivalence {
return {
permissionId: input.permission,
scope: input.scope ?? null,
sourceKind: input.sourceKind,
sourceRef: input.sourceRef ?? null,
userId: input.userId,
};
}
/**
* Serializes every create/revoke touching one equivalence tuple against a transaction-scoped
* PostgreSQL advisory lock, so concurrent callers cannot both observe "no effective equivalent"
* and both insert. A process-local mutex would not do this — nothing constrains the number of API
* processes — and neither would an unlocked check-then-insert.
*
* The key is a 64-bit hash of the tuple encoded as a JSON array, which keeps `NULL` and the empty
* string distinct and makes separator forgery through a crafted scope or source reference
* impossible. A hash collision is harmless by construction: it can only make two unrelated tuples
* serialize against each other, never make a real conflict go unnoticed, because the authoritative
* decision is always the exact SQL predicate below and never the hash.
*
* `pg_advisory_xact_lock(bigint)` uses PostgreSQL's *single-key* advisory-lock space, which is
* verified to be disjoint from the two-key space used by the migration runner
* (`core/src/migrations/postgres-database.ts`) and by platform-owner bootstrap. Grant serialization
* therefore can never block, or be blocked by, migration or bootstrap locking. Being
* transaction-scoped, it is released on COMMIT or ROLLBACK with no manual unlock.
*/
async function lockEquivalence(
tx: Parameters<Parameters<AuthorizationDrizzleDatabase["transaction"]>[0]>[0],
equivalence: GrantEquivalence,
): Promise<void> {
await tx.execute(
sql`select pg_advisory_xact_lock(hashtextextended(jsonb_build_array(
${equivalence.userId}::text,
${equivalence.permissionId}::text,
${equivalence.scope}::text,
${equivalence.sourceKind}::text,
${equivalence.sourceRef}::text
)::text, 0))`,
);
}
/** Matches exactly the rows sharing an equivalence tuple. `is not distinct from` is what gives
* `NULL = NULL` for the two nullable members; plain `=` would silently match nothing. */
function equivalenceCondition(equivalence: GrantEquivalence) {
return and(
eq(ekklesiaCoreGrant.userId, equivalence.userId),
eq(ekklesiaCoreGrant.permissionId, equivalence.permissionId),
sql`${ekklesiaCoreGrant.scope} is not distinct from ${equivalence.scope}::text`,
eq(ekklesiaCoreGrant.sourceKind, equivalence.sourceKind),
sql`${ekklesiaCoreGrant.sourceRef} is not distinct from ${equivalence.sourceRef}::text`,
);
}
/** A supplied expiration must be a real instant strictly later than the acceptance clock. A past
* or equal-to-now expiration produces a grant that is dead the moment it is written, which is
* never what a caller meant; rejecting it here means trusted internal callers get the same
* guarantee the HTTP surface enforces, rather than each validating separately. */
function assertValidExpiration(expiresAt: Date | undefined, now: Date): void {
if (expiresAt === undefined) {
return;
}
if (
!(expiresAt instanceof Date) ||
Number.isNaN(expiresAt.getTime()) ||
expiresAt.getTime() <= now.getTime()
) {
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_EXPIRATION_INVALID");
}
}
function assertValidLimit(limit: number): number {
if (
typeof limit !== "number" ||
!Number.isSafeInteger(limit) ||
limit < 1 ||
limit > AUTHORIZATION_GRANT_LIST_MAX_LIMIT
) {
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_LIMIT_INVALID");
}
return limit;
}
export function createAuthorizationStore(
db: AuthorizationDrizzleDatabase,
options: CreateAuthorizationStoreOptions = {},
): AuthorizationStore {
const now = options.now ?? ((): Date => new Date());
return Object.freeze({ return Object.freeze({
createGrant: async (input: CreateGrantInput): Promise<GrantRecord> => { createGrant: async (input: CreateGrantInput): Promise<GrantRecord> => {
// Validated before any transaction opens, so a rejected expiration performs no database work
// at all and can never leave a grant or an audit row behind.
assertValidExpiration(input.expiresAt, now());
const equivalence = equivalenceOf(input);
try { try {
return await db.transaction(async (tx) => { return await db.transaction(async (tx) => {
await lockEquivalence(tx, equivalence);
// Under the lock, "currently effective" is authoritative: an equivalent that is revoked,
// or whose expiry has already passed, does not block a fresh issuance.
const effective = await tx
.select({ id: ekklesiaCoreGrant.id })
.from(ekklesiaCoreGrant)
.where(
and(
equivalenceCondition(equivalence),
isNull(ekklesiaCoreGrant.revokedAt),
or(
isNull(ekklesiaCoreGrant.expiresAt),
gt(ekklesiaCoreGrant.expiresAt, sql`now()`),
),
),
)
.limit(1);
if (effective.length > 0) {
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_ALREADY_EXISTS");
}
const [row] = await tx const [row] = await tx
.insert(ekklesiaCoreGrant) .insert(ekklesiaCoreGrant)
.values({ .values({
@@ -202,16 +368,33 @@ export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): Auth
} }
}, },
/**
* Revoking one grant revokes *every* currently unrevoked issuance sharing its equivalence
* tuple, in one transaction, with one `grant.revoke` audit row per grant actually changed.
*
* Addressing a single id and revoking only that row was actively misleading: an operator who
* revoked a grant received `success` while an equivalent duplicate kept authorizing the same
* user. Expired-but-unrevoked equivalents are included deliberately — they are already
* ineffective, but leaving them unrevoked would keep the history in two states for what is one
* authorization source. Grants with a different subject, permission, scope, source kind, or
* source reference are untouched.
*/
revokeGrant: async (input: RevokeGrantInput): Promise<void> => { revokeGrant: async (input: RevokeGrantInput): Promise<void> => {
try { try {
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
const [row] = await tx // This first read exists only to discover the equivalence tuple, which is safe to read
.update(ekklesiaCoreGrant) // before the lock because those columns are immutable once written. It deliberately does
.set({ // *not* settle whether the revocation may proceed: everything it observed can change
revokedAt: sql`now()`, // while this transaction waits for the lock below.
revokedByActorId: actorId(input.actor), const [target] = await tx
revokedByActorType: input.actor.type, .select({
permissionId: ekklesiaCoreGrant.permissionId,
scope: ekklesiaCoreGrant.scope,
sourceKind: ekklesiaCoreGrant.sourceKind,
sourceRef: ekklesiaCoreGrant.sourceRef,
userId: ekklesiaCoreGrant.userId,
}) })
.from(ekklesiaCoreGrant)
.where( .where(
and( and(
eq(ekklesiaCoreGrant.id, input.grantId), eq(ekklesiaCoreGrant.id, input.grantId),
@@ -221,12 +404,65 @@ export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): Auth
: [eq(ekklesiaCoreGrant.sourceKind, input.sourceKind)]), : [eq(ekklesiaCoreGrant.sourceKind, input.sourceKind)]),
), ),
) )
.returning(); .limit(1);
if (!row) { if (!target) {
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND"); throw new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND");
} }
await tx.insert(ekklesiaCoreAuthorizationAudit).values({ await lockEquivalence(tx, target);
// Revalidate the *addressed* grant now that the lock is held. Without this, a revocation
// that paused between the read above and the lock could wake after its target was
// revoked and a fresh equivalent created, and the tuple-matching update below would
// silently revoke that replacement — reporting success for a grant the caller never
// addressed, in an order no serial execution could produce, and losing the not-found
// result an already-revoked target must return.
//
// The check is deliberately narrow and re-asserts every condition the pre-lock read
// relied on: the exact addressed id, still unrevoked, still satisfying the caller's
// explicit/relationship source guard, and still carrying the same equivalence tuple the
// lock was taken for. It is a plain read, not `for update`: the tuple advisory lock
// remains the one create/revoke serialization order, so no second lock ordering — and no
// deadlock path — is introduced.
const [addressed] = await tx
.select({ id: ekklesiaCoreGrant.id })
.from(ekklesiaCoreGrant)
.where(
and(
eq(ekklesiaCoreGrant.id, input.grantId),
isNull(ekklesiaCoreGrant.revokedAt),
...(input.sourceKind === undefined
? []
: [eq(ekklesiaCoreGrant.sourceKind, input.sourceKind)]),
equivalenceCondition(target),
),
)
.limit(1);
if (!addressed) {
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND");
}
// Only now is the whole equivalence class cleared, which is what still removes genuine
// legacy duplicates — including expired-but-unrevoked ones. Narrowing this update to the
// addressed id instead would not have been a fix; it would have dropped that behavior.
const rows = await tx
.update(ekklesiaCoreGrant)
.set({
revokedAt: sql`now()`,
revokedByActorId: actorId(input.actor),
revokedByActorType: input.actor.type,
})
.where(and(equivalenceCondition(target), isNull(ekklesiaCoreGrant.revokedAt)))
.returning();
if (rows.length === 0) {
// Unreachable while the lock is held, since the row confirmed above satisfies this
// same predicate — kept as a fail-closed guard against a writer that bypasses the
// lock, rather than silently auditing nothing.
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND");
}
await tx.insert(ekklesiaCoreAuthorizationAudit).values(
rows.map((row) => ({
action: "grant.revoke", action: "grant.revoke",
actorId: actorId(input.actor), actorId: actorId(input.actor),
actorType: input.actor.type, actorType: input.actor.type,
@@ -234,7 +470,8 @@ export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): Auth
permissionId: row.permissionId, permissionId: row.permissionId,
scope: row.scope, scope: row.scope,
subjectUserId: row.userId, subjectUserId: row.userId,
}); })),
);
}); });
} catch (error) { } catch (error) {
if (isAuthorizationStoreError(error)) { if (isAuthorizationStoreError(error)) {
@@ -269,10 +506,21 @@ export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): Auth
} }
}, },
/**
* Deterministic keyset pagination over `created_at DESC, id DESC`. The boundary is a strict
* row-value comparison on *both* columns, so grants sharing a `created_at` — which concurrent
* creation makes routine — can neither be skipped nor repeated across pages. Offset pagination
* would do exactly that under concurrent writes, which is why it is not used.
*
* At most `limit + 1` rows are read: the extra row only answers "is there another page" and is
* never returned.
*/
listGrantsForUser: async ( listGrantsForUser: async (
userId: EkklesiaUserId, userId: EkklesiaUserId,
options: ListGrantsForUserOptions = {}, listOptions: ListGrantsForUserOptions,
): Promise<readonly GrantRecord[]> => { ): Promise<GrantPage> => {
const limit = assertValidLimit(listOptions.limit);
const after = listOptions.after;
try { try {
const rows = await db const rows = await db
.select() .select()
@@ -280,14 +528,31 @@ export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): Auth
.where( .where(
and( and(
eq(ekklesiaCoreGrant.userId, userId), eq(ekklesiaCoreGrant.userId, userId),
...(options.sourceKind === undefined ...(listOptions.sourceKind === undefined
? [] ? []
: [eq(ekklesiaCoreGrant.sourceKind, options.sourceKind)]), : [eq(ekklesiaCoreGrant.sourceKind, listOptions.sourceKind)]),
...(after === undefined
? []
: [
sql`(${ekklesiaCoreGrant.createdAt}, ${ekklesiaCoreGrant.id}) < (${after.createdAt}::timestamptz, ${after.id}::uuid)`,
]),
), ),
) )
.orderBy(desc(ekklesiaCoreGrant.createdAt)); .orderBy(desc(ekklesiaCoreGrant.createdAt), desc(ekklesiaCoreGrant.id))
return Object.freeze(rows.map(toGrantRecord)); .limit(limit + 1);
} catch {
const page = rows.slice(0, limit).map(toGrantRecord);
const last = page.at(-1);
return Object.freeze({
grants: Object.freeze(page),
...(rows.length > limit && last !== undefined
? { nextBoundary: Object.freeze({ createdAt: last.createdAt, id: last.id }) }
: {}),
});
} catch (error) {
if (isAuthorizationStoreError(error)) {
throw error;
}
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_LIST_FAILED"); throw new AuthorizationStoreError("AUTHORIZATION_GRANT_LIST_FAILED");
} }
}, },
+9 -1
View File
@@ -1,11 +1,19 @@
import { formatMigrationApplyReport } from "./migration-report.js"; import { formatMigrationApplyReport } from "./migration-report.js";
import { discoverRepositoryMigrations } from "./migration-command-support.js"; import { discoverRepositoryMigrations } from "./migration-command-support.js";
import { reportMigrationCommandError } from "./migration-command-errors.js"; import { reportMigrationCommandError } from "./migration-command-errors.js";
import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js";
import { applyModuleMigrations, createPostgresMigrationDatabase } from "../migrations/index.js"; import { applyModuleMigrations, createPostgresMigrationDatabase } from "../migrations/index.js";
async function main(): Promise<void> { async function main(): Promise<void> {
const aggregate = await discoverRepositoryMigrations(); const aggregate = await discoverRepositoryMigrations();
const database = createPostgresMigrationDatabase(process.env.DATABASE_URL); const databaseUrl = process.env.DATABASE_URL;
// Resolved and asserted before any client exists: an unsafe production transport fails
// here rather than after opening a connection.
const transport =
typeof databaseUrl === "string" ? resolveExecutableDatabaseTransport(databaseUrl) : undefined;
const database = createPostgresMigrationDatabase(databaseUrl, {
...(transport === undefined ? {} : { transport }),
});
const result = await applyModuleMigrations({ aggregate, database }); const result = await applyModuleMigrations({ aggregate, database });
for (const line of formatMigrationApplyReport(result)) { for (const line of formatMigrationApplyReport(result)) {
+5
View File
@@ -6,6 +6,7 @@ import {
createAuthorizationDrizzleDatabase, createAuthorizationDrizzleDatabase,
PlatformOwnerBootstrapError, PlatformOwnerBootstrapError,
} from "../authorization/index.js"; } from "../authorization/index.js";
import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js";
class BootstrapCliError extends Error { class BootstrapCliError extends Error {
constructor(message: string) { constructor(message: string) {
@@ -67,9 +68,13 @@ async function main(): Promise<void> {
throw new BootstrapCliError("DATABASE_URL is required."); throw new BootstrapCliError("DATABASE_URL is required.");
} }
// Asserted before the connection is constructed, so an unsafe production transport never opens
// a client at all.
const transport = resolveExecutableDatabaseTransport(databaseUrl);
const { close, db } = createAuthorizationDrizzleDatabase(databaseUrl, { const { close, db } = createAuthorizationDrizzleDatabase(databaseUrl, {
connectionTimeoutMs: 10_000, connectionTimeoutMs: 10_000,
operationTimeoutMs: 10_000, operationTimeoutMs: 10_000,
transport,
}); });
try { try {
await bootstrapPlatformOwner(db, { userId: parsedUserId.data }); await bootstrapPlatformOwner(db, { userId: parsedUserId.data });
@@ -0,0 +1,46 @@
import {
assertProductionDatabaseTransport,
type DatabaseTransportEnvironment,
type DatabaseTransportPolicy,
createDatabaseTransportPolicy,
} from "../database/transport.js";
/**
* The one place every Core-owned executable reads its database transport policy from the process
* environment, so `pnpm doctor`, `pnpm migrations:plan`, `pnpm migrations:apply`, and
* `pnpm authorization:bootstrap-owner` cannot drift from each other or from `apps/api`.
*
* Environment reading lives here, in the executable layer, deliberately: the reusable Core
* database/migration constructors keep taking explicit typed inputs and never touch `process.env`.
* `NODE_ENV` is interpreted exactly as `apps/api` interprets it — only the literal string
* `"development"` relaxes anything, so an unset or misspelled value validates as production.
*/
export function readDatabaseTransportFromEnvironment(): {
readonly environment: DatabaseTransportEnvironment;
readonly transport: DatabaseTransportPolicy;
} {
const environment: DatabaseTransportEnvironment =
process.env.NODE_ENV === "development" ? "development" : "production";
const transport = createDatabaseTransportPolicy(
{
...(process.env.EKKLESIA_DATABASE_CA === undefined
? {}
: { certificateAuthority: process.env.EKKLESIA_DATABASE_CA }),
...(process.env.EKKLESIA_DATABASE_TLS_MODE === undefined
? {}
: { mode: process.env.EKKLESIA_DATABASE_TLS_MODE }),
},
{ environment },
);
return { environment, transport };
}
/**
* Resolves the policy and asserts it is safe for the given database URL before any client, pool,
* or connection is constructed. Throws the sanitized `DatabaseTransportError` otherwise.
*/
export function resolveExecutableDatabaseTransport(databaseUrl: string): DatabaseTransportPolicy {
const { environment, transport } = readDatabaseTransportFromEnvironment();
assertProductionDatabaseTransport(databaseUrl, transport, environment);
return transport;
}
+30
View File
@@ -39,6 +39,7 @@ function requiredString(value: unknown): string {
function readIdentityConfigurationFromEnvironment(): DoctorOptions["identityConfiguration"] { function readIdentityConfigurationFromEnvironment(): DoctorOptions["identityConfiguration"] {
const secret = process.env.EKKLESIA_IDENTITY_SECRET; const secret = process.env.EKKLESIA_IDENTITY_SECRET;
const baseUrl = process.env.EKKLESIA_IDENTITY_BASE_URL; const baseUrl = process.env.EKKLESIA_IDENTITY_BASE_URL;
const publicSignupEnabled = process.env.EKKLESIA_PUBLIC_SIGNUP_ENABLED;
const trustedOriginsValue = process.env.EKKLESIA_IDENTITY_TRUSTED_ORIGINS; const trustedOriginsValue = process.env.EKKLESIA_IDENTITY_TRUSTED_ORIGINS;
const trustedOrigins = trustedOriginsValue const trustedOrigins = trustedOriginsValue
? trustedOriginsValue ? trustedOriginsValue
@@ -50,11 +51,28 @@ function readIdentityConfigurationFromEnvironment(): DoctorOptions["identityConf
return { return {
...(baseUrl ? { baseUrl } : {}), ...(baseUrl ? { baseUrl } : {}),
environment: process.env.NODE_ENV === "development" ? "development" : "production", environment: process.env.NODE_ENV === "development" ? "development" : "production",
...(publicSignupEnabled ? { publicSignupEnabled } : {}),
...(secret ? { secret } : {}), ...(secret ? { secret } : {}),
...(trustedOrigins ? { trustedOrigins } : {}), ...(trustedOrigins ? { trustedOrigins } : {}),
}; };
} }
function readClientAddressFromEnvironment(): DoctorOptions["clientAddress"] {
const mode = process.env.EKKLESIA_CLIENT_ADDRESS_MODE;
if (mode === undefined) {
return undefined;
}
const trustedProxiesValue = process.env.EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES;
return {
header: process.env.EKKLESIA_CLIENT_ADDRESS_HEADER,
mode,
trustedProxies: trustedProxiesValue
?.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0),
};
}
export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise<number> { export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise<number> {
const [rootValue, moduleSdkValue] = await Promise.all([ const [rootValue, moduleSdkValue] = await Promise.all([
readJson(path.join(repositoryRoot, "package.json")), readJson(path.join(repositoryRoot, "package.json")),
@@ -63,7 +81,19 @@ export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise<
const rootPackage = rootPackageDocument(rootValue); const rootPackage = rootPackageDocument(rootValue);
const moduleSdkPackage = rootPackageDocument(moduleSdkValue); const moduleSdkPackage = rootPackageDocument(moduleSdkValue);
const databaseEnvironment =
process.env.NODE_ENV === "development" ? ("development" as const) : ("production" as const);
const report = await diagnoseEkklesia({ const report = await diagnoseEkklesia({
clientAddress: readClientAddressFromEnvironment(),
databaseEnvironment,
databaseTransport: {
...(process.env.EKKLESIA_DATABASE_CA === undefined
? {}
: { certificateAuthority: process.env.EKKLESIA_DATABASE_CA }),
...(process.env.EKKLESIA_DATABASE_TLS_MODE === undefined
? {}
: { mode: process.env.EKKLESIA_DATABASE_TLS_MODE }),
},
databaseUrl: process.env.DATABASE_URL, databaseUrl: process.env.DATABASE_URL,
identityConfiguration: readIdentityConfigurationFromEnvironment(), identityConfiguration: readIdentityConfigurationFromEnvironment(),
moduleDiscovery: { moduleDiscovery: {
+9
View File
@@ -1,7 +1,16 @@
import { DatabaseTransportError } from "../database/transport.js";
import { MigrationRunnerError, PostgresMigrationDatabaseError } from "../migrations/index.js"; import { MigrationRunnerError, PostgresMigrationDatabaseError } from "../migrations/index.js";
import { ModuleDiscoveryError, ModuleMigrationDiscoveryError } from "../modules/index.js"; import { ModuleDiscoveryError, ModuleMigrationDiscoveryError } from "../modules/index.js";
export function reportMigrationCommandError(error: unknown): void { export function reportMigrationCommandError(error: unknown): void {
if (error instanceof DatabaseTransportError) {
// The message is a fixed string chosen by code; it never carries the connection string, host,
// port, credentials, CA material, or the offending mode value, so it is safe to print as-is —
// and an operator who misconfigured TLS needs to know that, not "failed unexpectedly".
console.error(`[${error.code}] ${error.message}`);
return;
}
if (error instanceof ModuleDiscoveryError) { if (error instanceof ModuleDiscoveryError) {
console.error(error.message); console.error(error.message);
for (const issue of error.issues) { for (const issue of error.issues) {
+9 -1
View File
@@ -1,11 +1,19 @@
import { formatMigrationPlanReport } from "./migration-report.js"; import { formatMigrationPlanReport } from "./migration-report.js";
import { discoverRepositoryMigrations } from "./migration-command-support.js"; import { discoverRepositoryMigrations } from "./migration-command-support.js";
import { reportMigrationCommandError } from "./migration-command-errors.js"; import { reportMigrationCommandError } from "./migration-command-errors.js";
import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js";
import { createPostgresMigrationDatabase, planModuleMigrations } from "../migrations/index.js"; import { createPostgresMigrationDatabase, planModuleMigrations } from "../migrations/index.js";
async function main(): Promise<void> { async function main(): Promise<void> {
const aggregate = await discoverRepositoryMigrations(); const aggregate = await discoverRepositoryMigrations();
const database = createPostgresMigrationDatabase(process.env.DATABASE_URL); const databaseUrl = process.env.DATABASE_URL;
// Resolved and asserted before any client exists: an unsafe production transport fails
// here rather than after opening a connection.
const transport =
typeof databaseUrl === "string" ? resolveExecutableDatabaseTransport(databaseUrl) : undefined;
const database = createPostgresMigrationDatabase(databaseUrl, {
...(transport === undefined ? {} : { transport }),
});
const plan = await planModuleMigrations({ aggregate, database }); const plan = await planModuleMigrations({ aggregate, database });
for (const line of formatMigrationPlanReport(plan)) { for (const line of formatMigrationPlanReport(plan)) {
+1
View File
@@ -1 +1,2 @@
export * from "./pool.js"; export * from "./pool.js";
export * from "./transport.js";
+14
View File
@@ -1,4 +1,5 @@
import { Pool, type PoolConfig } from "pg"; import { Pool, type PoolConfig } from "pg";
import { type DatabaseTransportPolicy, databaseTlsConnectionOptions } from "./transport.js";
export type PostgresPoolErrorCode = "POSTGRES_POOL_CONFIGURATION_INVALID"; export type PostgresPoolErrorCode = "POSTGRES_POOL_CONFIGURATION_INVALID";
@@ -22,6 +23,11 @@ export interface PostgresPoolOptions {
/** Bounds both `query_timeout` and `statement_timeout`: `connectionTimeoutMs` alone only bounds /** Bounds both `query_timeout` and `statement_timeout`: `connectionTimeoutMs` alone only bounds
* establishing a connection, not a hung query already running on one. */ * establishing a connection, not a hung query already running on one. */
readonly operationTimeoutMs?: number; readonly operationTimeoutMs?: number;
/** The validated transport policy (`./transport.js`). Omitted only by isolated unit-test and
* single-domain factories that connect to a disposable loopback database; every production
* executable passes one explicitly. When omitted, no `ssl` option is set and the connection is
* plaintext — exactly the pre-existing development behavior. */
readonly transport?: DatabaseTransportPolicy;
} }
export interface PostgresPoolHandle { export interface PostgresPoolHandle {
@@ -84,8 +90,16 @@ export function createValidatedPostgresPool(
const validatedConnectionString = validateConnectionString(connectionString); const validatedConnectionString = validateConnectionString(connectionString);
const validatedConnectionTimeoutMs = validateTimeout(options.connectionTimeoutMs); const validatedConnectionTimeoutMs = validateTimeout(options.connectionTimeoutMs);
const validatedOperationTimeoutMs = validateTimeout(options.operationTimeoutMs); const validatedOperationTimeoutMs = validateTimeout(options.operationTimeoutMs);
// `databaseTlsConnectionOptions` also rejects a URL carrying its own TLS parameters, so the
// pinned driver's `Object.assign({}, config, parse(connectionString))` precedence can never
// silently replace the policy's `ssl` object with a weaker one.
const ssl =
options.transport === undefined
? undefined
: databaseTlsConnectionOptions(validatedConnectionString, options.transport);
const poolConfig: PoolConfig = { const poolConfig: PoolConfig = {
connectionString: validatedConnectionString, connectionString: validatedConnectionString,
...(ssl === undefined ? {} : { ssl }),
...(validatedConnectionTimeoutMs === undefined ...(validatedConnectionTimeoutMs === undefined
? {} ? {}
: { connectionTimeoutMillis: validatedConnectionTimeoutMs }), : { connectionTimeoutMillis: validatedConnectionTimeoutMs }),
+253
View File
@@ -0,0 +1,253 @@
import net from "node:net";
import tls from "node:tls";
import type { ConnectionOptions } from "node:tls";
export type DatabaseTransportEnvironment = "development" | "production";
/**
* The two transport modes this release supports, named after the libpq/PostgreSQL vocabulary an
* operator already knows:
*
* - `verify-full` — TLS is required, the server certificate must chain to a trusted CA, **and**
* the certificate identity must match the configured database host. This is the production
* default, and the only mode a production deployment may use.
* - `disable` — no TLS at all. Available only for the disposable loopback PostgreSQL used by local
* development and this repository's integration harness.
*
* There is deliberately no encryption-only mode (`require`/`verify-ca`) and no
* `rejectUnauthorized: false` escape hatch: both authenticate nothing, and an unauthenticated
* encrypted channel to an attacker-controlled endpoint is not a security property worth offering.
*/
export type DatabaseTlsMode = "disable" | "verify-full";
export type DatabaseTransportErrorCode =
| "DATABASE_TLS_CONNECTION_STRING_CONFLICT"
| "DATABASE_TLS_MODE_INVALID"
| "DATABASE_TLS_REQUIRED_IN_PRODUCTION"
| "DATABASE_URL_INVALID";
const errorMessages: Readonly<Record<DatabaseTransportErrorCode, string>> = Object.freeze({
DATABASE_TLS_CONNECTION_STRING_CONFLICT:
"The database URL must not carry TLS parameters; configure the database TLS mode explicitly.",
DATABASE_TLS_MODE_INVALID: 'The database TLS mode must be exactly "verify-full" or "disable".',
DATABASE_TLS_REQUIRED_IN_PRODUCTION:
"A production database connection must use certificate-verified TLS.",
DATABASE_URL_INVALID: "The database URL must be an absolute postgres:// or postgresql:// URL.",
});
/** Never carries the connection string, host, port, database name, credentials, certificate, CA
* material, or the offending mode value. */
export class DatabaseTransportError extends Error {
readonly code: DatabaseTransportErrorCode;
constructor(code: DatabaseTransportErrorCode) {
super(errorMessages[code]);
this.name = "DatabaseTransportError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
export interface DatabaseTransportPolicy {
readonly mode: DatabaseTlsMode;
/** PEM trust material for a private PostgreSQL CA. Optional: when omitted, Node's own trusted CA
* set is used. Never logged, never echoed in an error. */
readonly certificateAuthority?: string;
}
export interface DatabaseTransportPolicyInput {
readonly mode?: unknown;
readonly certificateAuthority?: unknown;
}
export interface CreateDatabaseTransportPolicyOptions {
/** Defaults to production, so an omitted environment never silently relaxes transport security. */
readonly environment?: DatabaseTransportEnvironment;
}
/**
* Every TLS-related parameter `[email protected]` recognizes in a PostgreSQL URL. These
* are rejected outright rather than merged, because the pinned driver resolves them in a way that
* silently defeats an explicit policy: `pg`'s own `ConnectionParameters` does
* `Object.assign({}, config, parse(config.connectionString))`
* (`pg/lib/connection-parameters.js`), so a URL parameter **overwrites** the `ssl` object a caller
* passed alongside it. Verified directly against a live TLS PostgreSQL instance: a
* `?sslmode=disable` URL combined with an explicit certificate-verifying `ssl` object connects in
* plaintext.
*
* The pinned release additionally aliases `prefer`/`require`/`verify-ca` to full verification while
* printing a deprecation warning that they will adopt weaker libpq semantics in `pg@9`, so even
* a URL that looks safe today is not a durable guarantee. One typed policy is the whole contract.
*/
const CONNECTION_STRING_TLS_PARAMETERS = Object.freeze([
"ssl",
"sslcert",
"sslkey",
"sslmode",
"sslnegotiation",
"sslpassword",
"sslrootcert",
"uselibpqcompat",
]);
function parseDatabaseUrl(databaseUrl: string): URL {
let url: URL;
try {
url = new URL(databaseUrl);
} catch {
throw new DatabaseTransportError("DATABASE_URL_INVALID");
}
if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") {
throw new DatabaseTransportError("DATABASE_URL_INVALID");
}
return url;
}
/** The host a certificate's identity must match — the exact host the driver will connect to. */
export function databaseHostFromUrl(databaseUrl: string): string {
const url = parseDatabaseUrl(databaseUrl);
// `URL.hostname` keeps IPv6 literals bracketed; TLS identity checking wants the bare address.
const hostname = url.hostname;
return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
}
/** Whether the database endpoint is an unambiguous loopback literal. Used only to decide whether
* the development plaintext mode is even permissible; it is never a production allowance. */
function isLoopbackDatabaseHost(host: string): boolean {
const normalized = host.toLowerCase();
if (normalized === "localhost" || normalized.endsWith(".localhost")) {
return true;
}
if (net.isIPv4(normalized)) {
return normalized.startsWith("127.");
}
if (net.isIPv6(normalized)) {
return normalized === "::1" || normalized === "::ffff:127.0.0.1";
}
return false;
}
/**
* Validates the typed transport policy. Parsing is deliberately separate from environment policy,
* matching the convention the public-signup gate already established: a malformed mode is reported
* as malformed in every environment, never relabeled as a production failure.
*/
export function createDatabaseTransportPolicy(
input: DatabaseTransportPolicyInput = {},
options: CreateDatabaseTransportPolicyOptions = {},
): DatabaseTransportPolicy {
const environment = options.environment ?? "production";
let mode: DatabaseTlsMode;
if (input.mode === undefined) {
// Production fails safe to verified TLS; development keeps the plaintext disposable-PostgreSQL
// workflow this repository's harness relies on.
mode = environment === "production" ? "verify-full" : "disable";
} else if (input.mode === "verify-full" || input.mode === "disable") {
mode = input.mode;
} else {
// No coercion of booleans, `1`, `"TRUE"`, `" verify-full "`, or any other loose spelling.
throw new DatabaseTransportError("DATABASE_TLS_MODE_INVALID");
}
if (environment === "production" && mode !== "verify-full") {
throw new DatabaseTransportError("DATABASE_TLS_REQUIRED_IN_PRODUCTION");
}
if (
input.certificateAuthority !== undefined &&
(typeof input.certificateAuthority !== "string" ||
input.certificateAuthority.trim().length === 0)
) {
throw new DatabaseTransportError("DATABASE_TLS_MODE_INVALID");
}
return Object.freeze({
...(typeof input.certificateAuthority === "string"
? { certificateAuthority: input.certificateAuthority }
: {}),
mode,
});
}
/**
* Rejects any database URL carrying its own TLS parameters. Called before a pool or client is ever
* constructed, so a URL can never silently downgrade or contradict the validated policy — there is
* exactly one place transport security is decided.
*/
export function assertNoConnectionStringTlsParameters(databaseUrl: string): void {
const url = parseDatabaseUrl(databaseUrl);
for (const [name] of url.searchParams) {
if (CONNECTION_STRING_TLS_PARAMETERS.includes(name.toLowerCase())) {
throw new DatabaseTransportError("DATABASE_TLS_CONNECTION_STRING_CONFLICT");
}
}
}
/**
* Translates the validated policy into the exact pinned-`pg` options that achieve it, and asserts
* the URL cannot override them.
*
* `verify-full` is **not** simply `ssl: true` or `{ rejectUnauthorized: true }`. Verified directly
* against a live TLS PostgreSQL instance: those verify the certificate *chain*, but `pg` only sets
* `options.servername` for non-IP hosts (`pg/lib/connection.js`) and passes `tls.connect` a bare
* socket carrying no host, so for an IP endpoint Node has no identity to check and falls back to
* the fixed name `localhost` — never the address actually dialled. A certificate issued for an
* entirely different name is therefore accepted when connecting to an IP address. Supplying an
* explicit `checkServerIdentity` pinned to the configured host closes that gap for DNS and IP hosts
* alike (proven: a certificate valid only for `localhost` is rejected with
* `ERR_TLS_CERT_ALTNAME_INVALID` when reached via `127.0.0.1`).
*/
export function databaseTlsConnectionOptions(
databaseUrl: string,
policy: DatabaseTransportPolicy,
): false | ConnectionOptions {
assertNoConnectionStringTlsParameters(databaseUrl);
if (policy.mode === "disable") {
return false;
}
const host = databaseHostFromUrl(databaseUrl);
return {
...(policy.certificateAuthority === undefined ? {} : { ca: policy.certificateAuthority }),
// Chain verification. There is no configuration that can set this to `false`.
rejectUnauthorized: true,
// Identity verification against the host actually being connected to — never the SNI value pg
// may or may not have set, and never skipped for an IP endpoint.
checkServerIdentity: (_servername: string, certificate: tls.PeerCertificate) =>
tls.checkServerIdentity(host, certificate),
};
}
/**
* The one production guard every executable database path shares: a production deployment must use
* `verify-full`, and its URL must not carry TLS parameters. There is no production plaintext
* exception — not for loopback, not for an RFC1918 address, not for a Docker service name. A
* production process that genuinely runs beside its database still gets certificate-verified TLS,
* which costs nothing and removes an entire class of "it was only internal" misconfiguration.
*/
export function assertProductionDatabaseTransport(
databaseUrl: string,
policy: DatabaseTransportPolicy,
environment: DatabaseTransportEnvironment,
): void {
assertNoConnectionStringTlsParameters(databaseUrl);
if (environment !== "production") {
return;
}
if (policy.mode !== "verify-full") {
throw new DatabaseTransportError("DATABASE_TLS_REQUIRED_IN_PRODUCTION");
}
}
/** A fixed, generic description of the effective transport for diagnostics. Never includes the
* URL, host, port, database name, credentials, certificate, or CA path. */
export function describeDatabaseTransport(policy: DatabaseTransportPolicy): string {
return policy.mode === "verify-full"
? `TLS required with certificate and hostname verification${
policy.certificateAuthority === undefined ? "" : " (explicit private CA supplied)"
}.`
: "TLS disabled (development/test plaintext).";
}
export { isLoopbackDatabaseHost };
+2 -2
View File
@@ -13,8 +13,8 @@ const sections: readonly DoctorSection[] = Object.freeze([
section("Platform", ["platform.runtime"]), section("Platform", ["platform.runtime"]),
section("Modules", ["modules.discovery", "modules.dependencies", "modules.permissions"]), section("Modules", ["modules.discovery", "modules.dependencies", "modules.permissions"]),
section("Data definitions", ["schemas.discovery", "migrations.discovery"]), section("Data definitions", ["schemas.discovery", "migrations.discovery"]),
section("PostgreSQL", ["postgresql.connection", "postgresql.plan"]), section("PostgreSQL", ["database.transport", "postgresql.connection", "postgresql.plan"]),
section("Identity", ["identity.configuration"]), section("Identity", ["identity.configuration", "identity.client-address"]),
section("Object storage", ["object-storage.integration"]), section("Object storage", ["object-storage.integration"]),
]); ]);
+113 -1
View File
@@ -7,11 +7,25 @@ import {
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import { getTableName } from "drizzle-orm"; import { getTableName } from "drizzle-orm";
import { type PermissionRegistry, PermissionRegistryError } from "../authorization/index.js"; import { type PermissionRegistry, PermissionRegistryError } from "../authorization/index.js";
import {
assertProductionDatabaseTransport,
type DatabaseTransportEnvironment,
type DatabaseTransportPolicyInput,
DatabaseTransportError,
createDatabaseTransportPolicy,
describeDatabaseTransport,
} from "../database/transport.js";
import {
type ClientAddressConfigInput,
ClientAddressConfigError,
createClientAddressConfig,
} from "../identity/client-address.js";
import { import {
type CreateIdentityConfigOptions, type CreateIdentityConfigOptions,
createIdentityConfig, createIdentityConfig,
IdentityConfigError, IdentityConfigError,
} from "../identity/config.js"; } from "../identity/config.js";
import { TRUSTED_CLIENT_ADDRESS_HEADER } from "../identity/runtime.js";
import { identityAdapterSchema } from "../identity/schema.js"; import { identityAdapterSchema } from "../identity/schema.js";
import { import {
createPostgresMigrationDatabase, createPostgresMigrationDatabase,
@@ -35,6 +49,7 @@ import {
} from "../modules/index.js"; } from "../modules/index.js";
export type DoctorCheckId = export type DoctorCheckId =
| "database.transport"
| "platform.runtime" | "platform.runtime"
| "modules.discovery" | "modules.discovery"
| "modules.dependencies" | "modules.dependencies"
@@ -44,6 +59,7 @@ export type DoctorCheckId =
| "postgresql.connection" | "postgresql.connection"
| "postgresql.plan" | "postgresql.plan"
| "identity.configuration" | "identity.configuration"
| "identity.client-address"
| "object-storage.integration"; | "object-storage.integration";
export type DoctorCheckStatus = "failure" | "pass" | "skipped" | "warning"; export type DoctorCheckStatus = "failure" | "pass" | "skipped" | "warning";
@@ -64,11 +80,17 @@ export interface DoctorReport {
export interface DoctorIdentityConfigurationOptions { export interface DoctorIdentityConfigurationOptions {
readonly baseUrl?: string | undefined; readonly baseUrl?: string | undefined;
readonly environment?: CreateIdentityConfigOptions["environment"]; readonly environment?: CreateIdentityConfigOptions["environment"];
readonly publicSignupEnabled?: string | undefined;
readonly secret?: string | undefined; readonly secret?: string | undefined;
readonly trustedOrigins?: readonly string[] | undefined; readonly trustedOrigins?: readonly string[] | undefined;
} }
export interface DoctorOptions { export interface DoctorOptions {
readonly clientAddress?: ClientAddressConfigInput | undefined;
/** The environment the executable resolved, used only for transport policy evaluation; identity
* configuration keeps its own `identityConfiguration.environment` for the same purpose. */
readonly databaseEnvironment?: DatabaseTransportEnvironment | undefined;
readonly databaseTransport?: DatabaseTransportPolicyInput | undefined;
readonly databaseUrl?: string | undefined; readonly databaseUrl?: string | undefined;
readonly identityConfiguration?: DoctorIdentityConfigurationOptions | undefined; readonly identityConfiguration?: DoctorIdentityConfigurationOptions | undefined;
readonly moduleDiscovery: ModuleDiscoveryOptions; readonly moduleDiscovery: ModuleDiscoveryOptions;
@@ -363,6 +385,7 @@ function identityConfigurationCheck(
const hasAnyValue = const hasAnyValue =
input?.secret !== undefined || input?.secret !== undefined ||
input?.baseUrl !== undefined || input?.baseUrl !== undefined ||
input?.publicSignupEnabled !== undefined ||
(input?.trustedOrigins?.length ?? 0) > 0; (input?.trustedOrigins?.length ?? 0) > 0;
if (!hasAnyValue) { if (!hasAnyValue) {
@@ -373,9 +396,10 @@ function identityConfigurationCheck(
} }
try { try {
createIdentityConfig( const config = createIdentityConfig(
{ {
baseUrl: input?.baseUrl, baseUrl: input?.baseUrl,
publicSignupEnabled: input?.publicSignupEnabled,
secret: input?.secret, secret: input?.secret,
trustedOrigins: input?.trustedOrigins, trustedOrigins: input?.trustedOrigins,
}, },
@@ -383,6 +407,9 @@ function identityConfigurationCheck(
); );
return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [ return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [
schemaDetail, schemaDetail,
config.publicSignupEnabled
? "Public signup is explicitly ENABLED."
: "Public signup is disabled (safe default).",
]); ]);
} catch (error) { } catch (error) {
const code = const code =
@@ -394,6 +421,76 @@ function identityConfigurationCheck(
} }
} }
/**
* Pure configuration validation only, mirroring `identityConfigurationCheck`: never resolves a real
* peer address or reads a real request. Omitted configuration is a `pass`, not a skip — direct mode
* (trusting only the actual immediate socket peer) is itself a complete, safe default, unlike
* identity configuration, which has no meaningful default at all. An explicitly configured
* trusted-proxy mode reports its header name and trusted-proxy count; an invalid or contradictory
* combination is a `failure`, distinguishing the three states this pass's audit specifically calls
* for: safe direct mode, explicitly configured proxy mode, and an invalid configuration.
*/
/**
* Pure configuration validation only: never opens a connection, and never prints the database URL,
* host, port, database name, credentials, certificate, or CA material. An unsafe production
* transport is a blocking `failure`, not a warning — a production deployment that would reach
* PostgreSQL without certificate-verified TLS is not "ready with warnings".
*/
function databaseTransportCheck(
input: DatabaseTransportPolicyInput | undefined,
databaseUrl: string | undefined,
environment: DatabaseTransportEnvironment | undefined,
): DoctorCheck {
const effectiveEnvironment = environment ?? "production";
try {
const policy = createDatabaseTransportPolicy(input, { environment: effectiveEnvironment });
if (typeof databaseUrl === "string" && databaseUrl.trim() !== "") {
// Also rejects a URL carrying TLS parameters that could override the policy.
assertProductionDatabaseTransport(databaseUrl, policy, effectiveEnvironment);
}
return freezeCheck(
"database.transport",
"pass",
policy.mode === "verify-full"
? "Database TLS verified"
: "Database TLS disabled (development)",
[describeDatabaseTransport(policy)],
);
} catch (error) {
const code =
error instanceof DatabaseTransportError ? error.code : "DATABASE_TRANSPORT_INVALID";
return freezeCheck("database.transport", "failure", "Database transport unsafe", [
`[${code}] The configured database transport is not permitted.`,
]);
}
}
function clientAddressCheck(input: ClientAddressConfigInput | undefined): DoctorCheck {
try {
const config = createClientAddressConfig(input, TRUSTED_CLIENT_ADDRESS_HEADER);
if (config.mode === "direct") {
return freezeCheck("identity.client-address", "pass", "Direct client-address mode", [
"Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.",
]);
}
return freezeCheck("identity.client-address", "pass", "Trusted-proxy client-address mode", [
`Trusts header "${config.header}" only from a request whose immediate socket peer matches one of ${config.trustedProxies.length} configured trusted ${plural(config.trustedProxies.length, "proxy")}.`,
"The reverse proxy must overwrite (never append to) this header, and the origin must not be reachable except through it.",
]);
} catch (error) {
const code =
error instanceof ClientAddressConfigError
? error.code
: "CLIENT_ADDRESS_CONFIGURATION_INVALID";
return freezeCheck(
"identity.client-address",
"failure",
"Client-address configuration invalid",
[`[${code}] Configured client-address values failed validation.`],
);
}
}
function skippedPostgresChecks(detail: string): readonly [DoctorCheck, DoctorCheck] { function skippedPostgresChecks(detail: string): readonly [DoctorCheck, DoctorCheck] {
return [ return [
freezeCheck("postgresql.connection", "skipped", "Connection check skipped", [detail]), freezeCheck("postgresql.connection", "skipped", "Connection check skipped", [detail]),
@@ -531,9 +628,16 @@ export async function diagnoseEkklesia(
); );
} else { } else {
try { try {
// Doctor's own read-only connection uses the exact same validated transport policy every
// other executable does, so a Doctor "connection established" can never mean something
// weaker than what `pnpm api:start` or `pnpm migrations:apply` would use.
const doctorTransport = createDatabaseTransportPolicy(options.databaseTransport, {
environment: options.databaseEnvironment ?? "production",
});
const database = dependencies.createMigrationDatabase(databaseUrl, { const database = dependencies.createMigrationDatabase(databaseUrl, {
connectionTimeoutMs: doctorPostgresTimeoutMs, connectionTimeoutMs: doctorPostgresTimeoutMs,
operationTimeoutMs: doctorPostgresTimeoutMs, operationTimeoutMs: doctorPostgresTimeoutMs,
transport: doctorTransport,
}); });
const plan = await dependencies.planMigrations({ aggregate: migrations, database }); const plan = await dependencies.planMigrations({ aggregate: migrations, database });
postgresConnectionCheck = freezeCheck( postgresConnectionCheck = freezeCheck(
@@ -548,6 +652,12 @@ export async function diagnoseEkklesia(
} }
const identityCheck = identityConfigurationCheck(options.identityConfiguration); const identityCheck = identityConfigurationCheck(options.identityConfiguration);
const clientAddressDoctorCheck = clientAddressCheck(options.clientAddress);
const transportCheck = databaseTransportCheck(
options.databaseTransport,
options.databaseUrl,
options.databaseEnvironment,
);
const storageCheck = freezeCheck("object-storage.integration", "skipped", "Check unavailable", [ const storageCheck = freezeCheck("object-storage.integration", "skipped", "Check unavailable", [
"Core storage integration has not been implemented yet.", "Core storage integration has not been implemented yet.",
@@ -560,9 +670,11 @@ export async function diagnoseEkklesia(
permissionsCheck, permissionsCheck,
schemaCheck, schemaCheck,
migrationCheck, migrationCheck,
transportCheck,
postgresConnectionCheck, postgresConnectionCheck,
postgresPlanCheck, postgresPlanCheck,
identityCheck, identityCheck,
clientAddressDoctorCheck,
storageCheck, storageCheck,
]); ]);
} }
+280
View File
@@ -0,0 +1,280 @@
import net from "node:net";
export type ClientAddressMode = "direct" | "trusted-proxy";
export type ClientAddressConfigErrorCode =
| "CLIENT_ADDRESS_DIRECT_MODE_CONTRADICTION"
| "CLIENT_ADDRESS_HEADER_INVALID"
| "CLIENT_ADDRESS_HEADER_MISSING"
| "CLIENT_ADDRESS_MODE_INVALID"
| "CLIENT_ADDRESS_TRUSTED_PROXIES_EMPTY"
| "CLIENT_ADDRESS_TRUSTED_PROXY_INVALID";
const clientAddressConfigErrorMessages: Readonly<Record<ClientAddressConfigErrorCode, string>> =
Object.freeze({
CLIENT_ADDRESS_DIRECT_MODE_CONTRADICTION:
"Direct client-address mode must not also configure a header or trusted proxies.",
CLIENT_ADDRESS_HEADER_INVALID: "The client-address header must be a valid HTTP header name.",
CLIENT_ADDRESS_HEADER_MISSING:
"Trusted-proxy client-address mode requires an explicit header name.",
CLIENT_ADDRESS_MODE_INVALID: 'The client-address mode must be "direct" or "trusted-proxy".',
CLIENT_ADDRESS_TRUSTED_PROXIES_EMPTY:
"Trusted-proxy client-address mode requires at least one trusted proxy address or range.",
CLIENT_ADDRESS_TRUSTED_PROXY_INVALID:
"Every trusted proxy entry must be a valid IPv4/IPv6 address or CIDR range.",
});
/** Never carries the offending mode, header name, or trusted-proxy value. */
export class ClientAddressConfigError extends Error {
readonly code: ClientAddressConfigErrorCode;
constructor(code: ClientAddressConfigErrorCode) {
super(clientAddressConfigErrorMessages[code]);
this.name = "ClientAddressConfigError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
export interface DirectClientAddressConfig {
readonly mode: "direct";
}
/**
* `header` is the one header name the reverse proxy is trusted to set — the platform transport
* boundary (`apps/api/src/client-address.ts`) overwrites any externally supplied copy of it before
* this policy ever sees the request, so it never needs to be `X-Forwarded-For` specifically, though
* that remains the common choice. `trustedProxies` lists the proxy's own address(es)/range(s), never
* a broad private range that also covers real clients.
*/
export interface TrustedProxyClientAddressConfig {
readonly mode: "trusted-proxy";
readonly header: string;
readonly trustedProxies: readonly string[];
}
export type ClientAddressConfig = DirectClientAddressConfig | TrustedProxyClientAddressConfig;
export interface ClientAddressConfigInput {
readonly mode?: unknown;
readonly header?: unknown;
readonly trustedProxies?: unknown;
}
const HEADER_TOKEN_PATTERN = /^[a-z0-9-]+$/u;
const CIDR_PREFIX_PATTERN = /^\d+$/u;
const IPV4_MAPPED_PATTERN = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/iu;
/**
* Normalizes a single address token for consistent identity/comparison: plain IPv4 is returned
* as-is, an IPv4-mapped IPv6 address (`::ffff:a.b.c.d` — the form Node's own `socket.remoteAddress`
* and real reverse proxies actually emit for an IPv4 peer on a dual-stack listener) collapses to its
* embedded IPv4 form, and any other valid IPv6 address is lowercased. A zone id (`%eth0`) is
* rejected as ambiguous outside its own interface. Anything that is not a syntactically valid IPv4
* or IPv6 address returns `undefined` rather than being guessed at.
*/
export function normalizeClientAddress(address: string): string | undefined {
const trimmed = address.trim();
if (trimmed.length === 0) {
return undefined;
}
if (net.isIPv4(trimmed)) {
return trimmed;
}
if (trimmed.includes("%")) {
return undefined;
}
if (net.isIPv6(trimmed)) {
const mapped = IPV4_MAPPED_PATTERN.exec(trimmed);
if (mapped) {
const embedded = mapped[1] as string;
return net.isIPv4(embedded) ? embedded : undefined;
}
return trimmed.toLowerCase();
}
return undefined;
}
interface ParsedTrustedProxyEntry {
readonly network: string;
readonly prefix: number;
readonly family: "ipv4" | "ipv6";
}
function parseTrustedProxyEntry(entry: string): ParsedTrustedProxyEntry | undefined {
const slashIndex = entry.indexOf("/");
const addressPart = slashIndex === -1 ? entry : entry.slice(0, slashIndex);
const network = normalizeClientAddress(addressPart);
if (!network) {
return undefined;
}
const family: "ipv4" | "ipv6" = net.isIPv4(network) ? "ipv4" : "ipv6";
const maxPrefix = family === "ipv4" ? 32 : 128;
if (slashIndex === -1) {
return { family, network, prefix: maxPrefix };
}
const prefixPart = entry.slice(slashIndex + 1);
if (!CIDR_PREFIX_PATTERN.test(prefixPart)) {
return undefined;
}
const prefix = Number(prefixPart);
return prefix <= maxPrefix ? { family, network, prefix } : undefined;
}
/** Trusted-proxy entries that are not a valid IPv4/IPv6 address or CIDR range — used both by config
* validation and, if ever needed diagnostically, by Doctor. */
export function findInvalidTrustedProxies(entries: readonly string[]): readonly string[] {
return entries.filter((entry) => parseTrustedProxyEntry(entry) === undefined);
}
/**
* Validates explicit, typed client-address configuration. The safe default (`mode: "direct"`) is
* used whenever `mode` is entirely omitted; every other field is validated strictly, and an
* incomplete or contradictory combination (proxy details without proxy mode, proxy mode without
* proxy details) is rejected outright rather than guessed at. `internalHeaderName` is the one
* platform-owned header (`TRUSTED_CLIENT_ADDRESS_HEADER`, `./runtime.js`) a caller must never be
* able to configure as its own "trusted" source header.
*/
export function createClientAddressConfig(
input: ClientAddressConfigInput = {},
internalHeaderName?: string,
): ClientAddressConfig {
const mode = input.mode ?? "direct";
if (mode !== "direct" && mode !== "trusted-proxy") {
throw new ClientAddressConfigError("CLIENT_ADDRESS_MODE_INVALID");
}
if (mode === "direct") {
if (input.header !== undefined || input.trustedProxies !== undefined) {
throw new ClientAddressConfigError("CLIENT_ADDRESS_DIRECT_MODE_CONTRADICTION");
}
return Object.freeze({ mode: "direct" });
}
if (typeof input.header !== "string" || input.header.trim().length === 0) {
throw new ClientAddressConfigError("CLIENT_ADDRESS_HEADER_MISSING");
}
const header = input.header.trim().toLowerCase();
if (!HEADER_TOKEN_PATTERN.test(header)) {
throw new ClientAddressConfigError("CLIENT_ADDRESS_HEADER_INVALID");
}
if (internalHeaderName !== undefined && header === internalHeaderName) {
// The internal sanitized header is never a valid *source* header — trusting it as the
// caller-forwarded value would let a caller set its own "resolved" address directly.
throw new ClientAddressConfigError("CLIENT_ADDRESS_HEADER_INVALID");
}
if (!Array.isArray(input.trustedProxies) || input.trustedProxies.length === 0) {
throw new ClientAddressConfigError("CLIENT_ADDRESS_TRUSTED_PROXIES_EMPTY");
}
const trustedProxies = input.trustedProxies.map((entry) => {
if (typeof entry !== "string" || entry.trim().length === 0) {
throw new ClientAddressConfigError("CLIENT_ADDRESS_TRUSTED_PROXY_INVALID");
}
return entry.trim();
});
if (findInvalidTrustedProxies(trustedProxies).length > 0) {
throw new ClientAddressConfigError("CLIENT_ADDRESS_TRUSTED_PROXY_INVALID");
}
return Object.freeze({
header,
mode: "trusted-proxy",
trustedProxies: Object.freeze(trustedProxies),
});
}
export type ClientAddressFailureReason =
| "FORWARDED_CHAIN_EXHAUSTED"
| "FORWARDED_HEADER_MALFORMED"
| "FORWARDED_HEADER_MISSING"
| "PEER_ADDRESS_UNAVAILABLE";
export type ClientAddressResolution =
| { readonly ok: true; readonly address: string }
| { readonly ok: false; readonly reason: ClientAddressFailureReason };
export interface ClientAddressResolver {
resolve(input: {
readonly peerAddress: string | undefined;
readonly headers: Headers;
}): ClientAddressResolution;
}
function buildTrustedProxyMatcher(trustedProxies: readonly string[]): (address: string) => boolean {
const blockList = new net.BlockList();
for (const entry of trustedProxies) {
const parsed = parseTrustedProxyEntry(entry);
if (!parsed) {
// Config validation already rejects this at startup; a matcher is only ever built from an
// already-validated config, so this can only mean the config was constructed unsafely.
throw new ClientAddressConfigError("CLIENT_ADDRESS_TRUSTED_PROXY_INVALID");
}
blockList.addSubnet(parsed.network, parsed.prefix, parsed.family);
}
return (address: string): boolean =>
blockList.check(address, net.isIPv4(address) ? "ipv4" : "ipv6");
}
/**
* Builds the one server-owned client-address resolution policy this API runtime uses for every
* request, from already-validated configuration. Built once at startup (never per-request) so the
* trusted-proxy `BlockList` is compiled a single time. Depends only on `node:net` and the standard
* Fetch `Headers` type — never Hono or `@hono/node-server` — so it stays reusable by Core's own
* identity composition without pulling an HTTP framework into Core; the Node/Hono transport glue
* that reads the actual socket peer and rewrites the request lives in `apps/api` instead
* (`apps/api/src/client-address.ts`).
*
* Direct mode trusts only the actual immediate socket peer — never any request header. Trusted-proxy
* mode trusts the configured forwarded header's content only when the actual immediate socket peer
* matches a configured trusted-proxy address/range; otherwise it falls back to direct-mode behavior
* (the peer address itself), exactly as if no forwarded header existed at all. A forwarded chain is
* walked right to left: trailing hops that match a trusted proxy are skipped, and the first hop from
* the right that does not match is the resolved client address. A chain that is missing, empty,
* contains any malformed token, or consists entirely of trusted-proxy hops (no genuine client
* address left to resolve) fails closed rather than guessing — never a caller-chosen bucket, and
* never a silent collapse onto the proxy's own address.
*/
export function createClientAddressResolver(config: ClientAddressConfig): ClientAddressResolver {
const isTrustedProxy =
config.mode === "trusted-proxy" ? buildTrustedProxyMatcher(config.trustedProxies) : undefined;
return {
resolve({ peerAddress, headers }): ClientAddressResolution {
const normalizedPeer =
peerAddress === undefined ? undefined : normalizeClientAddress(peerAddress);
if (!normalizedPeer) {
return { ok: false, reason: "PEER_ADDRESS_UNAVAILABLE" };
}
if (config.mode === "direct" || !isTrustedProxy?.(normalizedPeer)) {
return { ok: true, address: normalizedPeer };
}
const headerValue = headers.get(config.header);
if (headerValue === null) {
return { ok: false, reason: "FORWARDED_HEADER_MISSING" };
}
const tokens = headerValue.split(",").map((token) => token.trim());
if (tokens.length === 0 || tokens.some((token) => token.length === 0)) {
return { ok: false, reason: "FORWARDED_HEADER_MALFORMED" };
}
const normalizedTokens: string[] = [];
for (const token of tokens) {
const normalized = normalizeClientAddress(token);
if (!normalized) {
return { ok: false, reason: "FORWARDED_HEADER_MALFORMED" };
}
normalizedTokens.push(normalized);
}
for (let index = normalizedTokens.length - 1; index >= 0; index -= 1) {
const candidate = normalizedTokens[index];
if (candidate !== undefined && !isTrustedProxy(candidate)) {
return { ok: true, address: candidate };
}
}
return { ok: false, reason: "FORWARDED_CHAIN_EXHAUSTED" };
},
};
}
+198 -21
View File
@@ -5,6 +5,8 @@ export type IdentityConfigErrorCode =
| "BASE_URL_INVALID" | "BASE_URL_INVALID"
| "BASE_URL_LOOPBACK_IN_PRODUCTION" | "BASE_URL_LOOPBACK_IN_PRODUCTION"
| "BASE_URL_MISSING" | "BASE_URL_MISSING"
| "PUBLIC_SIGNUP_ENABLED_INVALID"
| "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION"
| "SECRET_LOW_ENTROPY" | "SECRET_LOW_ENTROPY"
| "SECRET_MISSING" | "SECRET_MISSING"
| "SECRET_TOO_SHORT" | "SECRET_TOO_SHORT"
@@ -16,12 +18,108 @@ export type IdentityConfigErrorCode =
| "TRUSTED_ORIGINS_MISSING"; | "TRUSTED_ORIGINS_MISSING";
const MIN_SECRET_LENGTH = 32; const MIN_SECRET_LENGTH = 32;
const LOOPBACK_HOSTNAMES: ReadonlySet<string> = new Set([
"localhost", /**
"127.0.0.1", * Hostnames that are never a legitimate public production origin, matched exactly (after WHATWG
"::1", * URL canonicalization, which already folds `127.1`, `2130706433`, and `0x7f000001` into
"0.0.0.0", * `127.0.0.1`, and lowercases every hostname).
]); *
* This is a purely *syntactic* classifier. It rejects literal and reserved loopback/unspecified
* spellings; it performs no DNS resolution, so it cannot and does not prove that some arbitrary
* public name will never resolve to a loopback address later. Network reachability remains the
* deployment's own firewall/DNS responsibility.
*/
const IPV4_LOOPBACK_BLOCK = /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/u;
const UNSPECIFIED_HOSTNAMES: ReadonlySet<string> = new Set(["0.0.0.0", "[::]"]);
const LITERAL_LOOPBACK_HOSTNAMES: ReadonlySet<string> = new Set(["localhost"]);
/** Expands a bracketed IPv6 `URL.hostname` into its 8 canonical 16-bit groups, or `undefined` when
* the value is not a bracketed IPv6 literal. Handles `::` zero-compression and the mixed
* `::ffff:a.b.c.d` notation the WHATWG parser preserves. */
function expandIpv6Hostname(hostname: string): readonly number[] | undefined {
if (!hostname.startsWith("[") || !hostname.endsWith("]")) {
return undefined;
}
let body = hostname.slice(1, -1);
// Mixed notation: translate a trailing dotted-quad into two hex groups.
const dotted = /^(.*:)(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/u.exec(body);
if (dotted) {
const octets = [dotted[2], dotted[3], dotted[4], dotted[5]].map((part) => Number(part));
if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {
return undefined;
}
const high = ((octets[0] as number) << 8) | (octets[1] as number);
const low = ((octets[2] as number) << 8) | (octets[3] as number);
body = `${dotted[1]}${high.toString(16)}:${low.toString(16)}`;
}
const [head, tail, ...extra] = body.split("::");
if (extra.length > 0) {
return undefined;
}
const parseGroups = (value: string | undefined): number[] | undefined => {
if (value === undefined || value === "") {
return [];
}
const groups: number[] = [];
for (const part of value.split(":")) {
if (!/^[0-9a-f]{1,4}$/u.test(part)) {
return undefined;
}
groups.push(Number.parseInt(part, 16));
}
return groups;
};
const leading = parseGroups(head);
const trailing = tail === undefined ? [] : parseGroups(tail);
if (!leading || !trailing) {
return undefined;
}
if (tail === undefined) {
return leading.length === 8 ? leading : undefined;
}
const missing = 8 - leading.length - trailing.length;
if (missing < 0) {
return undefined;
}
return [...leading, ...Array.from({ length: missing }, () => 0), ...trailing];
}
/** Whether a canonicalized `URL.hostname` is a loopback or unspecified listen address in any
* spelling this repository accepts — literal name, reserved `.localhost` namespace, the full IPv4
* `127.0.0.0/8` block, IPv6 `::1` compressed or expanded, IPv6 `::` unspecified, and IPv4-mapped
* IPv6 forms of both in mixed (`::ffff:127.0.0.1`) and hexadecimal (`::ffff:7f00:1`) notation. */
function isLoopbackOrUnspecifiedHostname(hostname: string): boolean {
const normalized = hostname.toLowerCase();
if (LITERAL_LOOPBACK_HOSTNAMES.has(normalized) || UNSPECIFIED_HOSTNAMES.has(normalized)) {
return true;
}
// RFC 6761 reserves the entire `.localhost` namespace for loopback.
if (normalized.endsWith(".localhost")) {
return true;
}
if (IPV4_LOOPBACK_BLOCK.test(normalized)) {
return true;
}
const groups = expandIpv6Hostname(normalized);
if (!groups) {
return false;
}
const isAllZero = groups.every((group) => group === 0);
// `::` (unspecified) and `::1` (loopback).
if (isAllZero || (groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1)) {
return true;
}
// IPv4-mapped IPv6 (`::ffff:a.b.c.d`): classify the embedded IPv4 address.
const isMapped = groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff;
if (isMapped) {
const high = groups[6] as number;
const low = groups[7] as number;
const embedded = `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`;
return IPV4_LOOPBACK_BLOCK.test(embedded) || embedded === "0.0.0.0";
}
return false;
}
const errorMessages: Readonly<Record<IdentityConfigErrorCode, string>> = Object.freeze({ const errorMessages: Readonly<Record<IdentityConfigErrorCode, string>> = Object.freeze({
BASE_URL_INSECURE_IN_PRODUCTION: "The identity base URL must use https in production.", BASE_URL_INSECURE_IN_PRODUCTION: "The identity base URL must use https in production.",
@@ -29,6 +127,10 @@ const errorMessages: Readonly<Record<IdentityConfigErrorCode, string>> = Object.
BASE_URL_LOOPBACK_IN_PRODUCTION: BASE_URL_LOOPBACK_IN_PRODUCTION:
"The identity base URL must not resolve to a loopback host in production.", "The identity base URL must not resolve to a loopback host in production.",
BASE_URL_MISSING: "The identity base URL is required.", BASE_URL_MISSING: "The identity base URL is required.",
PUBLIC_SIGNUP_ENABLED_INVALID:
'Public signup enablement must be an explicit boolean or the exact string "true"/"false".',
PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION:
"Public signup cannot be enabled in production until durable signup-abuse controls exist.",
SECRET_LOW_ENTROPY: "The identity secret does not have sufficient entropy.", SECRET_LOW_ENTROPY: "The identity secret does not have sufficient entropy.",
SECRET_MISSING: "The identity secret is required.", SECRET_MISSING: "The identity secret is required.",
SECRET_TOO_SHORT: `The identity secret must be at least ${MIN_SECRET_LENGTH} characters.`, SECRET_TOO_SHORT: `The identity secret must be at least ${MIN_SECRET_LENGTH} characters.`,
@@ -61,12 +163,20 @@ export class IdentityConfigError extends Error {
export interface IdentityConfig { export interface IdentityConfig {
readonly baseUrl: string; readonly baseUrl: string;
/** Whether `POST /api/auth/sign-up/email` accepts new accounts. Defaults to `false` (disabled) —
* public signup never becomes reachable merely because this input was omitted, misspelled, or
* loosely parsed. Development and test composition may enable it explicitly; production
* currently cannot enable it at all (`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION`), because the
* durable signup-abuse prerequisites do not exist yet — see `validatePublicSignupEnabled`. In
* production this is therefore always `false`. */
readonly publicSignupEnabled: boolean;
readonly secret: string; readonly secret: string;
readonly trustedOrigins: readonly string[]; readonly trustedOrigins: readonly string[];
} }
export interface IdentityConfigInput { export interface IdentityConfigInput {
readonly baseUrl?: unknown; readonly baseUrl?: unknown;
readonly publicSignupEnabled?: unknown;
readonly secret?: unknown; readonly secret?: unknown;
readonly trustedOrigins?: unknown; readonly trustedOrigins?: unknown;
} }
@@ -76,20 +186,38 @@ export interface CreateIdentityConfigOptions {
readonly environment?: IdentityEnvironment; readonly environment?: IdentityEnvironment;
} }
function isLoopbackHostname(hostname: string): boolean { /**
return LOOPBACK_HOSTNAMES.has(hostname.toLowerCase()); * Parses one value that must be exactly a canonical absolute `http:`/`https:` **origin** — the
} * single representation both the identity base URL and every trusted origin use, so no component
* of the platform can reinterpret the same deployment differently.
function parseAbsoluteHttpUrl(value: unknown): URL | undefined { *
* Canonical means `URL.origin` must equal the configured string byte-for-byte. That one comparison
* structurally rejects every non-origin component at once — credentials, any path (including a
* bare trailing `/`, since `URL.origin` is slashless), a query, a fragment, surrounding
* whitespace, an uppercase hostname, and a redundant default port — because the WHATWG parser
* normalizes or preserves each of those in a way that no longer matches `origin`.
*
* Structural parsing is deliberately separate from environment policy: a caller distinguishes
* "this is not an origin" from "this origin is not allowed in production" and reports the
* structural code first, so an already-malformed URL is never mislabeled as a production-loopback
* failure.
*/
function parseCanonicalHttpOrigin(value: unknown): URL | undefined {
if (typeof value !== "string" || value.length === 0) { if (typeof value !== "string" || value.length === 0) {
return undefined; return undefined;
} }
let url: URL;
try { try {
const url = new URL(value); url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:" ? url : undefined;
} catch { } catch {
return undefined; return undefined;
} }
if (url.protocol !== "http:" && url.protocol !== "https:") {
return undefined;
}
// `URL.origin` carries no credentials, path, query, or fragment, and is already lowercased with
// any default port elided — so requiring exact equality is the whole canonical-origin rule.
return url.origin === value ? url : undefined;
} }
function validateSecret(secret: unknown): string { function validateSecret(secret: unknown): string {
@@ -112,7 +240,11 @@ function validateBaseUrl(baseUrl: unknown, environment: IdentityEnvironment): st
if (typeof baseUrl !== "string" || baseUrl.length === 0) { if (typeof baseUrl !== "string" || baseUrl.length === 0) {
throw new IdentityConfigError("BASE_URL_MISSING"); throw new IdentityConfigError("BASE_URL_MISSING");
} }
const url = parseAbsoluteHttpUrl(baseUrl); // Structural validity first, in every environment: the base URL is the public origin of the
// already-fixed `/api/auth` transport, and Ekklesia does not support mounting Better Auth
// beneath a configurable URL path. A credentialed, pathed, queried, fragmented, whitespace-
// padded, or otherwise non-canonical spelling is structurally invalid, never a policy failure.
const url = parseCanonicalHttpOrigin(baseUrl);
if (!url) { if (!url) {
throw new IdentityConfigError("BASE_URL_INVALID"); throw new IdentityConfigError("BASE_URL_INVALID");
} }
@@ -120,11 +252,13 @@ function validateBaseUrl(baseUrl: unknown, environment: IdentityEnvironment): st
if (url.protocol !== "https:") { if (url.protocol !== "https:") {
throw new IdentityConfigError("BASE_URL_INSECURE_IN_PRODUCTION"); throw new IdentityConfigError("BASE_URL_INSECURE_IN_PRODUCTION");
} }
if (isLoopbackHostname(url.hostname)) { if (isLoopbackOrUnspecifiedHostname(url.hostname)) {
throw new IdentityConfigError("BASE_URL_LOOPBACK_IN_PRODUCTION"); throw new IdentityConfigError("BASE_URL_LOOPBACK_IN_PRODUCTION");
} }
} }
return baseUrl; // Store the canonical origin — identical to what `validateTrustedOrigin` stores — so CORS,
// Better Auth's own origin checks, and this base URL can never disagree about the same origin.
return url.origin;
} }
function validateTrustedOrigin(origin: unknown, environment: IdentityEnvironment): string { function validateTrustedOrigin(origin: unknown, environment: IdentityEnvironment): string {
@@ -138,19 +272,61 @@ function validateTrustedOrigin(origin: unknown, environment: IdentityEnvironment
if (origin.includes("*") || origin.includes("?")) { if (origin.includes("*") || origin.includes("?")) {
throw new IdentityConfigError("TRUSTED_ORIGIN_WILDCARD_UNSUPPORTED"); throw new IdentityConfigError("TRUSTED_ORIGIN_WILDCARD_UNSUPPORTED");
} }
const url = parseAbsoluteHttpUrl(origin); const url = parseCanonicalHttpOrigin(origin);
if (!url || url.origin !== origin) { if (!url) {
throw new IdentityConfigError("TRUSTED_ORIGIN_INVALID"); throw new IdentityConfigError("TRUSTED_ORIGIN_INVALID");
} }
if (environment === "production") { if (environment === "production") {
if (url.protocol !== "https:") { if (url.protocol !== "https:") {
throw new IdentityConfigError("TRUSTED_ORIGIN_INSECURE_IN_PRODUCTION"); throw new IdentityConfigError("TRUSTED_ORIGIN_INSECURE_IN_PRODUCTION");
} }
if (isLoopbackHostname(url.hostname)) { // The identical classifier the base URL uses, so the two can never disagree about whether the
// same host is a production-legal origin.
if (isLoopbackOrUnspecifiedHostname(url.hostname)) {
throw new IdentityConfigError("TRUSTED_ORIGIN_LOOPBACK_IN_PRODUCTION"); throw new IdentityConfigError("TRUSTED_ORIGIN_LOOPBACK_IN_PRODUCTION");
} }
} }
return origin; return url.origin;
}
/**
* Strict, explicit-only parsing: `undefined` is the one safe default (disabled), a genuine boolean
* passes straight through for programmatic callers, and the exact lowercase strings `"true"`/
* `"false"` are accepted so an environment variable (always a string) can opt in — anything else
* (a misspelling, `"1"`, `"yes"`, mixed case, an empty string) is rejected outright rather than
* silently coerced to `false` or `true`. There is deliberately no "loosely truthy" branch.
*
* Parsing is deliberately separated from the production policy below: a malformed value is
* reported as malformed in *every* environment (`PUBLIC_SIGNUP_ENABLED_INVALID`), so an operator
* who typo'd the flag is never told the misleading "unsupported in production" story instead.
*
* Enabling public signup is then refused outright in production
* (`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION`). This is not a preference — the durable
* signup-abuse prerequisites a public registration endpoint needs (verified email or an
* invitation requirement, plus durable shared/edge throttling rather than this release's
* per-process in-memory limiter) do not exist in this repository yet, so there is no safe
* production configuration in which the flag could be honored. Development and test composition
* may still enable it explicitly through this same typed path. When those prerequisites are
* actually implemented and proven, this check is the single place that changes.
*/
function validatePublicSignupEnabled(value: unknown, environment: IdentityEnvironment): boolean {
let enabled: boolean;
if (value === undefined) {
enabled = false;
} else if (typeof value === "boolean") {
enabled = value;
} else if (value === "true") {
enabled = true;
} else if (value === "false") {
enabled = false;
} else {
throw new IdentityConfigError("PUBLIC_SIGNUP_ENABLED_INVALID");
}
if (enabled && environment === "production") {
throw new IdentityConfigError("PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION");
}
return enabled;
} }
function validateTrustedOrigins( function validateTrustedOrigins(
@@ -179,6 +355,7 @@ export function createIdentityConfig(
const secret = validateSecret(input.secret); const secret = validateSecret(input.secret);
const baseUrl = validateBaseUrl(input.baseUrl, environment); const baseUrl = validateBaseUrl(input.baseUrl, environment);
const trustedOrigins = validateTrustedOrigins(input.trustedOrigins, environment); const trustedOrigins = validateTrustedOrigins(input.trustedOrigins, environment);
const publicSignupEnabled = validatePublicSignupEnabled(input.publicSignupEnabled, environment);
return Object.freeze({ baseUrl, secret, trustedOrigins }); return Object.freeze({ baseUrl, publicSignupEnabled, secret, trustedOrigins });
} }
+13
View File
@@ -1,5 +1,9 @@
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
import { Pool, type PoolConfig } from "pg"; import { Pool, type PoolConfig } from "pg";
import {
type DatabaseTransportPolicy,
databaseTlsConnectionOptions,
} from "../database/transport.js";
import { identityAdapterSchema } from "./schema.js"; import { identityAdapterSchema } from "./schema.js";
export type IdentityDrizzleDatabase = NodePgDatabase<typeof identityAdapterSchema>; export type IdentityDrizzleDatabase = NodePgDatabase<typeof identityAdapterSchema>;
@@ -28,6 +32,10 @@ export interface IdentityDatabaseOptions {
* indefinitely. `connectionTimeoutMs` alone only bounds establishing a connection — it does * indefinitely. `connectionTimeoutMs` alone only bounds establishing a connection — it does
* nothing once a query is already running on one. */ * nothing once a query is already running on one. */
readonly operationTimeoutMs?: number; readonly operationTimeoutMs?: number;
/** The validated transport policy (`core/src/database/transport.js`). Passed explicitly by
* every production executable; omitted by single-domain unit/integration factories that use
* the disposable loopback database, which keeps the pre-existing plaintext behavior. */
readonly transport?: DatabaseTransportPolicy;
} }
export interface IdentityDatabaseHandle { export interface IdentityDatabaseHandle {
@@ -81,8 +89,13 @@ export function createIdentityDrizzleDatabase(
const validatedConnectionString = validateConnectionString(connectionString); const validatedConnectionString = validateConnectionString(connectionString);
const validatedConnectionTimeoutMs = validateTimeout(options.connectionTimeoutMs); const validatedConnectionTimeoutMs = validateTimeout(options.connectionTimeoutMs);
const validatedOperationTimeoutMs = validateTimeout(options.operationTimeoutMs); const validatedOperationTimeoutMs = validateTimeout(options.operationTimeoutMs);
const ssl =
options.transport === undefined
? undefined
: databaseTlsConnectionOptions(validatedConnectionString, options.transport);
const poolConfig: PoolConfig = { const poolConfig: PoolConfig = {
connectionString: validatedConnectionString, connectionString: validatedConnectionString,
...(ssl === undefined ? {} : { ssl }),
...(validatedConnectionTimeoutMs === undefined ...(validatedConnectionTimeoutMs === undefined
? {} ? {}
: { connectionTimeoutMillis: validatedConnectionTimeoutMs }), : { connectionTimeoutMillis: validatedConnectionTimeoutMs }),
+1
View File
@@ -1,3 +1,4 @@
export * from "./client-address.js";
export * from "./config.js"; export * from "./config.js";
export * from "./database.js"; export * from "./database.js";
export * from "./runtime.js"; export * from "./runtime.js";
+129 -16
View File
@@ -3,12 +3,13 @@ import {
createAuthenticatedPrincipal, createAuthenticatedPrincipal,
type EkklesiaPrincipal, type EkklesiaPrincipal,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
import { betterAuth } from "better-auth"; import { betterAuth, type BetterAuthOptions } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { getCookies } from "better-auth/cookies";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import type { IdentityConfig } from "./config.js"; import type { IdentityConfig } from "./config.js";
import type { IdentityDrizzleDatabase } from "./database.js"; import type { IdentityDrizzleDatabase } from "./database.js";
import { identityAdapterSchema } from "./schema.js"; import { ekklesiaCoreSession, identityAdapterSchema } from "./schema.js";
export type IdentityRuntimeErrorCode = export type IdentityRuntimeErrorCode =
| "AUTH_REQUEST_FAILED" | "AUTH_REQUEST_FAILED"
@@ -23,6 +24,18 @@ const runtimeErrorMessages: Readonly<Record<IdentityRuntimeErrorCode, string>> =
const identityDatabaseProbeTimeoutMs = 3_000; const identityDatabaseProbeTimeoutMs = 3_000;
/**
* The one header name Better Auth's own client-address resolution (`advanced.ipAddress.
* ipAddressHeaders`) is configured to trust — never `X-Forwarded-For`, `Forwarded`,
* `CF-Connecting-IP`, `X-Real-IP`, or any other caller-reachable header. Core never reinterprets an
* untrusted proxy header itself: `apps/api`'s transport boundary (`apps/api/src/client-address.ts`)
* is the only writer, resolving the real socket peer (direct mode) or a chain walked against an
* explicitly configured trusted-proxy set (proxy mode) before overwriting any externally supplied
* copy of this exact header with its own server-derived result. Better Auth is configured to trust
* only this one synthesized value, never a second, competing header name.
*/
export const TRUSTED_CLIENT_ADDRESS_HEADER = "x-ekklesia-client-address";
/** /**
* A fixed, generic replacement for whatever Better Auth actually logged. Better Auth's own log * A fixed, generic replacement for whatever Better Auth actually logged. Better Auth's own log
* call sites are not a stable contract Core can audit line by line, and several of them pass a * call sites are not a stable contract Core can audit line by line, and several of them pass a
@@ -107,11 +120,76 @@ async function withTimeout<T>(operation: Promise<T>, timeoutMs: number): Promise
} }
} }
async function probeIdentityDatabase(db: IdentityDrizzleDatabase): Promise<boolean> { /**
* Whether the caller actually presented *the* configured Better Auth session cookie — not merely
* some cookie. An arbitrary anonymous cookie (`Cookie: x=y`) is not evidence that Better Auth ever
* attempted to resolve an Ekklesia session, so it must never trigger session-store work.
*
* Parsing deliberately mirrors RFC 6265's `cookie-string` grammar the same way Better Auth's own
* parser does: split on `;`, take each pair's name as the text before its first `=`, and trim
* surrounding whitespace. Matching is an exact, whole-name string comparison — never `includes`, a
* substring test, or an unanchored regular expression — so `better-auth.session_token_x`,
* `x_better-auth.session_token`, a differently-cased spelling, and a percent-encoded lookalike are
* all correctly *not* the session cookie (cookie names are case-sensitive per RFC 6265 §4.1.1, and
* cookie names are never percent-decoded).
*
* Ambiguity fails safe *towards* checking rather than away from it: a duplicated session-cookie
* name still reports `true`, because reporting `false` is what would let a genuinely broken
* session store be reported as an anonymous request. Returning `true` never grants access on its
* own — it only means the readiness check runs and Better Auth's own result still governs.
*/
export function hasConfiguredSessionCookie(headers: Headers, cookieName: string): boolean {
const cookieHeader = headers.get("cookie");
if (cookieHeader === null) {
return false;
}
for (const pair of cookieHeader.split(";")) {
const separatorIndex = pair.indexOf("=");
const name = (separatorIndex === -1 ? pair : pair.slice(0, separatorIndex)).trim();
if (name === cookieName) {
return true;
}
}
return false;
}
/**
* Whether the actual Better Auth session store is usable — not merely whether PostgreSQL can
* execute a trivial statement.
*
* The previous `select 1` probe proved only that *a* connection could run *a* query, so a missing
* session relation, a drifted/absent column, or a revoked `SELECT` privilege all still looked
* "reachable", and Better Auth's ambiguous `null` was then reported as a genuinely anonymous
* request. This instead plans a real read of the exact session relation and the exact columns the
* adapter depends on, under a `where false` predicate: PostgreSQL still resolves the relation,
* every named column, and the caller's `SELECT` privilege at plan time, while guaranteeing zero
* rows are matched, read, or returned — so no session row, token, or user id can ever be
* enumerated, logged, or exposed by the check itself.
*
* Uses the injected Identity Drizzle view (the API's one shared pool); it never constructs another
* pool, client, connection string, or Drizzle instance. The bounded `withTimeout` guarantees the
* public response is never blocked by a hung query, and `clearTimeout` in its `finally` leaves no
* timer behind.
*/
async function probeSessionStore(db: IdentityDrizzleDatabase): Promise<boolean> {
try { try {
await withTimeout(db.execute(sql`select 1`), identityDatabaseProbeTimeoutMs); await withTimeout(
db
.select({
expiresAt: ekklesiaCoreSession.expiresAt,
id: ekklesiaCoreSession.id,
token: ekklesiaCoreSession.token,
userId: ekklesiaCoreSession.userId,
})
.from(ekklesiaCoreSession)
.where(sql`false`),
identityDatabaseProbeTimeoutMs,
);
return true; return true;
} catch { } catch {
// Deliberately swallowed: the caught value may carry raw SQL, a relation/column name, a
// driver detail, a host/port, or a stack. The caller converts `false` into the fixed,
// sanitized `PRINCIPAL_RESOLUTION_FAILED` contract instead.
return false; return false;
} }
} }
@@ -135,8 +213,12 @@ export function createIdentityRuntime(
): EkklesiaIdentityRuntime { ): EkklesiaIdentityRuntime {
const rateLimitOverride = options.rateLimitOverride; const rateLimitOverride = options.rateLimitOverride;
const auth = constructBetterAuth(() => // One options object feeds both `betterAuth()` and `getCookies()` below, so the session-cookie
betterAuth({ // name this runtime detects is derived from the exact same configuration Better Auth itself
// uses. That makes drift structurally impossible rather than merely tested: there is no second
// hard-coded copy of the cookie name (or of the `__Secure-` prefix rule the configured HTTPS
// `baseURL` implies) anywhere in this repository.
const betterAuthOptions = {
advanced: { advanced: {
// Better Auth's own default is environment-sensitive: `skipOriginCheck` falls back to // Better Auth's own default is environment-sensitive: `skipOriginCheck` falls back to
// `true` whenever `isTest()` is true (NODE_ENV=test, which Vitest sets, or TEST=1) unless // `true` whenever `isTest()` is true (NODE_ENV=test, which Vitest sets, or TEST=1) unless
@@ -149,6 +231,21 @@ export function createIdentityRuntime(
// is enforced in every environment, including under Vitest. // is enforced in every environment, including under Vitest.
disableCSRFCheck: false, disableCSRFCheck: false,
disableOriginCheck: false, disableOriginCheck: false,
// The pinned release's own client-address resolution (`getIp`, `@better-auth/core/utils/
// ip`) only ever reads request headers — it has no way to see the actual TCP socket peer.
// Restricting `ipAddressHeaders` to exactly one platform-owned header name, rather than
// leaving it on Better Auth's own default (`x-forwarded-for`, trusted directly with no
// proxy policy), is what stops a caller from choosing its own rate-limit bucket: the named
// header is populated only by `apps/api`'s transport boundary, after it has already
// resolved and verified the caller's address against the actual socket peer (and, in
// explicit proxy mode, a configured trusted-proxy chain) — see
// `apps/api/src/client-address.ts`. `trustedProxies` is deliberately left unset here: that
// option only inspects header *content* for proxy-shaped addresses and can never verify who
// actually sent the request, so the equivalent trust decision is made once, correctly, at
// the transport boundary instead of asked of Better Auth a second time.
ipAddress: {
ipAddressHeaders: [TRUSTED_CLIENT_ADDRESS_HEADER],
},
}, },
baseURL: options.config.baseUrl, baseURL: options.config.baseUrl,
database: drizzleAdapter(options.db, { database: drizzleAdapter(options.db, {
@@ -157,7 +254,13 @@ export function createIdentityRuntime(
}), }),
emailAndPassword: { emailAndPassword: {
autoSignIn: false, autoSignIn: false,
disableSignUp: false, // Public registration is disabled unless explicitly enabled (`IdentityConfig.
// publicSignupEnabled`, `core/src/identity/config.ts`) — never a side effect of an absent,
// misspelled, or loosely parsed configuration value. Better Auth's own `disableSignUp` gate
// rejects every `/sign-up/email` call with a fixed `400 EMAIL_PASSWORD_SIGN_UP_DISABLED`
// before ever looking up the email, so a disabled deployment never reveals whether an
// address is already registered; sign-in and existing sessions are entirely unaffected.
disableSignUp: !options.config.publicSignupEnabled,
enabled: true, enabled: true,
// Pinned explicitly rather than left on Better Auth's fallback default (`|| 8` / `|| 128` // Pinned explicitly rather than left on Better Auth's fallback default (`|| 8` / `|| 128`
// in the pinned release): an implicit default can change silently on a future Better Auth // in the pinned release): an implicit default can change silently on a future Better Auth
@@ -204,7 +307,15 @@ export function createIdentityRuntime(
}, },
secret: options.config.secret, secret: options.config.secret,
trustedOrigins: [...options.config.trustedOrigins], trustedOrigins: [...options.config.trustedOrigins],
}), } satisfies BetterAuthOptions;
const auth = constructBetterAuth(() => betterAuth(betterAuthOptions));
// `getCookies` is Better Auth's own supported, exported helper (`better-auth/cookies`) for
// resolving the configured cookie names from an options object — not a dependency internal. It
// is given the identical object above, so the returned `sessionToken.name` is exactly the name
// the constructed runtime will read, including the `__Secure-` prefix an https `baseURL` implies.
const sessionCookieName = constructBetterAuth(
() => getCookies(betterAuthOptions).sessionToken.name,
); );
return Object.freeze({ return Object.freeze({
@@ -230,14 +341,16 @@ export function createIdentityRuntime(
return createAuthenticatedPrincipal(userId); return createAuthenticatedPrincipal(userId);
} }
// Better Auth's getSession swallows infrastructure failures internally and returns null // Better Auth's getSession swallows infrastructure failures internally and returns null for
// for them exactly like a genuinely missing, expired, or malformed session — it never // them exactly like a genuinely missing, expired, revoked, or malformed session — it never
// throws. A request with no Cookie header at all is unambiguous: Better Auth never queries // throws. Only a request that actually presented *the configured session cookie* is
// the database for that case, so it is always genuinely anonymous. Only when a cookie was // ambiguous enough to be worth disambiguating: with no cookie at all, or with only unrelated
// actually presented is the null result ambiguous enough to require disambiguation. // cookies, Better Auth never resolved an Ekklesia session in the first place, so `null` is
if (headers.has("cookie")) { // unambiguous and no session-store work is performed. This is what keeps an arbitrary
const reachable = await probeIdentityDatabase(options.db); // anonymous cookie from causing identity database work on every request.
if (!reachable) { if (hasConfiguredSessionCookie(headers, sessionCookieName)) {
const sessionStoreUsable = await probeSessionStore(options.db);
if (!sessionStoreUsable) {
throw new IdentityRuntimeError("PRINCIPAL_RESOLUTION_FAILED"); throw new IdentityRuntimeError("PRINCIPAL_RESOLUTION_FAILED");
} }
} }
+126 -21
View File
@@ -1,4 +1,8 @@
import { Client, type ClientConfig, type QueryResultRow } from "pg"; import { Client, type ClientConfig, type QueryResultRow } from "pg";
import {
type DatabaseTransportPolicy,
databaseTlsConnectionOptions,
} from "../database/transport.js";
import type { import type {
AppliedMigrationRecord, AppliedMigrationRecord,
MigrationDatabase, MigrationDatabase,
@@ -73,9 +77,103 @@ export class PostgresMigrationDatabaseError extends Error {
} }
} }
/**
* The platform's fixed migration connection deadline, applied when a caller does not narrow it.
* Lives here, at the one construction boundary every migration command shares, so a future CLI
* call site cannot silently omit it.
*/
export const MIGRATION_CONNECTION_TIMEOUT_MS = 10_000;
/**
* The platform's fixed migration operation deadline: both `pg`'s client-side `query_timeout` and
* PostgreSQL's own server-side `statement_timeout`. The client-side value bounds a query whose
* response never arrives; the server-side value bounds work the server is actually doing. Applied
* to every statement the session issues — history reads, advisory-lock queries, transaction
* control, migration SQL, and history writes.
*/
export const MIGRATION_OPERATION_TIMEOUT_MS = 10_000;
/**
* Connection-string parameters that must not reach the pinned driver, established by measuring the
* installed `[email protected]`/`[email protected]` against a real PostgreSQL and a real
* blackhole listener rather than by reading the merge and assuming.
*
* `ConnectionParameters` merges as `Object.assign({}, config, parse(config.connectionString))` and
* `pg-connection-string` retains *arbitrary* query keys, so any of these lands in the driver's
* configuration. What each one actually does differs, and only the first genuinely weakens policy:
*
* - `statement_timeout` — **wins outright.** `?statement_timeout=0` produced a server-reported
* `statement_timeout` of `0` (disabled) and `?statement_timeout=900000` produced `15min`, against
* an explicit `statement_timeout: 10000`. This is the one real downgrade.
* - `query_timeout` — overrides the explicit field, but does not weaken the bound; it breaks the
* client. `client.query` computes its read timer as
* `queryConfig.query_timeout || connectionParameters.query_timeout`, and the parsed URL value is
* the *string* `"0"` — truthy — so every query gets a 0 ms read timer and fails
* nondeterministically with `Query read timeout`. Rejected so an operator gets a fixed
* configuration error instead of intermittent, unexplained query failures.
* - `connectionTimeoutMillis` — pollutes `ConnectionParameters.connect_timeout` (10 → `0` or `900`
* seconds), but that field is only consumed by `getLibpqConnectionString()`, which the pure-JS
* client never calls. Measured against a blackhole listener, the effective connect timer is
* **unchanged**: an explicit 500 ms still expired at ~500 ms with `?connectionTimeoutMillis=0`
* and with `=900000`. Rejected as unsupported and as defense in depth — no legitimate
* `DATABASE_URL` carries a driver option, and the polluted value would take effect on the native
* binding — not because it currently defeats the deadline.
* - `connect_timeout` — no effect at all: `ConnectionParameters` recomputes it from
* `connectionTimeoutMillis`, so `?connect_timeout=0` left it at the derived `10`. Rejected as
* unsupported; earlier revisions of this comment wrongly claimed it overrode the explicit field.
* - `options` carrying `statement_timeout` — measured **not** to override the named startup
* parameter: `?options=-c statement_timeout=0`, `=900s`, and a backslash-escaped spelling all
* still produced `10s`, as did an inherited `PGOPTIONS`. Rejected as unsupported rather than
* because it wins.
*
* Deliberately narrow: unrelated parameters (`application_name`, and anything else that does not
* name a timeout this boundary selects) are untouched. `PGCONNECT_TIMEOUT` needs no handling — the
* driver consults it only when `connectionTimeoutMillis` is `undefined`, and this boundary always
* sets it; an inherited `PGCONNECT_TIMEOUT=0` was measured to change nothing.
*/
const CONFLICTING_TIMEOUT_PARAMETERS = Object.freeze([
"connect_timeout",
"connectiontimeoutmillis",
"query_timeout",
"statement_timeout",
]);
function assertNoConflictingTimeoutParameters(connectionString: string): void {
let search: URLSearchParams;
try {
search = new URL(connectionString).searchParams;
} catch {
throw databaseError(
"POSTGRES_MIGRATION_CONFIGURATION_INVALID",
"The PostgreSQL database URL is invalid.",
);
}
for (const [rawKey, rawValue] of search.entries()) {
const key = rawKey.toLowerCase();
if (CONFLICTING_TIMEOUT_PARAMETERS.includes(key)) {
throw databaseError(
"POSTGRES_MIGRATION_CONFIGURATION_INVALID",
"The PostgreSQL database URL must not set a connection or statement timeout.",
);
}
if (key === "options" && rawValue.toLowerCase().includes("statement_timeout")) {
throw databaseError(
"POSTGRES_MIGRATION_CONFIGURATION_INVALID",
"The PostgreSQL database URL must not set a connection or statement timeout.",
);
}
}
}
export interface PostgresMigrationDatabaseOptions { export interface PostgresMigrationDatabaseOptions {
/** Omitted means `MIGRATION_CONNECTION_TIMEOUT_MS`. Tests may narrow it; nothing disables it. */
readonly connectionTimeoutMs?: number; readonly connectionTimeoutMs?: number;
/** Omitted means `MIGRATION_OPERATION_TIMEOUT_MS`. Tests may narrow it; nothing disables it. */
readonly operationTimeoutMs?: number; readonly operationTimeoutMs?: number;
/** The validated transport policy (`core/src/database/transport.js`). Every production executable
* that plans or applies migrations passes one; omitting it keeps the pre-existing plaintext
* behavior used by the disposable loopback PostgreSQL harness. */
readonly transport?: DatabaseTransportPolicy;
} }
interface HistoryExistenceRow extends QueryResultRow { interface HistoryExistenceRow extends QueryResultRow {
@@ -169,26 +267,31 @@ function createClientConfig(
throw invalidConfiguration("The PostgreSQL migration database options are invalid."); throw invalidConfiguration("The PostgreSQL migration database options are invalid.");
} }
const validatedConnectionTimeoutMs = validateTimeout( // Defaults are applied here, at the shared construction boundary, so every migration command
connectionTimeoutMs, // inherits a finite deadline without having to remember to ask for one.
"The PostgreSQL connection timeout is invalid.", const validatedConnectionTimeoutMs =
); validateTimeout(connectionTimeoutMs, "The PostgreSQL connection timeout is invalid.") ??
const validatedOperationTimeoutMs = validateTimeout( MIGRATION_CONNECTION_TIMEOUT_MS;
operationTimeoutMs, const validatedOperationTimeoutMs =
"The PostgreSQL operation timeout is invalid.", validateTimeout(operationTimeoutMs, "The PostgreSQL operation timeout is invalid.") ??
); MIGRATION_OPERATION_TIMEOUT_MS;
// The URL must not be able to weaken what was just selected.
assertNoConflictingTimeoutParameters(connectionString);
// Also rejects a URL carrying its own TLS parameters, so `pg`'s connection-string precedence
// can never silently replace this policy with a weaker one.
const ssl =
options?.transport === undefined
? undefined
: databaseTlsConnectionOptions(connectionString, options.transport);
return { return {
connectionString, connectionString,
...(validatedConnectionTimeoutMs === undefined connectionTimeoutMillis: validatedConnectionTimeoutMs,
? {}
: { connectionTimeoutMillis: validatedConnectionTimeoutMs }),
...(validatedOperationTimeoutMs === undefined
? {}
: {
query_timeout: validatedOperationTimeoutMs, query_timeout: validatedOperationTimeoutMs,
...(ssl === undefined ? {} : { ssl }),
statement_timeout: validatedOperationTimeoutMs, statement_timeout: validatedOperationTimeoutMs,
}),
}; };
} }
@@ -386,7 +489,7 @@ class PostgresMigrationDatabaseSession implements MigrationDatabaseSession {
const transaction = Object.freeze<MigrationTransaction>({ const transaction = Object.freeze<MigrationTransaction>({
ensureHistoryTable: async () => this.#ensureHistoryTable(), ensureHistoryTable: async () => this.#ensureHistoryTable(),
executeStatement: async (sql) => this.#executeStatement(sql), executeMigrationSql: async (sql) => this.#executeMigrationSql(sql),
insertAppliedMigration: async (migration) => this.#insertAppliedMigration(migration), insertAppliedMigration: async (migration) => this.#insertAppliedMigration(migration),
}); });
@@ -463,15 +566,17 @@ class PostgresMigrationDatabaseSession implements MigrationDatabaseSession {
} }
} }
async #executeStatement(sql: string): Promise<void> { /**
* Sends one migration's complete SQL to PostgreSQL in a single simple-query call — no values, no
* prepared-statement name, no rewriting — so the server performs all SQL parsing and the packaged
* bytes are what actually execute.
*/
async #executeMigrationSql(sql: string): Promise<void> {
this.#assertTransactionActive("POSTGRES_MIGRATION_EXECUTION_FAILED"); this.#assertTransactionActive("POSTGRES_MIGRATION_EXECUTION_FAILED");
try { try {
await this.#client.query(sql); await this.#client.query(sql);
} catch { } catch {
throw databaseError( throw databaseError("POSTGRES_MIGRATION_EXECUTION_FAILED", "A module migration failed.");
"POSTGRES_MIGRATION_EXECUTION_FAILED",
"A module migration statement failed.",
);
} }
} }
+14 -5
View File
@@ -4,7 +4,6 @@ import type {
} from "../modules/migration-discovery.js"; } from "../modules/migration-discovery.js";
const DEFAULT_MIGRATION_LOCK_TIMEOUT_MS = 10_000; const DEFAULT_MIGRATION_LOCK_TIMEOUT_MS = 10_000;
const DRIZZLE_STATEMENT_BREAKPOINT = "--> statement-breakpoint";
export type MigrationRunnerErrorCode = export type MigrationRunnerErrorCode =
| "MIGRATION_APPROVAL_LOOKUP_FAILED" | "MIGRATION_APPROVAL_LOOKUP_FAILED"
@@ -40,7 +39,12 @@ export interface MigrationHistoryInsert {
export interface MigrationTransaction { export interface MigrationTransaction {
ensureHistoryTable(): Promise<void>; ensureHistoryTable(): Promise<void>;
executeStatement(sql: string): Promise<void>; /**
* Executes one migration's **complete** SQL exactly as packaged, in a single call, inside the
* caller's transaction. Never a fragment: the runner does not split migration SQL, so the
* implementation must pass the string through to PostgreSQL unmodified.
*/
executeMigrationSql(sql: string): Promise<void>;
insertAppliedMigration(migration: MigrationHistoryInsert): Promise<void>; insertAppliedMigration(migration: MigrationHistoryInsert): Promise<void>;
} }
@@ -484,9 +488,14 @@ export async function applyModuleMigrations(
await transaction.ensureHistoryTable(); await transaction.ensureHistoryTable();
for (const migration of pending) { for (const migration of pending) {
executingIdentity = migration.identity; executingIdentity = migration.identity;
for (const statement of migration.sql.split(DRIZZLE_STATEMENT_BREAKPOINT)) { // PostgreSQL parses the batch, not this runner. Drizzle's `--> statement-breakpoint`
await transaction.executeStatement(statement); // lines are ordinary `--` comments to the server, so sending the migration whole keeps
} // the marker's bytes meaningful wherever they legitimately appear — inside a string
// literal, a dollar-quoted function body, a quoted identifier, or a comment — while a
// literal `split` on those bytes tore such a migration into fragments that were no
// longer valid SQL. Deliberately not a homegrown SQL lexer and not a new parser
// dependency: the database already has the only correct parser.
await transaction.executeMigrationSql(migration.sql);
await transaction.insertAppliedMigration(freezeHistoryInsert(migration)); await transaction.insertAppliedMigration(freezeHistoryInsert(migration));
} }
}); });
+134 -12
View File
@@ -12,6 +12,7 @@ import {
type ModuleRuntimeDefinition, type ModuleRuntimeDefinition,
type ModuleRuntimeServices, type ModuleRuntimeServices,
moduleRouteShapeKey, moduleRouteShapeKey,
moduleRoutesOverlap,
parseModuleRuntimeDefinition, parseModuleRuntimeDefinition,
type RegisteredCapability, type RegisteredCapability,
type RegisteredModuleHttpRoute, type RegisteredModuleHttpRoute,
@@ -33,9 +34,12 @@ export type ModuleRuntimeIssueCode =
| "MODULE_CAPABILITY_UNREGISTERED" | "MODULE_CAPABILITY_UNREGISTERED"
| "MODULE_REGISTER_FAILED" | "MODULE_REGISTER_FAILED"
| "MODULE_ROLLBACK_FAILED" | "MODULE_ROLLBACK_FAILED"
| "MODULE_ROLLBACK_TIMEOUT"
| "MODULE_ROUTE_DEFINITION_INVALID" | "MODULE_ROUTE_DEFINITION_INVALID"
| "MODULE_ROUTE_DUPLICATE" | "MODULE_ROUTE_DUPLICATE"
| "MODULE_ROUTE_MAX_BODY_BYTES_INVALID"
| "MODULE_ROUTE_METHOD_INVALID" | "MODULE_ROUTE_METHOD_INVALID"
| "MODULE_ROUTE_OVERLAP"
| "MODULE_ROUTE_PATH_INVALID" | "MODULE_ROUTE_PATH_INVALID"
| "MODULE_ROUTE_PERMISSION_INVALID" | "MODULE_ROUTE_PERMISSION_INVALID"
| "MODULE_ROUTE_PERMISSION_UNDECLARED" | "MODULE_ROUTE_PERMISSION_UNDECLARED"
@@ -44,7 +48,8 @@ export type ModuleRuntimeIssueCode =
| "MODULE_RUNTIME_IMPORT_FAILED" | "MODULE_RUNTIME_IMPORT_FAILED"
| "MODULE_RUNTIME_VERSION_MISMATCH" | "MODULE_RUNTIME_VERSION_MISMATCH"
| "MODULE_START_FAILED" | "MODULE_START_FAILED"
| "MODULE_STOP_FAILED"; | "MODULE_STOP_FAILED"
| "MODULE_STOP_TIMEOUT";
export interface ModuleRuntimeIssue { export interface ModuleRuntimeIssue {
readonly code: ModuleRuntimeIssueCode; readonly code: ModuleRuntimeIssueCode;
@@ -71,7 +76,16 @@ export interface StartedModuleRuntime {
stop(): Promise<void>; stop(): Promise<void>;
} }
export interface StartModuleRuntimeOptions extends ModuleDiscoveryOptions { export interface ModuleRuntimeTimeoutOptions {
/** Test-only narrowing of `MODULE_STOP_HOOK_TIMEOUT_MS`, applied to both normal stop and startup
* rollback, so a deadline can be proven in milliseconds instead of ten seconds. Production
* composition never sets this. */
readonly hookTimeoutMs?: number;
}
export interface StartModuleRuntimeOptions
extends ModuleDiscoveryOptions,
ModuleRuntimeTimeoutOptions {
/** The same frozen services object every started module's `start`/`stop` hooks and (once /** The same frozen services object every started module's `start`/`stop` hooks and (once
* `apps/api` mounts routes) HTTP handlers receive for this runtime — a required production * `apps/api` mounts routes) HTTP handlers receive for this runtime — a required production
* dependency, never an optional test-only global. See `ModuleRuntimeServices`. */ * dependency, never an optional test-only global. See `ModuleRuntimeServices`. */
@@ -330,10 +344,27 @@ class StagedModuleHttpRoutes {
), ),
); );
} }
// Distinct from a duplicate: these are *different* routes that can both match at least one
// request path. With an order-sensitive matcher, whichever registered first would decide
// which permission governs that path — a permission-policy decision no routing order may
// make. Core rejects the ambiguity here instead of ranking by specificity, so the module
// publishes none of its staged routes and no request can reach an ambiguous match.
const overlapping = local.find((existing) => moduleRoutesOverlap(existing, parsed.data));
if (overlapping) {
throw new ModuleRuntimeSignal(
runtimeIssue(
"MODULE_ROUTE_OVERLAP",
"register",
module.manifest.id,
`Module ${module.manifest.id} registered HTTP routes that can both match the same request path.`,
),
);
}
shapes.add(shapeKey); shapes.add(shapeKey);
local.push( local.push(
Object.freeze({ Object.freeze({
handler: parsed.data.handler, handler: parsed.data.handler,
maxBodyBytes: parsed.data.maxBodyBytes,
method: parsed.data.method, method: parsed.data.method,
moduleId: module.manifest.id, moduleId: module.manifest.id,
path: parsed.data.path, path: parsed.data.path,
@@ -469,18 +500,93 @@ async function registerModules(
} }
} }
/**
* The platform's fixed wait for one module's `stop` hook, during either a normal stop or a startup
* rollback. Not configurable in this slice — no environment variable, manifest field, or runtime
* service — so every deployment bounds cleanup identically.
*
* This bounds *waiting*, not execution. An in-process promise cannot be canceled: a hook that
* exceeds this deadline keeps running and may settle later, but it no longer delays the rest of
* shutdown, and the pool closes after the bounded sequence, so its late database work fails. The
* platform does not sandbox, terminate, or undo a trusted module's JavaScript.
*/
export const MODULE_STOP_HOOK_TIMEOUT_MS = 10_000;
interface StopModulesOptions {
/** Test-only narrowing of `MODULE_STOP_HOOK_TIMEOUT_MS` so a deadline can be proven without
* waiting out the production value. Production composition never sets this. */
readonly hookTimeoutMs?: number;
}
/**
* Awaits one hook under a deadline. The losing branch is always handled: the timer is cleared when
* the hook wins, and a hook that loses gets a permanent no-op rejection handler attached so a late
* throw can never surface as an unhandled rejection. A timed-out hook is never awaited again.
*/
async function awaitHookWithinDeadline(
hook: Promise<void>,
timeoutMs: number,
): Promise<"completed" | "timed-out"> {
let timer: ReturnType<typeof setTimeout> | undefined;
// Attached before the race so the rejection is considered handled even if the timeout wins.
const observed = hook.then(
() => "completed" as const,
(error: unknown) => {
throw error;
},
);
observed.catch(() => undefined);
try {
return await Promise.race([
observed,
new Promise<"timed-out">((resolve) => {
timer = setTimeout(() => resolve("timed-out"), timeoutMs);
}),
]);
} finally {
clearTimeout(timer);
}
}
/**
* Stops modules in reverse order, giving each hook a bounded slice of time and continuing to the
* next module whatever the previous one did. Every applicable hook is invoked at most once.
*
* The whole sequence is finite because the module count is finite and fixed at discovery: its
* worst case is `startedModuleCount * MODULE_STOP_HOOK_TIMEOUT_MS`, not one global ten-second
* bound.
*/
async function stopModules( async function stopModules(
modules: readonly LoadedModule[], modules: readonly LoadedModule[],
registry: StagedCapabilityRegistry, registry: StagedCapabilityRegistry,
phase: "rollback" | "stop", phase: "rollback" | "stop",
services: ModuleRuntimeServices, services: ModuleRuntimeServices,
options: StopModulesOptions = {},
): Promise<readonly ModuleRuntimeIssue[]> { ): Promise<readonly ModuleRuntimeIssue[]> {
const timeoutMs = options.hookTimeoutMs ?? MODULE_STOP_HOOK_TIMEOUT_MS;
const issues: ModuleRuntimeIssue[] = []; const issues: ModuleRuntimeIssue[] = [];
for (const module of [...modules].reverse()) { for (const module of [...modules].reverse()) {
const moduleId = module.discovered.manifest.id;
const stop = module.definition.stop;
if (!stop) {
// No synthetic callback and no timer for a module that declares no stop hook.
continue;
}
try { try {
await module.definition.stop?.( const outcome = await awaitHookWithinDeadline(
registry.createRuntimeContext(module.discovered, phase, services), Promise.resolve(stop(registry.createRuntimeContext(module.discovered, phase, services))),
timeoutMs,
); );
if (outcome === "timed-out") {
issues.push(
runtimeIssue(
phase === "rollback" ? "MODULE_ROLLBACK_TIMEOUT" : "MODULE_STOP_TIMEOUT",
phase,
moduleId,
`Module ${moduleId} ${phase} did not complete within the platform deadline.`,
),
);
}
} catch (error) { } catch (error) {
if (error instanceof ModuleRuntimeSignal) { if (error instanceof ModuleRuntimeSignal) {
issues.push(error.issue); issues.push(error.issue);
@@ -489,8 +595,8 @@ async function stopModules(
runtimeIssue( runtimeIssue(
phase === "rollback" ? "MODULE_ROLLBACK_FAILED" : "MODULE_STOP_FAILED", phase === "rollback" ? "MODULE_ROLLBACK_FAILED" : "MODULE_STOP_FAILED",
phase, phase,
module.discovered.manifest.id, moduleId,
`Module ${module.discovered.manifest.id} ${phase} failed.`, `Module ${moduleId} ${phase} failed.`,
), ),
); );
} }
@@ -503,14 +609,21 @@ async function startModules(
modules: readonly LoadedModule[], modules: readonly LoadedModule[],
registry: StagedCapabilityRegistry, registry: StagedCapabilityRegistry,
services: ModuleRuntimeServices, services: ModuleRuntimeServices,
options: StopModulesOptions = {},
): Promise<void> { ): Promise<void> {
const started: LoadedModule[] = []; // A module joins the rollback set the moment its `start` hook is *invoked*, not when it
// completes. A hook that acquired a connection, registered a listener, or recorded partial
// initialization before throwing still has cleanup to do, and previously only the modules that
// had already finished were rolled back — leaving the failing module's own resources behind. Its
// `stop` hook therefore runs first, ahead of the earlier modules in reverse order, which is why
// module authors must make `stop` safe after a partially completed `start`.
const rollbackCandidates: LoadedModule[] = [];
for (const module of modules) { for (const module of modules) {
rollbackCandidates.push(module);
try { try {
await module.definition.start?.( await module.definition.start?.(
registry.createRuntimeContext(module.discovered, "start", services), registry.createRuntimeContext(module.discovered, "start", services),
); );
started.push(module);
} catch (error) { } catch (error) {
const startIssue = const startIssue =
error instanceof ModuleRuntimeSignal error instanceof ModuleRuntimeSignal
@@ -521,7 +634,13 @@ async function startModules(
module.discovered.manifest.id, module.discovered.manifest.id,
`Module ${module.discovered.manifest.id} startup failed.`, `Module ${module.discovered.manifest.id} startup failed.`,
); );
const rollbackIssues = await stopModules(started, registry, "rollback", services); const rollbackIssues = await stopModules(
rollbackCandidates,
registry,
"rollback",
services,
options,
);
throw new ModuleRuntimeError([startIssue, ...rollbackIssues]); throw new ModuleRuntimeError([startIssue, ...rollbackIssues]);
} }
} }
@@ -538,12 +657,13 @@ async function startModules(
export async function startDiscoveredModuleRuntime( export async function startDiscoveredModuleRuntime(
discoveredModules: readonly DiscoveredModule[], discoveredModules: readonly DiscoveredModule[],
services: ModuleRuntimeServices, services: ModuleRuntimeServices,
timeouts: ModuleRuntimeTimeoutOptions = {},
): Promise<StartedModuleRuntime> { ): Promise<StartedModuleRuntime> {
const modules = await loadRuntimeDefinitions(discoveredModules); const modules = await loadRuntimeDefinitions(discoveredModules);
const capabilityRegistry = new StagedCapabilityRegistry(); const capabilityRegistry = new StagedCapabilityRegistry();
const httpRoutes = new StagedModuleHttpRoutes(); const httpRoutes = new StagedModuleHttpRoutes();
await registerModules(modules, capabilityRegistry, httpRoutes); await registerModules(modules, capabilityRegistry, httpRoutes);
await startModules(modules, capabilityRegistry, services); await startModules(modules, capabilityRegistry, services, timeouts);
const capabilities = capabilityRegistry.publish(); const capabilities = capabilityRegistry.publish();
const routes = httpRoutes.publish(); const routes = httpRoutes.publish();
@@ -563,7 +683,7 @@ export async function startDiscoveredModuleRuntime(
routes, routes,
stop: (): Promise<void> => { stop: (): Promise<void> => {
stopPromise ??= (async () => { stopPromise ??= (async () => {
const issues = await stopModules(modules, capabilityRegistry, "stop", services); const issues = await stopModules(modules, capabilityRegistry, "stop", services, timeouts);
if (issues.length > 0) { if (issues.length > 0) {
throw new ModuleRuntimeError(issues); throw new ModuleRuntimeError(issues);
} }
@@ -577,5 +697,7 @@ export async function startModuleRuntime(
options: StartModuleRuntimeOptions, options: StartModuleRuntimeOptions,
): Promise<StartedModuleRuntime> { ): Promise<StartedModuleRuntime> {
const discovered = await discoverModules(options); const discovered = await discoverModules(options);
return startDiscoveredModuleRuntime(discovered, options.services); return startDiscoveredModuleRuntime(discovered, options.services, {
...(options.hookTimeoutMs === undefined ? {} : { hookTimeoutMs: options.hookTimeoutMs }),
});
} }
@@ -3,12 +3,13 @@ import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { parseEkklesiaUserId } from "@ekklesia/contracts"; import { createUserActor, parseEkklesiaUserId, parsePermissionId } from "@ekklesia/contracts";
import { Client, type QueryResultRow } from "pg"; import { Client, type QueryResultRow } from "pg";
import { afterAll, describe, expect, it } from "vitest"; import { afterAll, describe, expect, it } from "vitest";
import { import {
bootstrapPlatformOwner, bootstrapPlatformOwner,
createAuthorizationDrizzleDatabase, createAuthorizationDrizzleDatabase,
createAuthorizationStore,
PlatformOwnerBootstrapError, PlatformOwnerBootstrapError,
} from "../src/authorization/index.js"; } from "../src/authorization/index.js";
import { import {
@@ -255,7 +256,7 @@ describe.sequential("live platform-owner bootstrap", () => {
expect(audit).toHaveLength(1); expect(audit).toHaveLength(1);
}); });
it("still refuses bootstrap after the existing owner is revoked — the historical row alone is enough to have consumed it", async () => { it("still refuses bootstrap after the existing owner is revoked — the durable consumed record, not the owner row, is the authority", async () => {
await safeQuery( await safeQuery(
admin, admin,
`update "ekklesia_core_platform_owner" set revoked_at = now(), revoked_by_actor_type = 'system' where user_id = $1`, `update "ekklesia_core_platform_owner" set revoked_at = now(), revoked_by_actor_type = 'system' where user_id = $1`,
@@ -362,3 +363,328 @@ describe.sequential("live platform-owner bootstrap — concurrency", () => {
} }
}); });
}); });
/**
* Pass 3: bootstrap's single-use authority and authorization security history must both survive
* user deletion, which is exactly what the pre-Pass-3 cascading foreign keys destroyed.
*
* The administrative client here is **test-only provisioning and inspection infrastructure**: it
* seeds users, performs the user deletion under test, and reads back the resulting state. It is
* never an API pool or a runtime connection, and every client and disposable database it opens is
* closed or dropped in `finally`.
*/
describe.sequential("live platform-owner bootstrap — durability across user deletion", () => {
async function consumedCount(client: Client): Promise<number> {
const rows = await safeQuery<{ count: string }>(
client,
`select count(*)::text as count from "ekklesia_core_platform_owner_bootstrap"`,
);
return Number(rows[0]?.count ?? "0");
}
it("records exactly one consumed row, one owner row, and one audit row in one transaction", async () => {
const database = await createDisposableDatabase("durable");
await applyCoreMigrations(database.url);
const admin = await openAdministrativeClient(database.url);
const { close, db } = createAuthorizationDrizzleDatabase(database.url);
try {
const ownerId = parseEkklesiaUserId("durable_owner");
const laterId = parseEkklesiaUserId("durable_later");
await seedUser(admin, ownerId, "[email protected]");
await seedUser(admin, laterId, "[email protected]");
expect(await consumedCount(admin)).toBe(0);
await bootstrapPlatformOwner(db, { userId: ownerId });
const consumed = await safeQuery<{ consumed_for_user_id: string | null; id: string }>(
admin,
`select id, consumed_for_user_id from "ekklesia_core_platform_owner_bootstrap"`,
);
expect(consumed).toHaveLength(1);
expect(consumed[0]?.id).toBe("singleton");
expect(consumed[0]?.consumed_for_user_id).toBe(ownerId);
expect(await ownerRowCount(admin)).toBe(1);
expect(
await safeQuery(
admin,
`select id from "ekklesia_core_authorization_audit" where action = 'platform_owner.grant'`,
),
).toHaveLength(1);
// Deleting the bootstrapped owner's user row removes the *live* owner row, exactly as
// before — but no longer the consumed sentinel or the security history describing it.
await safeQuery(admin, `delete from "ekklesia_core_user" where id = $1`, [ownerId]);
expect(await ownerRowCount(admin)).toBe(0);
expect(await consumedCount(admin)).toBe(1);
const survivingAudit = await safeQuery<{ action: string; subject_user_id: string }>(
admin,
`select action, subject_user_id from "ekklesia_core_authorization_audit"`,
);
expect(survivingAudit).toHaveLength(1);
expect(survivingAudit[0]).toMatchObject({
action: "platform_owner.grant",
subject_user_id: ownerId,
});
// And bootstrap is still consumed — the operation that establishes a platform's first
// administrator cannot be reopened by deleting a user.
let captured: unknown;
try {
await bootstrapPlatformOwner(db, { userId: laterId });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED" });
expect(await ownerRowCount(admin)).toBe(0);
} finally {
await close();
await safeEnd(admin);
await dropDatabase(database.name);
}
});
it("retains a grant subject's create and revoke audit rows after that user is deleted", async () => {
const database = await createDisposableDatabase("audit_survives");
await applyCoreMigrations(database.url);
const admin = await openAdministrativeClient(database.url);
const { close, db } = createAuthorizationDrizzleDatabase(database.url);
try {
const subjectId = parseEkklesiaUserId("audit_subject");
const managerId = parseEkklesiaUserId("audit_manager");
await seedUser(admin, subjectId, "[email protected]");
await seedUser(admin, managerId, "[email protected]");
const store = createAuthorizationStore(db);
const grant = await store.createGrant({
actor: createUserActor(managerId),
permission: parsePermissionId("calendar.events.read"),
sourceKind: "explicit",
userId: subjectId,
});
await store.revokeGrant({ actor: createUserActor(managerId), grantId: grant.id });
const before = await safeQuery<{ action: string }>(
admin,
`select action from "ekklesia_core_authorization_audit" where subject_user_id = $1 order by created_at`,
[subjectId],
);
expect(before.map((row) => row.action)).toEqual(["grant.create", "grant.revoke"]);
await safeQuery(admin, `delete from "ekklesia_core_user" where id = $1`, [subjectId]);
// The live grant row still cascades with the user, as intended — the security *history*
// does not.
expect(
await safeQuery(admin, `select id from "ekklesia_core_grant" where user_id = $1`, [
subjectId,
]),
).toHaveLength(0);
const after = await safeQuery<{ action: string; subject_user_id: string }>(
admin,
`select action, subject_user_id from "ekklesia_core_authorization_audit" where subject_user_id = $1 order by created_at`,
[subjectId],
);
expect(after.map((row) => row.action)).toEqual(["grant.create", "grant.revoke"]);
// The retained subject is a plain identifier snapshot; no profile data was ever copied here.
expect(Object.keys(after[0] as object).sort()).toEqual(["action", "subject_user_id"]);
} finally {
await close();
await safeEnd(admin);
await dropDatabase(database.name);
}
});
it("does not consume bootstrap when the transaction fails after the sentinel was claimed", async () => {
const database = await createDisposableDatabase("failed_claim");
await applyCoreMigrations(database.url);
const admin = await openAdministrativeClient(database.url);
const { close, db } = createAuthorizationDrizzleDatabase(database.url);
try {
const ownerId = parseEkklesiaUserId("failed_claim_owner");
await seedUser(admin, ownerId, "[email protected]");
// A temporary trigger makes the audit insert — the last statement of the bootstrap
// transaction, after the sentinel and owner row were already written — violate the audit
// action CHECK constraint.
await safeQuery(
admin,
`create function ekklesia_test_break_bootstrap() returns trigger as $$
begin new.action := 'not.a.real.action'; return new; end;
$$ language plpgsql`,
);
await safeQuery(
admin,
`create trigger ekklesia_test_break_bootstrap_trigger before insert on "ekklesia_core_authorization_audit"
for each row execute function ekklesia_test_break_bootstrap()`,
);
try {
let captured: unknown;
try {
await bootstrapPlatformOwner(db, { userId: ownerId });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "PLATFORM_OWNER_BOOTSTRAP_FAILED" });
// The sentinel, the owner row, and the audit row all rolled back together, so the one
// legitimate first use is still available.
expect(await consumedCount(admin)).toBe(0);
expect(await ownerRowCount(admin)).toBe(0);
} finally {
await safeQuery(
admin,
`drop trigger if exists ekklesia_test_break_bootstrap_trigger on "ekklesia_core_authorization_audit"`,
);
await safeQuery(admin, `drop function if exists ekklesia_test_break_bootstrap()`);
}
// With the failure removed, bootstrap still works — it was never burned.
await bootstrapPlatformOwner(db, { userId: ownerId });
expect(await consumedCount(admin)).toBe(1);
expect(await ownerRowCount(admin)).toBe(1);
} finally {
await close();
await safeEnd(admin);
await dropDatabase(database.name);
}
});
});
/**
* Upgrade path: an installation created before this migration must not gain a fresh first use.
* These scenarios apply only 0000/0001, plant representative pre-migration state through the
* test-only administrative client, then apply 0002 and assert the backfill's effect.
*/
describe.sequential("live Core migration 0002 — bootstrap-consumed backfill", () => {
async function applyThrough(url: string, upTo: number): Promise<void> {
const emptyModulesDirectory = await mkdtemp(
path.join(tmpdir(), "ekklesia-upgrade-no-modules-"),
);
try {
const aggregate = await discoverAllMigrations({
coreMigrationsDirectory,
moduleDiscovery: {
moduleSdkVersion: "0.1.0",
modulesDirectory: emptyModulesDirectory,
platformVersion: "0.1.0",
},
});
const selected = {
...aggregate,
migrations: aggregate.migrations.slice(0, upTo),
};
await applyModuleMigrations({
aggregate: selected,
database: createPostgresMigrationDatabase(url),
});
} finally {
await rm(emptyModulesDirectory, { recursive: true, force: true });
}
}
async function consumedRows(client: Client) {
return await safeQuery<{ consumed_for_user_id: string | null; id: string }>(
client,
`select id, consumed_for_user_id from "ekklesia_core_platform_owner_bootstrap"`,
);
}
it("backfills consumed state for an installation that already has an owner, including a revoked one", async () => {
for (const [label, revoke] of [
["active_owner", false],
["revoked_owner", true],
] as const) {
const database = await createDisposableDatabase(label);
const admin = await openAdministrativeClient(database.url);
try {
await applyThrough(database.url, 2);
const ownerId = parseEkklesiaUserId(`upgrade_owner_${label}`);
const nextId = parseEkklesiaUserId(`upgrade_next_${label}`);
await seedUser(admin, ownerId, `upgrade-${label}@example.test`);
await seedUser(admin, nextId, `upgrade-next-${label}@example.test`);
await safeQuery(
admin,
`insert into "ekklesia_core_platform_owner" (user_id, granted_by_actor_type, granted_by_actor_id)
values ($1, 'system', 'authorization-bootstrap-cli')`,
[ownerId],
);
await safeQuery(
admin,
`insert into "ekklesia_core_authorization_audit" (action, actor_type, actor_id, subject_user_id)
values ('platform_owner.grant', 'system', 'authorization-bootstrap-cli', $1)`,
[ownerId],
);
if (revoke) {
await safeQuery(
admin,
`update "ekklesia_core_platform_owner" set revoked_at = now(), revoked_by_actor_type = 'system' where user_id = $1`,
[ownerId],
);
}
await applyThrough(database.url, 3);
const consumed = await consumedRows(admin);
expect(consumed, label).toHaveLength(1);
expect(consumed[0]?.id).toBe("singleton");
// A backfill cannot know which of several historical owner rows the original single use
// targeted, so it deliberately records no subject rather than fabricating one.
expect(consumed[0]?.consumed_for_user_id).toBeNull();
// Every pre-existing audit row survives the migration untouched.
expect(
await safeQuery(admin, `select id from "ekklesia_core_authorization_audit"`),
).toHaveLength(1);
const { close, db } = createAuthorizationDrizzleDatabase(database.url);
try {
let captured: unknown;
try {
await bootstrapPlatformOwner(db, { userId: nextId });
} catch (error) {
captured = error;
}
expect(captured, label).toMatchObject({
code: "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED",
});
} finally {
await close();
}
} finally {
await safeEnd(admin);
await dropDatabase(database.name);
}
}
});
it("leaves bootstrap available exactly once for an installation that never had an owner", async () => {
const database = await createDisposableDatabase("never_owned");
const admin = await openAdministrativeClient(database.url);
try {
await applyThrough(database.url, 2);
const userId = parseEkklesiaUserId("never_owned_user");
await seedUser(admin, userId, "[email protected]");
await applyThrough(database.url, 3);
// A legitimately never-bootstrapped installation must retain its one first use.
expect(await consumedRows(admin)).toHaveLength(0);
const { close, db } = createAuthorizationDrizzleDatabase(database.url);
try {
await bootstrapPlatformOwner(db, { userId });
expect(await consumedRows(admin)).toHaveLength(1);
let captured: unknown;
try {
await bootstrapPlatformOwner(db, { userId });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED" });
} finally {
await close();
}
} finally {
await safeEnd(admin);
await dropDatabase(database.name);
}
});
});
+753 -8
View File
@@ -9,6 +9,7 @@ import {
type GrantId, type GrantId,
parseAuthorizationScope, parseAuthorizationScope,
parseEkklesiaUserId, parseEkklesiaUserId,
parseGrantId,
parsePermissionId, parsePermissionId,
SYSTEM_ACTOR, SYSTEM_ACTOR,
} from "@ekklesia/contracts"; } from "@ekklesia/contracts";
@@ -171,6 +172,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([ expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
"core:0000_identity_core_tables", "core:0000_identity_core_tables",
"core:0001_authorization_core_tables", "core:0001_authorization_core_tables",
"core:0002_authorization_integrity",
]); ]);
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
} finally { } finally {
@@ -222,6 +224,7 @@ describe.sequential("live Core authorization store", () => {
"ekklesia_core_grant", "ekklesia_core_grant",
"ekklesia_core_migrations", "ekklesia_core_migrations",
"ekklesia_core_platform_owner", "ekklesia_core_platform_owner",
"ekklesia_core_platform_owner_bootstrap",
"ekklesia_core_session", "ekklesia_core_session",
"ekklesia_core_user", "ekklesia_core_user",
"ekklesia_core_verification", "ekklesia_core_verification",
@@ -339,23 +342,56 @@ describe.sequential("live Core authorization store", () => {
}); });
}); });
it("denies a grant once it expires, evaluated against a real elapsed instant", async () => { it("refuses to create a grant that is already expired on arrival", async () => {
const expiringPermission = parsePermissionId("calendar.events.export"); // The store no longer accepts a past expiration at all: such a grant is dead the moment it is
const past = new Date(Date.now() - 1_000); // written, which is never what a caller meant. This is validated before any transaction opens.
let captured: unknown;
try {
await store.createGrant({ await store.createGrant({
actor, actor,
expiresAt: past, expiresAt: new Date(Date.now() - 1_000),
permission: parsePermissionId("calendar.events.export"),
sourceKind: "explicit",
userId: userOneId,
});
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "AUTHORIZATION_GRANT_EXPIRATION_INVALID" });
expect(
await safeQuery(admin, `select id from "ekklesia_core_grant" where permission_id = $1`, [
"calendar.events.export",
]),
).toHaveLength(0);
});
it("denies a grant once its expiry has passed, evaluated against a real elapsed instant", async () => {
const expiringPermission = parsePermissionId("calendar.events.expiring");
const expiresAt = new Date(Date.now() + 60_000);
await store.createGrant({
actor,
expiresAt,
permission: expiringPermission, permission: expiringPermission,
sourceKind: "explicit", sourceKind: "explicit",
userId: userOneId, userId: userOneId,
}); });
const active = await store.hasActiveGrant({ // Deterministic rather than sleeping: the read port evaluates expiration against the instant
now: new Date(), // it is given, so advancing that instant past the stored expiry is a real elapsed-time check.
expect(
await store.hasActiveGrant({
now: new Date(expiresAt.getTime() - 1_000),
permission: expiringPermission, permission: expiringPermission,
userId: userOneId, userId: userOneId,
}); }),
expect(active).toBe(false); ).toBe(true);
expect(
await store.hasActiveGrant({
now: new Date(expiresAt.getTime() + 1_000),
permission: expiringPermission,
userId: userOneId,
}),
).toBe(false);
}); });
it("allows a grant that has not yet expired", async () => { it("allows a grant that has not yet expired", async () => {
@@ -537,3 +573,712 @@ describe.sequential("live Core authorization store — no automatic migration",
} }
}); });
}); });
/**
* Pass 3 grant-integrity scenarios, all against real PostgreSQL because every property under test
* is a database-concurrency or database-constraint property that a fake cannot establish.
*
* The administrative client each scenario uses (`openAdministrativeClient`) is **test-only
* provisioning and inspection infrastructure**: it seeds users, plants legacy duplicate rows that
* the production store would now refuse to create, and reads back the resulting state. It is never
* an API pool or a runtime connection, and it is closed in `afterAll` along with its disposable
* database.
*/
describe.sequential("live Core authorization store — grant equivalence and revocation", () => {
let database: { name: string; url: string };
let admin: Client;
let store: ReturnType<typeof createAuthorizationStore>;
let close: () => Promise<void>;
const subject = parseEkklesiaUserId("equiv_subject");
const other = parseEkklesiaUserId("equiv_other");
const actor = createUserActor("equiv_other");
const permission = parsePermissionId("calendar.events.read");
async function effectiveCount(userId: string, permissionId: string): Promise<number> {
const rows = await safeQuery<{ n: string }>(
admin,
`select count(*)::int as n from "ekklesia_core_grant"
where user_id = $1 and permission_id = $2 and revoked_at is null
and (expires_at is null or expires_at > now())`,
[userId, permissionId],
);
return Number(rows[0]?.n ?? 0);
}
it("prepares a disposable database with the three Core migrations applied", async () => {
database = await createDisposableDatabase("equivalence");
await applyCoreMigrations(database.url);
admin = await openAdministrativeClient(database.url);
await seedUser(admin, subject, "[email protected]");
await seedUser(admin, other, "[email protected]");
const handle = createAuthorizationDrizzleDatabase(database.url);
close = handle.close;
store = createAuthorizationStore(handle.db);
expect(await effectiveCount(subject, permission)).toBe(0);
});
it("creates the first unscoped explicit grant and writes exactly one create audit row", async () => {
const grant = await store.createGrant({
actor,
permission,
sourceKind: "explicit",
userId: subject,
});
expect(grant.scope).toBeUndefined();
const audit = await safeQuery<{ action: string }>(
admin,
`select action from "ekklesia_core_authorization_audit" where grant_id = $1`,
[grant.id],
);
expect(audit.map((row) => row.action)).toEqual(["grant.create"]);
});
it("refuses a sequential equivalent creation, leaving no second grant and no second audit row", async () => {
const auditBefore = await safeQuery(
admin,
`select id from "ekklesia_core_authorization_audit" where subject_user_id = $1 and permission_id = $2`,
[subject, permission],
);
let captured: unknown;
try {
await store.createGrant({ actor, permission, sourceKind: "explicit", userId: subject });
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(AuthorizationStoreError);
expect(captured).toMatchObject({ code: "AUTHORIZATION_GRANT_ALREADY_EXISTS" });
expect(await effectiveCount(subject, permission)).toBe(1);
const auditAfter = await safeQuery(
admin,
`select id from "ekklesia_core_authorization_audit" where subject_user_id = $1 and permission_id = $2`,
[subject, permission],
);
expect(auditAfter).toHaveLength(auditBefore.length);
});
it("a differing expiration does not make an otherwise identical issuance distinct", async () => {
let captured: unknown;
try {
await store.createGrant({
actor,
expiresAt: new Date(Date.now() + 3_600_000),
permission,
sourceKind: "explicit",
userId: subject,
});
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "AUTHORIZATION_GRANT_ALREADY_EXISTS" });
expect(await effectiveCount(subject, permission)).toBe(1);
});
it("a different subject, permission, scope, source kind, or source reference is not equivalent", async () => {
const distinct = [
{ permission, sourceKind: "explicit" as const, userId: other },
{
permission: parsePermissionId("calendar.events.write"),
sourceKind: "explicit" as const,
userId: subject,
},
{
permission,
scope: parseAuthorizationScope("calendar-event:1"),
sourceKind: "explicit" as const,
userId: subject,
},
{ permission, sourceKind: "relationship" as const, userId: subject },
{
permission,
sourceKind: "relationship" as const,
sourceRef: "roster:7",
userId: subject,
},
];
for (const input of distinct) {
const grant = await store.createGrant({ actor, ...input });
expect(grant.id).toBeDefined();
}
// A second issuance of one of those distinct tuples is itself a duplicate.
let captured: unknown;
try {
await store.createGrant({
actor,
permission,
scope: parseAuthorizationScope("calendar-event:1"),
sourceKind: "explicit",
userId: subject,
});
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "AUTHORIZATION_GRANT_ALREADY_EXISTS" });
});
it("serializes concurrent equivalent creations to exactly one grant and one conflict", async () => {
const concurrentPermission = parsePermissionId("calendar.events.concurrent");
// Six genuinely concurrent transactions against the same tuple. The transaction-scoped
// advisory lock is what makes this deterministic: no sleeps, no retries, no process-local
// mutex — a second API process would be serialized identically.
const results = await Promise.allSettled(
Array.from({ length: 6 }, () =>
store.createGrant({
actor,
permission: concurrentPermission,
sourceKind: "explicit",
userId: subject,
}),
),
);
const fulfilled = results.filter((result) => result.status === "fulfilled");
const rejected = results.filter((result) => result.status === "rejected");
expect(fulfilled).toHaveLength(1);
expect(rejected).toHaveLength(5);
for (const result of rejected) {
expect((result as PromiseRejectedResult).reason).toMatchObject({
code: "AUTHORIZATION_GRANT_ALREADY_EXISTS",
});
}
expect(await effectiveCount(subject, concurrentPermission)).toBe(1);
const audit = await safeQuery(
admin,
`select id from "ekklesia_core_authorization_audit"
where subject_user_id = $1 and permission_id = $2 and action = 'grant.create'`,
[subject, concurrentPermission],
);
expect(audit).toHaveLength(1);
});
it("allows a fresh issuance once the previous equivalent is revoked, and again once one expires", async () => {
const cyclePermission = parsePermissionId("calendar.events.cycle");
const first = await store.createGrant({
actor,
permission: cyclePermission,
sourceKind: "explicit",
userId: subject,
});
await store.revokeGrant({ actor, grantId: first.id, sourceKind: "explicit" });
// Revoked equivalents do not block a new issuance.
const second = await store.createGrant({
actor,
expiresAt: new Date(Date.now() + 60_000),
permission: cyclePermission,
sourceKind: "explicit",
userId: subject,
});
expect(second.id).not.toBe(first.id);
// Force the second grant past its expiry through the administrative client, which is the only
// deterministic way to observe "expired equivalent" without sleeping.
await safeQuery(
admin,
`update "ekklesia_core_grant" set expires_at = now() - interval '1 second' where id = $1`,
[second.id],
);
const third = await store.createGrant({
actor,
permission: cyclePermission,
sourceKind: "explicit",
userId: subject,
});
expect(third.id).not.toBe(second.id);
expect(await effectiveCount(subject, cyclePermission)).toBe(1);
});
it("one revocation revokes every unrevoked equivalent, audits each by its own id, and leaves nothing authorizing", async () => {
const legacyPermission = parsePermissionId("calendar.events.legacy");
// Plant the exact state the old store could produce: several equivalent issuances, including
// one already expired but never revoked, plus two deliberately non-equivalent neighbours.
const legacyIds: string[] = [];
for (const expires of ["null", "now() + interval '1 hour'", "now() - interval '1 hour'"]) {
const rows = await safeQuery<{ id: string }>(
admin,
`insert into "ekklesia_core_grant"
(user_id, permission_id, scope, source_kind, source_ref, created_by_actor_type, created_by_actor_id, expires_at)
values ($1, $2, null, 'explicit', null, 'user', $3, ${expires}) returning id`,
[subject, legacyPermission, other],
);
legacyIds.push(rows[0]?.id as string);
}
const scopedNeighbour = await safeQuery<{ id: string }>(
admin,
`insert into "ekklesia_core_grant"
(user_id, permission_id, scope, source_kind, source_ref, created_by_actor_type, created_by_actor_id)
values ($1, $2, 'calendar-event:9', 'explicit', null, 'user', $3) returning id`,
[subject, legacyPermission, other],
);
// Deliberately on its own permission: an unscoped relationship grant for `legacyPermission`
// would legitimately keep authorizing after this revocation (it is a different equivalence
// tuple), which would make the "nothing authorizes" assertion below meaningless rather than
// wrong. The scoped neighbour above stays on the same permission because a scoped grant never
// satisfies an unscoped check.
const relationshipNeighbour = await safeQuery<{ id: string }>(
admin,
`insert into "ekklesia_core_grant"
(user_id, permission_id, scope, source_kind, source_ref, created_by_actor_type, created_by_actor_id)
values ($1, 'calendar.events.legacyrel', null, 'relationship', 'roster:9', 'user', $2) returning id`,
[subject, other],
);
expect(await effectiveCount(subject, legacyPermission)).toBe(3);
await store.revokeGrant({
actor,
grantId: parseGrantId(legacyIds[0] as string),
sourceKind: "explicit",
});
const revoked = await safeQuery<{ id: string; revoked_at: Date | null }>(
admin,
`select id, revoked_at from "ekklesia_core_grant" where id = any($1::uuid[])`,
[legacyIds],
);
// Every unrevoked equivalent, including the expired-but-unrevoked one, is now revoked.
expect(revoked.every((row) => row.revoked_at !== null)).toBe(true);
const auditRows = await safeQuery<{ grant_id: string }>(
admin,
`select grant_id from "ekklesia_core_authorization_audit"
where action = 'grant.revoke' and grant_id = any($1::uuid[])`,
[legacyIds],
);
// One audit row per grant actually revoked, each carrying that row's own id.
expect(auditRows).toHaveLength(legacyIds.length);
expect([...new Set(auditRows.map((row) => row.grant_id))].sort()).toEqual(
[...legacyIds].sort(),
);
// Differently scoped and differently sourced grants are untouched.
const untouched = await safeQuery<{ revoked_at: Date | null }>(
admin,
`select revoked_at from "ekklesia_core_grant" where id = any($1::uuid[])`,
[[scopedNeighbour[0]?.id, relationshipNeighbour[0]?.id]],
);
expect(untouched.every((row) => row.revoked_at === null)).toBe(true);
// No equivalent issuance continues authorizing the user.
expect(
await store.hasActiveGrant({
now: new Date(),
permission: legacyPermission,
userId: subject,
}),
).toBe(false);
});
it("serializes a concurrent create and revoke on the same tuple without leaving an unexplained duplicate", async () => {
const racePermission = parsePermissionId("calendar.events.race");
const seed = await store.createGrant({
actor,
permission: racePermission,
sourceKind: "explicit",
userId: subject,
});
const [revokeResult, createResult] = await Promise.allSettled([
store.revokeGrant({ actor, grantId: seed.id, sourceKind: "explicit" }),
store.createGrant({
actor,
permission: racePermission,
sourceKind: "explicit",
userId: subject,
}),
]);
// Whichever order the two transactions serialize in, the end state is consistent with that
// order: either the create lost to an effective equivalent (0 effective), or it ran after the
// revoke (1 effective). Two effective equivalents is the state this pass makes impossible.
expect(revokeResult.status).toBe("fulfilled");
const effective = await effectiveCount(subject, racePermission);
expect(effective).toBeLessThanOrEqual(1);
if (createResult.status === "rejected") {
expect(createResult.reason).toMatchObject({ code: "AUTHORIZATION_GRANT_ALREADY_EXISTS" });
expect(effective).toBe(0);
} else {
expect(effective).toBe(1);
}
});
it("rolls back every grant and audit change when the multi-row revocation transaction fails", async () => {
const doomedPermission = parsePermissionId("calendar.events.doomed");
const rows = await safeQuery<{ id: string }>(
admin,
`insert into "ekklesia_core_grant"
(user_id, permission_id, scope, source_kind, source_ref, created_by_actor_type, created_by_actor_id)
values ($1, $2, null, 'explicit', null, 'user', $3), ($1, $2, null, 'explicit', null, 'user', $3)
returning id`,
[subject, doomedPermission, other],
);
// Force the audit insert inside the revoke transaction to violate a real constraint: the audit
// action CHECK only permits the four known actions, so a temporary trigger that rewrites the
// action makes the whole multi-row revocation fail after the grant updates were staged.
await safeQuery(
admin,
`create function ekklesia_test_break_audit() returns trigger as $$
begin new.action := 'not.a.real.action'; return new; end;
$$ language plpgsql`,
);
await safeQuery(
admin,
`create trigger ekklesia_test_break_audit_trigger before insert on "ekklesia_core_authorization_audit"
for each row execute function ekklesia_test_break_audit()`,
);
try {
let captured: unknown;
try {
await store.revokeGrant({
actor,
grantId: parseGrantId(rows[0]?.id as string),
sourceKind: "explicit",
});
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "AUTHORIZATION_GRANT_REVOKE_FAILED" });
const after = await safeQuery<{ revoked_at: Date | null }>(
admin,
`select revoked_at from "ekklesia_core_grant" where id = any($1::uuid[])`,
[rows.map((row) => row.id)],
);
expect(after.every((row) => row.revoked_at === null)).toBe(true);
const audit = await safeQuery(
admin,
`select id from "ekklesia_core_authorization_audit" where permission_id = $1`,
[doomedPermission],
);
expect(audit).toHaveLength(0);
} finally {
// Always restored, even when an assertion above fails.
await safeQuery(
admin,
`drop trigger if exists ekklesia_test_break_audit_trigger on "ekklesia_core_authorization_audit"`,
);
await safeQuery(admin, `drop function if exists ekklesia_test_break_audit()`);
}
});
it("reports duplicate conflict, not-found, and infrastructure failure as three distinct sanitized outcomes", async () => {
const codes: string[] = [];
try {
await store.createGrant({ actor, permission, sourceKind: "explicit", userId: subject });
} catch (error) {
codes.push((error as AuthorizationStoreError).code);
}
try {
await store.revokeGrant({
actor,
grantId: parseGrantId("00000000-0000-4000-8000-000000000000"),
sourceKind: "explicit",
});
} catch (error) {
codes.push((error as AuthorizationStoreError).code);
}
await close();
try {
await store.hasActiveGrant({ now: new Date(), permission, userId: subject });
} catch (error) {
codes.push((error as AuthorizationStoreError).code);
}
expect(codes).toEqual([
"AUTHORIZATION_GRANT_ALREADY_EXISTS",
"AUTHORIZATION_GRANT_NOT_FOUND",
"AUTHORIZATION_GRANT_LOOKUP_FAILED",
]);
await safeEnd(admin);
});
});
describe.sequential("live Core authorization store — bounded keyset pagination", () => {
let database: { name: string; url: string };
let admin: Client;
let store: ReturnType<typeof createAuthorizationStore>;
let close: () => Promise<void>;
const subject = parseEkklesiaUserId("page_subject");
it("seeds explicit and relationship grants, including several sharing one created_at", async () => {
database = await createDisposableDatabase("pagination");
await applyCoreMigrations(database.url);
admin = await openAdministrativeClient(database.url);
await seedUser(admin, subject, "[email protected]");
const handle = createAuthorizationDrizzleDatabase(database.url);
close = handle.close;
store = createAuthorizationStore(handle.db);
// Seven explicit grants all sharing one identical `created_at`, which is exactly the case a
// timestamp-only keyset boundary would skip or repeat, plus relationship grants that must
// never appear on any page.
await safeQuery(
admin,
`insert into "ekklesia_core_grant"
(user_id, permission_id, scope, source_kind, source_ref, created_by_actor_type, created_by_actor_id, created_at)
select $1, 'calendar.events.p' || generation, null, 'explicit', null, 'user', $1,
timestamptz '2026-01-01 00:00:00Z'
from generate_series(1, 7) as generation`,
[subject],
);
await safeQuery(
admin,
`insert into "ekklesia_core_grant"
(user_id, permission_id, scope, source_kind, source_ref, created_by_actor_type, created_by_actor_id, created_at)
select $1, 'calendar.events.r' || generation, null, 'relationship', 'roster:' || generation, 'user', $1,
timestamptz '2026-01-01 00:00:00Z'
from generate_series(1, 3) as generation`,
[subject],
);
expect(
await safeQuery(admin, `select id from "ekklesia_core_grant" where user_id = $1`, [subject]),
).toHaveLength(10);
});
it("traverses every explicit grant exactly once across pages, excluding relationship grants", async () => {
const seen: string[] = [];
let after: { createdAt: Date; id: GrantId } | undefined;
let pages = 0;
for (;;) {
const page = await store.listGrantsForUser(subject, {
...(after === undefined ? {} : { after }),
limit: 3,
sourceKind: "explicit",
});
pages += 1;
seen.push(...page.grants.map((grant) => grant.id));
expect(page.grants.every((grant) => grant.sourceKind === "explicit")).toBe(true);
if (page.nextBoundary === undefined) {
expect(page.grants.length).toBeLessThanOrEqual(3);
break;
}
after = page.nextBoundary;
expect(pages).toBeLessThan(10);
}
// 7 explicit rows at limit 3 → pages of 3, 3, 1. No duplicates, no omissions, deterministic
// despite every row sharing one created_at.
expect(pages).toBe(3);
expect(seen).toHaveLength(7);
expect(new Set(seen).size).toBe(7);
// Ordering is created_at DESC, id DESC — with equal timestamps that is a pure descending id
// sort, which is exactly what makes traversal total and repeatable.
expect([...seen].sort().reverse()).toEqual(seen);
});
it("honours the 1 and 100 boundaries and rejects anything outside them", async () => {
expect(
(await store.listGrantsForUser(subject, { limit: 1, sourceKind: "explicit" })).grants,
).toHaveLength(1);
const all = await store.listGrantsForUser(subject, { limit: 100, sourceKind: "explicit" });
expect(all.grants).toHaveLength(7);
expect(all.nextBoundary).toBeUndefined();
for (const limit of [0, 101, -1, 1.5]) {
await expect(store.listGrantsForUser(subject, { limit })).rejects.toMatchObject({
code: "AUTHORIZATION_GRANT_LIMIT_INVALID",
});
}
await close();
await safeEnd(admin);
});
});
/**
* A revocation must never succeed by revoking a *replacement* grant it never addressed.
*
* `revokeGrant` discovers the addressed grant's equivalence tuple before taking that tuple's
* advisory lock, because the tuple columns are immutable once written. The all-equivalent update
* that follows matches on the tuple, not on the addressed id — so without an under-lock
* revalidation of the target itself, a revoker that paused between its target read and the lock
* could wake up after the target was revoked and a fresh equivalent created, and then revoke that
* replacement while reporting success. No serial order explains that outcome, and it contradicts
* the rule that an already-revoked target keeps not-found behavior.
*
* Reproducing it needs the interleaving forced, not raced: an ordinary `Promise.all` would depend
* on timing luck. The tuple advisory lock is cooperative, so a test-only connection can hold the
* exact key the store computes — using the identical SQL expression, not a re-derived constant —
* while ordinary `UPDATE`/`INSERT` statements proceed normally. That is the same test-only
* administrative provisioning this suite already uses to seed legacy duplicates and force
* expirations; no production test hook exists to pause the store.
*/
describe.sequential("live Core authorization store — stale revocation under the equivalence lock", () => {
/** Bounded, condition-based wait — never an arbitrary sleep. Returns once a backend is genuinely
* blocked on an advisory lock in this disposable database, which is the only way to know the
* stale revocation has passed its target read and reached the lock. */
async function awaitAdvisoryLockWait(client: Client): Promise<void> {
const deadline = Date.now() + 20_000;
while (Date.now() < deadline) {
const rows = await safeQuery<{ waiting: number }>(
client,
`select count(*)::int as waiting
from pg_locks l
join pg_stat_activity a on a.pid = l.pid
where l.locktype = 'advisory'
and not l.granted
and a.wait_event_type = 'Lock'
and a.wait_event = 'advisory'
and l.database = (select oid from pg_database where datname = current_database())`,
);
if (Number(rows[0]?.waiting ?? 0) > 0) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw harnessError("The stale revocation never reached the advisory-lock wait.");
}
/** An independent outer deadline, so a regression can never hang the suite. The timer is always
* cleared, including when the awaited promise rejects. */
async function withDeadline<T>(operation: Promise<T>, timeoutMs: number): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
operation,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(
() => reject(harnessError("The stale revocation did not settle before its deadline.")),
timeoutMs,
);
}),
]);
} finally {
clearTimeout(timer);
}
}
it("rejects a revocation whose addressed target was revoked and replaced while it waited for the tuple lock", async () => {
const database = await createDisposableDatabase("stale_revoke");
await applyCoreMigrations(database.url);
const admin = await openAdministrativeClient(database.url);
const lockHolder = await openAdministrativeClient(database.url);
const handle = createAuthorizationDrizzleDatabase(database.url);
let staleSettled: Promise<unknown> | undefined;
let lockHeld = false;
try {
const subject = parseEkklesiaUserId("stale_subject");
const manager = parseEkklesiaUserId("stale_manager");
await seedUser(admin, subject, "[email protected]");
await seedUser(admin, manager, "[email protected]");
const store = createAuthorizationStore(handle.db);
const actor = createUserActor(manager);
const permission = parsePermissionId("calendar.events.stale");
const firstGrant = await store.createGrant({
actor,
permission,
sourceKind: "explicit",
userId: subject,
});
// Hold the exact tuple key the store derives, computed with the identical SQL expression.
await safeQuery(lockHolder, "begin");
await safeQuery(
lockHolder,
`select pg_advisory_xact_lock(hashtextextended(jsonb_build_array(
$1::text, $2::text, null::text, 'explicit'::text, null::text
)::text, 0))`,
[subject, permission],
);
lockHeld = true;
// The stale revoker reads its target successfully, then blocks on the held lock.
const stale = store.revokeGrant({ actor, grantId: firstGrant.id, sourceKind: "explicit" });
staleSettled = stale.then(
() => ({ status: "fulfilled" as const }),
(error: unknown) => ({ error, status: "rejected" as const }),
);
await awaitAdvisoryLockWait(admin);
// Committed while the stale revoker waits: the target is revoked and audited, then a fresh
// equivalent replacement is created — exactly what a concurrent revoker and creator produce.
await safeQuery(
admin,
`update "ekklesia_core_grant"
set revoked_at = now(), revoked_by_actor_type = 'user', revoked_by_actor_id = $2
where id = $1`,
[firstGrant.id, manager],
);
await safeQuery(
admin,
`insert into "ekklesia_core_authorization_audit"
(action, actor_type, actor_id, subject_user_id, permission_id, grant_id)
values ('grant.revoke', 'user', $1, $2, $3, $4)`,
[manager, subject, permission, firstGrant.id],
);
const replacementRows = await safeQuery<{ id: string }>(
admin,
`insert into "ekklesia_core_grant"
(user_id, permission_id, scope, source_kind, source_ref, created_by_actor_type, created_by_actor_id)
values ($1, $2, null, 'explicit', null, 'user', $3)
returning id`,
[subject, permission, manager],
);
const replacementId = replacementRows[0]?.id as string;
expect(replacementId).toBeDefined();
// Release the cooperative lock and let the stale revoker resume.
await safeQuery(lockHolder, "commit");
lockHeld = false;
const outcome = (await withDeadline(staleSettled, 30_000)) as {
error?: unknown;
status: "fulfilled" | "rejected";
};
// The addressed target no longer exists in the state it was addressed in.
expect(outcome.status).toBe("rejected");
expect(outcome.error).toBeInstanceOf(AuthorizationStoreError);
expect(outcome.error).toMatchObject({ code: "AUTHORIZATION_GRANT_NOT_FOUND" });
// The replacement it never addressed is untouched, and no audit row claims otherwise.
const replacement = await safeQuery<{ revoked_at: Date | null }>(
admin,
`select revoked_at from "ekklesia_core_grant" where id = $1`,
[replacementId],
);
expect(replacement[0]?.revoked_at).toBeNull();
expect(
await safeQuery(
admin,
`select id from "ekklesia_core_authorization_audit"
where action = 'grant.revoke' and grant_id = $1`,
[replacementId],
),
).toHaveLength(0);
// The end state is consistent with a serial order: revoke(G1), create(G2), reject(stale).
expect(await store.hasActiveGrant({ now: new Date(), permission, userId: subject })).toBe(
true,
);
const effective = await safeQuery<{ n: number }>(
admin,
`select count(*)::int as n from "ekklesia_core_grant"
where user_id = $1 and permission_id = $2 and revoked_at is null`,
[subject, permission],
);
expect(Number(effective[0]?.n)).toBe(1);
const revokeAudit = await safeQuery<{ grant_id: string }>(
admin,
`select grant_id from "ekklesia_core_authorization_audit" where action = 'grant.revoke'`,
);
expect(revokeAudit.map((row) => row.grant_id)).toEqual([firstGrant.id]);
} finally {
// The lock is released first, so a failed assertion above can never leave the stale
// revocation blocked forever; every client, pool, promise, and database is then cleaned up.
if (lockHeld) {
await safeQuery(lockHolder, "rollback").catch(() => undefined);
}
if (staleSettled) {
await withDeadline(staleSettled, 30_000).catch(() => undefined);
}
await safeEnd(lockHolder).catch(() => undefined);
await safeEnd(admin).catch(() => undefined);
await handle.close().catch(() => undefined);
await dropDatabase(database.name).catch(() => undefined);
}
});
});
+78 -1
View File
@@ -4,8 +4,13 @@ const poolConstructor = vi.hoisted(() => vi.fn());
vi.mock("pg", () => ({ Pool: poolConstructor })); vi.mock("pg", () => ({ Pool: poolConstructor }));
import { parseEkklesiaUserId, parsePermissionId, SYSTEM_ACTOR } from "@ekklesia/contracts";
import { createAuthorizationDrizzleDatabase } from "../src/authorization/database.js"; import { createAuthorizationDrizzleDatabase } from "../src/authorization/database.js";
import { AuthorizationStoreError, createAuthorizationStore } from "../src/authorization/store.js"; import {
AUTHORIZATION_GRANT_LIST_MAX_LIMIT,
AuthorizationStoreError,
createAuthorizationStore,
} from "../src/authorization/store.js";
describe("Core authorization store — import and construction safety", () => { describe("Core authorization store — import and construction safety", () => {
it("does not construct a pool or open a connection merely because the module is imported", () => { it("does not construct a pool or open a connection merely because the module is imported", () => {
@@ -33,7 +38,11 @@ describe("Core authorization store — import and construction safety", () => {
describe("AuthorizationStoreError", () => { describe("AuthorizationStoreError", () => {
it.each([ it.each([
"AUTHORIZATION_GRANT_ALREADY_EXISTS",
"AUTHORIZATION_GRANT_CREATE_FAILED", "AUTHORIZATION_GRANT_CREATE_FAILED",
"AUTHORIZATION_GRANT_EXPIRATION_INVALID",
"AUTHORIZATION_GRANT_LIMIT_INVALID",
"AUTHORIZATION_GRANT_LIST_FAILED",
"AUTHORIZATION_GRANT_LOOKUP_FAILED", "AUTHORIZATION_GRANT_LOOKUP_FAILED",
"AUTHORIZATION_GRANT_NOT_FOUND", "AUTHORIZATION_GRANT_NOT_FOUND",
"AUTHORIZATION_GRANT_REVOKE_FAILED", "AUTHORIZATION_GRANT_REVOKE_FAILED",
@@ -56,3 +65,71 @@ describe("AuthorizationStoreError", () => {
); );
}); });
}); });
/**
* Input validation the store performs *before* any transaction opens, so a rejected value performs
* no database work at all. The fake database below records every call it receives; asserting it
* stayed empty is what proves "no grant and no audit row" rather than merely "an error was
* thrown". `createAuthorizationStore`'s injectable clock keeps this deterministic without
* arbitrary sleeps or real-time flakiness.
*/
describe("Core authorization store — pre-database input validation", () => {
const fixedNow = new Date("2026-07-26T12:00:00.000Z");
function recordingStore() {
const calls: string[] = [];
poolConstructor.mockImplementation(function fakePool() {
return { end: async () => undefined, on: vi.fn(), query: vi.fn() };
});
const { db } = createAuthorizationDrizzleDatabase("postgresql://example.test/ekklesia");
const instrumented = new Proxy(db, {
get(target, property, receiver) {
if (property === "transaction" || property === "select" || property === "execute") {
calls.push(String(property));
}
return Reflect.get(target, property, receiver) as unknown;
},
});
return { calls, store: createAuthorizationStore(instrumented, { now: () => fixedNow }) };
}
it.each([
["a past instant", new Date("2020-01-01T00:00:00.000Z")],
["exactly the acceptance clock", new Date("2026-07-26T12:00:00.000Z")],
["an invalid Date", new Date("nonsense")],
])("rejects %s expiration with no database work at all", async (_label, expiresAt) => {
const { calls, store } = recordingStore();
await expect(
store.createGrant({
actor: SYSTEM_ACTOR,
expiresAt,
permission: parsePermissionId("calendar.events.read"),
sourceKind: "explicit",
userId: parseEkklesiaUserId("subject_1"),
}),
).rejects.toMatchObject({ code: "AUTHORIZATION_GRANT_EXPIRATION_INVALID" });
expect(calls).toEqual([]);
});
it.each([
0,
-1,
101,
1.5,
Number.NaN,
Number.POSITIVE_INFINITY,
Number.MAX_SAFE_INTEGER + 2,
])("rejects the unusable page size %s with no database work at all", async (limit) => {
const { calls, store } = recordingStore();
await expect(
store.listGrantsForUser(parseEkklesiaUserId("subject_1"), { limit }),
).rejects.toMatchObject({ code: "AUTHORIZATION_GRANT_LIMIT_INVALID" });
expect(calls).toEqual([]);
});
it("exposes one shared maximum page size rather than two that could drift", () => {
expect(AUTHORIZATION_GRANT_LIST_MAX_LIMIT).toBe(100);
});
});
+492
View File
@@ -0,0 +1,492 @@
import { execFile } from "node:child_process";
import { randomBytes } from "node:crypto";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import net from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { Client } from "pg";
import { afterAll, describe, expect, it } from "vitest";
import { databaseTlsConnectionOptions } from "../src/database/transport.js";
const run = promisify(execFile);
/**
* Real TLS proof for the production transport policy, exercised through the pinned `pg` client
* against a disposable, TLS-enabled PostgreSQL container.
*
* Every certificate, key, and CA used here is generated at test runtime into an isolated temporary
* directory and deleted in `afterAll` — no private-key material is committed to this repository.
* The container, the isolated network, and any published loopback port are likewise disposable and
* removed in `afterAll` even when an assertion fails.
*
* ## Two supported topologies
*
* `EKKLESIA_TEST_JOB_CONTAINER` is the single topology signal, produced by
* `compose_job_network_job_container` in `scripts/lib/compose-job-network.sh` and exported by
* `scripts/test-migrations-integration.sh`. This harness never re-derives the topology itself.
*
* - **Direct host** (signal empty): the container publishes a random port bound to `127.0.0.1`
* only, and the suite connects through `localhost`.
* - **Containerized job** (signal set): the job executes inside a container and drives *sibling*
* containers through the runner's Docker daemon. Two host assumptions break there, and both are
* avoided rather than worked around:
* 1. A port published by the daemon lands on the **runner host's** loopback, which is a
* different network namespace from the job container's. Proven: a container published at
* `127.0.0.1:32907` by the daemon is `ECONNREFUSED` at that address from inside the job
* container. So no port is published at all; both containers join one isolated user-defined
* network and talk over container DNS on the standard port.
* 2. A bind-mount source path is resolved by the daemon against the **runner host's**
* filesystem. A directory created in the job container's `/tmp` does not exist there, so the
* daemon silently creates an empty one, the entrypoint's `cp` fails, the container exits,
* and `docker port` then reports `No public port '5432' published`. So the certificate and
* key are transferred with `docker cp` into the created-but-not-yet-started container, which
* crosses the daemon boundary correctly in both topologies.
*/
const instanceId = `${process.pid}-${randomBytes(4).toString("hex")}`;
/** Also the container's DNS name on the isolated network, hence the certificate identity there. */
const containerName = `ekklesia-database-tls-${instanceId}`;
const networkName = `ekklesia-database-tls-net-${instanceId}`;
const jobContainer = (process.env.EKKLESIA_TEST_JOB_CONTAINER ?? "").trim();
const containerizedJob = jobContainer !== "";
let certificateDirectory: string | undefined;
let containerCreated = false;
let networkCreated = false;
let jobContainerAttached = false;
/** The host the certificate is issued for, and the only host that may verify successfully. */
let identityHost: string | undefined;
/** A reachable address of the *same* server that the certificate deliberately does not cover. */
let mismatchHost: string | undefined;
let databasePort: number | undefined;
class TlsHarnessError extends Error {
constructor(message: string) {
super(message);
this.name = "TlsHarnessError";
this.stack = `${this.name}: ${this.message}`;
}
}
async function openssl(args: readonly string[], cwd: string): Promise<void> {
try {
await run("openssl", [...args], { cwd });
} catch {
// Never surface openssl's own stderr: it can echo paths from the harness environment.
throw new TlsHarnessError("The disposable TLS material could not be generated.");
}
}
async function docker(args: readonly string[]): Promise<string> {
const { stdout } = await run("docker", [...args]);
return stdout.trim();
}
/**
* The identity Node checks when nothing supplies one. Measured against the pinned driver, not
* assumed: for an IP host `pg` skips `options.servername` (`pg/lib/connection.js`) and hands
* `tls.connect` a bare socket with no `host`, and `net.Socket._host` is `null` for an IP literal —
* so Node falls back to the fixed string `localhost`. A certificate whose only SAN is
* `DNS:example.invalid`, reached chain-only over `127.0.0.1`, is rejected with
* `Host: localhost. is not in the cert's altnames` — proof that chain-only verification checks this
* name rather than the endpoint.
*/
const NODE_FALLBACK_IDENTITY = "localhost";
/**
* Generates a private CA, a server certificate signed by it, and an unrelated second CA.
*
* The certificate's SANs are exactly two DNS names and **no IP SAN**:
*
* - the host this run actually connects through, so the verify-full success case matches a real
* identity rather than a coincidence;
* - {@link NODE_FALLBACK_IDENTITY}, so the chain-only control below reproduces the same outcome in
* both topologies.
*
* Neither covers the server's IP address, which is what keeps the hostname-mismatch assertion
* meaningful: reaching the very same server through its address must fail under the production
* policy, whose pinned `checkServerIdentity` demands an `IP:` SAN that is deliberately absent.
* On a direct host the two names coincide and the SAN set is the single `DNS:localhost` entry this
* suite has always used.
*/
async function generateCertificates(directory: string, host: string): Promise<void> {
const subjectAltNames = [...new Set([host, NODE_FALLBACK_IDENTITY])]
.map((name) => `DNS:${name}`)
.join(",");
await openssl(
[
"req",
"-new",
"-x509",
"-days",
"1",
"-nodes",
"-out",
"ca.crt",
"-keyout",
"ca.key",
"-subj",
"/CN=Ekklesia Disposable Test CA",
],
directory,
);
await openssl(
[
"req",
"-new",
"-nodes",
"-out",
"server.csr",
"-keyout",
"server.key",
"-subj",
`/CN=${host}`,
],
directory,
);
await writeFile(path.join(directory, "san.cnf"), `subjectAltName=${subjectAltNames}\n`);
await openssl(
[
"x509",
"-req",
"-in",
"server.csr",
"-CA",
"ca.crt",
"-CAkey",
"ca.key",
"-CAcreateserial",
"-out",
"server.crt",
"-days",
"1",
"-extfile",
"san.cnf",
],
directory,
);
await openssl(
[
"req",
"-new",
"-x509",
"-days",
"1",
"-nodes",
"-out",
"other-ca.crt",
"-keyout",
"other-ca.key",
"-subj",
"/CN=Unrelated Test CA",
],
directory,
);
// Only the server leaf and its key are ever transferred into the container. Both CA private keys
// stay in the harness temporary directory and are deleted with it.
const material = path.join(directory, "server-material");
await mkdir(material);
for (const file of ["server.crt", "server.key"]) {
await writeFile(path.join(material, file), await readFile(path.join(directory, file)));
}
}
/**
* Creates the container, transfers the runtime-generated material into it, and starts it. The
* certificate/key are copied *inside* the container and re-owned before PostgreSQL starts:
* PostgreSQL refuses a key file that is not owned by the database user or root, and `docker cp`
* lands the files as root.
*/
async function startTlsPostgres(directory: string): Promise<void> {
const publication = containerizedJob
? // No published port: the runner host's loopback is not the job container's loopback.
["--network", networkName]
: // Docker's random-port form, pinned to loopback so PostgreSQL is never offered on any
// other interface.
["-p", "127.0.0.1::5432"];
await docker([
"create",
"--name",
containerName,
...publication,
"-e",
"POSTGRES_DB=ekklesia_tls",
"-e",
"POSTGRES_USER=ekklesia_tls",
"-e",
"POSTGRES_PASSWORD=ekklesia_tls",
"--entrypoint",
"/bin/sh",
"postgres:18.4-alpine3.24",
"-c",
"mkdir -p /tls && cp /certs-src/server.crt /certs-src/server.key /tls/ && " +
"chown postgres:postgres /tls/server.* && chmod 600 /tls/server.key && " +
"exec docker-entrypoint.sh postgres -c ssl=on -c ssl_cert_file=/tls/server.crt " +
"-c ssl_key_file=/tls/server.key",
]);
containerCreated = true;
// `docker cp` is executed by the daemon against the container filesystem, so it is correct
// whether the source directory lives on the daemon host or only inside the job container. The
// destination does not exist, so the source directory's *contents* become `/certs-src`.
await docker(["cp", path.join(directory, "server-material"), `${containerName}:/certs-src`]);
await docker(["start", containerName]);
}
/** Resolves the endpoint this run connects through, plus a reachable address of the same server
* that the certificate identity deliberately does not cover. */
async function resolveEndpoint(): Promise<void> {
if (!containerizedJob) {
const published = await docker(["port", containerName, "5432"]);
const match = /^127\.0\.0\.1:(\d+)$/u.exec(published.split("\n")[0]?.trim() ?? "");
if (!match) {
throw new TlsHarnessError(
"The disposable TLS PostgreSQL port is not bound safely to loopback.",
);
}
databasePort = Number(match[1]);
mismatchHost = "127.0.0.1";
return;
}
if (!(await connectJobContainerToNetwork())) {
throw new TlsHarnessError("The TLS test job could not join the isolated PostgreSQL network.");
}
const address = await docker([
"inspect",
"--format",
`{{ with index .NetworkSettings.Networks "${networkName}" }}{{ .IPAddress }}{{ end }}`,
containerName,
]);
if (!net.isIPv4(address)) {
throw new TlsHarnessError(
"The disposable TLS PostgreSQL has no address on the isolated test network.",
);
}
// The same server, reached by address instead of by name — a genuine identity mismatch, not a
// weakened assertion.
mismatchHost = address;
databasePort = 5432;
}
async function connectJobContainerToNetwork(): Promise<boolean> {
jobContainerAttached = true;
try {
await docker(["network", "connect", networkName, jobContainer]);
return true;
} catch {
await docker(["network", "disconnect", networkName, jobContainer]).catch(() => undefined);
jobContainerAttached = false;
return false;
}
}
/** Bounded readiness polling — never an arbitrary sleep. Resolves once the server accepts a TLS
* connection, or fails after the deadline. */
async function waitForTlsReadiness(host: string, port: number, ca: string): Promise<void> {
const deadline = Date.now() + 60_000;
let lastError: unknown;
while (Date.now() < deadline) {
const client = new Client({
connectionTimeoutMillis: 3_000,
database: "ekklesia_tls",
host,
password: "ekklesia_tls",
port,
ssl: { ca, rejectUnauthorized: true },
user: "ekklesia_tls",
});
try {
await client.connect();
await client.end();
return;
} catch (error) {
lastError = error;
try {
await client.end();
} catch {
// The failed client is already unusable; nothing to clean up beyond this.
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
throw new TlsHarnessError(
`The disposable TLS PostgreSQL did not become ready: ${String(lastError)}`,
);
}
async function connectWith(
host: string,
port: number,
ssl: ReturnType<typeof databaseTlsConnectionOptions>,
): Promise<{ encrypted: boolean }> {
const client = new Client({
connectionTimeoutMillis: 5_000,
database: "ekklesia_tls",
host,
password: "ekklesia_tls",
port,
...(ssl === false ? {} : { ssl }),
user: "ekklesia_tls",
});
try {
await client.connect();
const result = await client.query<{ encrypted: boolean }>(
"SELECT ssl AS encrypted FROM pg_stat_ssl WHERE pid = pg_backend_pid()",
);
return { encrypted: result.rows[0]?.encrypted === true };
} finally {
try {
await client.end();
} catch {
// Closing an already-failed client is not itself a test failure.
}
}
}
/** The trusted CA for the disposable server, read from the harness directory. */
async function readCertificateAuthority(file: "ca.crt" | "other-ca.crt"): Promise<string> {
if (certificateDirectory === undefined) {
throw new TlsHarnessError("The disposable TLS material was never generated.");
}
return await readFile(path.join(certificateDirectory, file), "utf8");
}
function requireEndpoint(): { identity: string; mismatch: string; port: number } {
if (identityHost === undefined || mismatchHost === undefined || databasePort === undefined) {
throw new TlsHarnessError("The disposable TLS PostgreSQL endpoint was never resolved.");
}
return { identity: identityHost, mismatch: mismatchHost, port: databasePort };
}
// Deterministic teardown: every disposable resource this harness created is removed after both
// successful and failed runs, container first so the network has no remaining endpoint.
afterAll(async () => {
if (containerCreated) {
await docker(["rm", "-f", "-v", containerName]).catch(() => undefined);
}
if (jobContainerAttached) {
await docker(["network", "disconnect", networkName, jobContainer]).catch(() => undefined);
}
if (networkCreated) {
await docker(["network", "rm", networkName]).catch(() => undefined);
}
if (certificateDirectory) {
// Removes the generated CA, server certificate, and every private key.
await rm(certificateDirectory, { force: true, recursive: true }).catch(() => undefined);
}
});
describe.sequential("production PostgreSQL TLS policy against a real TLS endpoint", () => {
it("starts a disposable TLS-enabled PostgreSQL with runtime-generated certificates", async () => {
certificateDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-tls-"));
if (containerizedJob) {
await docker(["network", "create", networkName]);
networkCreated = true;
}
// The certificate identity is generated from the host this run will actually connect through.
identityHost = containerizedJob ? containerName : "localhost";
await generateCertificates(certificateDirectory, identityHost);
await startTlsPostgres(certificateDirectory);
await resolveEndpoint();
const endpoint = requireEndpoint();
await waitForTlsReadiness(
endpoint.identity,
endpoint.port,
await readCertificateAuthority("ca.crt"),
);
expect(endpoint.port).toBeGreaterThan(0);
expect(endpoint.mismatch).not.toBe(endpoint.identity);
});
it("connects successfully, and encrypted, when the certificate is trusted and its identity matches the host", async () => {
const { identity, port } = requireEndpoint();
const ca = await readCertificateAuthority("ca.crt");
const ssl = databaseTlsConnectionOptions(
`postgresql://ekklesia_tls@${identity}:${port}/ekklesia_tls`,
{ certificateAuthority: ca, mode: "verify-full" },
);
const { encrypted } = await connectWith(identity, port, ssl);
expect(encrypted).toBe(true);
});
it("refuses a certificate that does not chain to the configured CA", async () => {
const { identity, port } = requireEndpoint();
const otherCa = await readCertificateAuthority("other-ca.crt");
const ssl = databaseTlsConnectionOptions(
`postgresql://ekklesia_tls@${identity}:${port}/ekklesia_tls`,
{ certificateAuthority: otherCa, mode: "verify-full" },
);
await expect(connectWith(identity, port, ssl)).rejects.toMatchObject({
code: "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
});
});
it("refuses a trusted certificate whose identity does not match the host being connected to", async () => {
// The certificate carries no IP SAN, so reaching the identical server via its address is a
// genuine hostname mismatch. This is the case the pinned driver does *not* catch on its own:
// `pg` sets `servername` only for non-IP hosts, and with it unset Node checks
// `NODE_FALLBACK_IDENTITY` instead of the endpoint — so `rejectUnauthorized: true` alone would
// accept this connection, as the control below demonstrates.
const { mismatch, port } = requireEndpoint();
const ca = await readCertificateAuthority("ca.crt");
const ssl = databaseTlsConnectionOptions(
`postgresql://ekklesia_tls@${mismatch}:${port}/ekklesia_tls`,
{ certificateAuthority: ca, mode: "verify-full" },
);
await expect(connectWith(mismatch, port, ssl)).rejects.toMatchObject({
code: "ERR_TLS_CERT_ALTNAME_INVALID",
});
});
it("proves the gap the policy closes: chain-only verification would accept that same mismatch", async () => {
const { identity, mismatch, port } = requireEndpoint();
const ca = await readCertificateAuthority("ca.crt");
// Exactly what `ssl: { ca, rejectUnauthorized: true }` alone does — no pinned
// `checkServerIdentity`. It connects to an address the certificate does not cover, because the
// only identity Node had to check was `NODE_FALLBACK_IDENTITY`. The endpoint is never verified
// at all, which is why the policy supplies its own check.
expect(mismatch).not.toBe(identity);
expect(net.isIP(mismatch)).toBeGreaterThan(0);
const { encrypted } = await connectWith(mismatch, port, { ca, rejectUnauthorized: true });
expect(encrypted).toBe(true);
});
it("cannot satisfy verify-full against a plaintext-only PostgreSQL endpoint", async () => {
// The disposable plaintext PostgreSQL the rest of the integration suite uses.
const plaintextUrl = process.env.EKKLESIA_MIGRATION_TEST_DATABASE_URL;
if (typeof plaintextUrl !== "string" || plaintextUrl.trim() === "") {
throw new TlsHarnessError("The plaintext integration database is not configured.");
}
const parsed = new URL(plaintextUrl);
const ca = await readCertificateAuthority("ca.crt");
const ssl = databaseTlsConnectionOptions(plaintextUrl, {
certificateAuthority: ca,
mode: "verify-full",
});
const client = new Client({
connectionTimeoutMillis: 5_000,
database: parsed.pathname.slice(1),
host: parsed.hostname,
password: decodeURIComponent(parsed.password),
port: Number(parsed.port),
...(ssl === false ? {} : { ssl }),
user: decodeURIComponent(parsed.username),
});
try {
await expect(client.connect()).rejects.toBeTruthy();
} finally {
try {
await client.end();
} catch {
// Already unusable.
}
}
});
});
+303
View File
@@ -0,0 +1,303 @@
import tls from "node:tls";
import { describe, expect, it, vi } from "vitest";
import { reportMigrationCommandError } from "../src/cli/migration-command-errors.js";
import {
assertNoConnectionStringTlsParameters,
assertProductionDatabaseTransport,
createDatabaseTransportPolicy,
databaseHostFromUrl,
DatabaseTransportError,
databaseTlsConnectionOptions,
describeDatabaseTransport,
} from "../src/database/transport.js";
const productionUrl = "postgresql://ekklesia:[email protected]:5432/ekklesia";
describe("createDatabaseTransportPolicy", () => {
it("defaults to certificate-verified TLS in production and plaintext in development", () => {
expect(createDatabaseTransportPolicy()).toEqual({ mode: "verify-full" });
expect(createDatabaseTransportPolicy({}, { environment: "production" })).toEqual({
mode: "verify-full",
});
expect(createDatabaseTransportPolicy({}, { environment: "development" })).toEqual({
mode: "disable",
});
});
it("refuses plaintext in production", () => {
let captured: unknown;
try {
createDatabaseTransportPolicy({ mode: "disable" }, { environment: "production" });
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(DatabaseTransportError);
expect(captured).toMatchObject({ code: "DATABASE_TLS_REQUIRED_IN_PRODUCTION" });
});
it("rejects every loose, aliased, or coercible mode spelling in both environments", () => {
for (const mode of [
"VERIFY-FULL",
"Verify-Full",
" verify-full",
"verify-full ",
"require", // encryption-only: deliberately unsupported
"verify-ca", // no hostname verification: deliberately unsupported
"prefer",
"no-verify",
"allow",
"true",
true,
false,
1,
0,
"",
null,
]) {
for (const environment of ["production", "development"] as const) {
let captured: unknown;
try {
createDatabaseTransportPolicy({ mode }, { environment });
} catch (error) {
captured = error;
}
expect(captured, `expected ${JSON.stringify(mode)} to be rejected`).toMatchObject({
code: "DATABASE_TLS_MODE_INVALID",
});
}
}
});
it("accepts an optional private CA and rejects an empty one", () => {
expect(
createDatabaseTransportPolicy({ certificateAuthority: "-----BEGIN CERTIFICATE-----" })
.certificateAuthority,
).toBe("-----BEGIN CERTIFICATE-----");
for (const certificateAuthority of ["", " ", 42]) {
let captured: unknown;
try {
createDatabaseTransportPolicy({ certificateAuthority });
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(DatabaseTransportError);
}
});
it("never carries the offending mode or CA material in the thrown error", () => {
let captured: unknown;
try {
createDatabaseTransportPolicy({ certificateAuthority: "SUPER-SECRET-CA", mode: "sslmode=x" });
} catch (error) {
captured = error;
}
const publicText = `${String(captured)}\n${
captured instanceof Error ? (captured.stack ?? "") : ""
}\n${JSON.stringify(captured)}`;
expect(publicText).not.toContain("SUPER-SECRET-CA");
expect(publicText).not.toContain("sslmode=x");
});
});
describe("assertNoConnectionStringTlsParameters", () => {
it("rejects every TLS parameter the pinned connection-string parser recognizes", () => {
for (const parameter of [
"ssl=true",
"sslmode=disable",
"sslmode=require",
"sslmode=verify-full",
"sslcert=/tmp/c.crt",
"sslkey=/tmp/c.key",
"sslrootcert=/tmp/ca.crt",
"sslpassword=x",
"sslnegotiation=direct",
"uselibpqcompat=true",
"SSLMODE=disable", // case-insensitive
]) {
let captured: unknown;
try {
assertNoConnectionStringTlsParameters(`${productionUrl}?${parameter}`);
} catch (error) {
captured = error;
}
expect(captured, `expected ${parameter} to be rejected`).toMatchObject({
code: "DATABASE_TLS_CONNECTION_STRING_CONFLICT",
});
}
});
it("allows an unrelated parameter", () => {
expect(() =>
assertNoConnectionStringTlsParameters(`${productionUrl}?application_name=ekklesia`),
).not.toThrow();
});
it("rejects a non-PostgreSQL or malformed URL", () => {
for (const url of ["", "not-a-url", "mysql://db.example.test/x", "http://db.example.test"]) {
let captured: unknown;
try {
assertNoConnectionStringTlsParameters(url);
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "DATABASE_URL_INVALID" });
}
});
});
describe("databaseTlsConnectionOptions", () => {
it("produces no TLS options at all when the policy disables TLS", () => {
expect(
databaseTlsConnectionOptions("postgresql://127.0.0.1:5432/ekklesia", { mode: "disable" }),
).toBe(false);
});
it("enables chain verification and pins hostname verification to the configured host", () => {
const options = databaseTlsConnectionOptions(productionUrl, { mode: "verify-full" });
expect(options).not.toBe(false);
if (options === false) {
throw new Error("expected TLS options");
}
// There is no configuration path that produces `rejectUnauthorized: false`.
expect(options.rejectUnauthorized).toBe(true);
expect(typeof options.checkServerIdentity).toBe("function");
expect(options.ca).toBeUndefined();
});
it("checks the certificate against the configured host, not against whatever SNI the driver set", () => {
// The pinned driver sets `servername` only for non-IP hosts, and Node skips identity checking
// entirely when it is unset — so the policy must pin the host itself. This asserts the
// supplied callback ignores the passed servername and uses the configured host.
const options = databaseTlsConnectionOptions(productionUrl, { mode: "verify-full" });
if (options === false || !options.checkServerIdentity) {
throw new Error("expected a checkServerIdentity callback");
}
const spy = vi.spyOn(tls, "checkServerIdentity").mockReturnValue(undefined);
try {
options.checkServerIdentity("attacker-supplied.example", {} as tls.PeerCertificate);
expect(spy).toHaveBeenCalledWith("db.example.test", expect.anything());
} finally {
spy.mockRestore();
}
});
it("uses the bare address for an IPv6 host so identity checking sees a usable value", () => {
expect(databaseHostFromUrl("postgresql://[2001:db8::1]:5432/ekklesia")).toBe("2001:db8::1");
expect(databaseHostFromUrl("postgresql://db.example.test:5432/ekklesia")).toBe(
"db.example.test",
);
});
it("passes an explicit private CA through unchanged", () => {
const certificateAuthority =
"-----BEGIN CERTIFICATE-----\nprivate-ca\n-----END CERTIFICATE-----";
const options = databaseTlsConnectionOptions(productionUrl, {
certificateAuthority,
mode: "verify-full",
});
if (options === false) {
throw new Error("expected TLS options");
}
expect(options.ca).toBe(certificateAuthority);
});
it("refuses to build options for a URL carrying its own TLS parameters", () => {
let captured: unknown;
try {
databaseTlsConnectionOptions(`${productionUrl}?sslmode=disable`, { mode: "verify-full" });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "DATABASE_TLS_CONNECTION_STRING_CONFLICT" });
});
});
describe("assertProductionDatabaseTransport", () => {
it("permits verified TLS in production and any validated policy in development", () => {
expect(() =>
assertProductionDatabaseTransport(productionUrl, { mode: "verify-full" }, "production"),
).not.toThrow();
expect(() =>
assertProductionDatabaseTransport(
"postgresql://127.0.0.1:5432/ekklesia",
{ mode: "disable" },
"development",
),
).not.toThrow();
});
it("refuses production plaintext, including for a loopback database — there is no production exception", () => {
for (const url of [
productionUrl,
"postgresql://127.0.0.1:5432/ekklesia",
"postgresql://localhost:5432/ekklesia",
"postgresql://[::1]:5432/ekklesia",
]) {
let captured: unknown;
try {
assertProductionDatabaseTransport(url, { mode: "disable" }, "production");
} catch (error) {
captured = error;
}
expect(captured, `expected production plaintext to ${url} to be refused`).toMatchObject({
code: "DATABASE_TLS_REQUIRED_IN_PRODUCTION",
});
}
});
});
describe("migration CLI transport reporting", () => {
it("reports the stable transport code instead of a generic 'failed unexpectedly', and leaks nothing", () => {
const captured: string[] = [];
const errorSpy = vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
captured.push(args.map(String).join(" "));
});
try {
for (const url of [
`${productionUrl}?sslmode=disable`,
"postgresql://ekklesia:[email protected]:5432/ekklesia?ssl=true",
]) {
try {
databaseTlsConnectionOptions(url, { mode: "verify-full" });
} catch (error) {
reportMigrationCommandError(error);
}
}
try {
createDatabaseTransportPolicy({ mode: "disable" }, { environment: "production" });
} catch (error) {
reportMigrationCommandError(error);
}
} finally {
errorSpy.mockRestore();
}
const output = captured.join("\n");
expect(output).toContain("[DATABASE_TLS_CONNECTION_STRING_CONFLICT]");
expect(output).toContain("[DATABASE_TLS_REQUIRED_IN_PRODUCTION]");
expect(output).not.toContain("failed unexpectedly");
// The connection string, its credentials, and its host never reach operator output.
for (const secret of ["hunter2", "db.example.test", "secret", "sslmode=disable"]) {
expect(output).not.toContain(secret);
}
});
});
describe("describeDatabaseTransport", () => {
it("describes the effective mode without revealing any connection detail", () => {
expect(describeDatabaseTransport({ mode: "verify-full" })).toBe(
"TLS required with certificate and hostname verification.",
);
expect(
describeDatabaseTransport({ certificateAuthority: "SECRET-CA", mode: "verify-full" }),
).toBe(
"TLS required with certificate and hostname verification (explicit private CA supplied).",
);
expect(
describeDatabaseTransport({ certificateAuthority: "SECRET-CA", mode: "verify-full" }),
).not.toContain("SECRET-CA");
expect(describeDatabaseTransport({ mode: "disable" })).toBe(
"TLS disabled (development/test plaintext).",
);
});
});
+4
View File
@@ -87,6 +87,8 @@ Data definitions
0 migrations · checksums valid 0 migrations · checksums valid
PostgreSQL PostgreSQL
✓ Database TLS verified
TLS required with certificate and hostname verification.
○ Connection check skipped ○ Connection check skipped
DATABASE_URL is not configured. DATABASE_URL is not configured.
○ Migration plan skipped ○ Migration plan skipped
@@ -96,6 +98,8 @@ Identity
○ Identity configuration skipped ○ Identity configuration skipped
Core identity schema defines 4 tables (ekklesia_core_account, ekklesia_core_session, ekklesia_core_user, ekklesia_core_verification). Core identity schema defines 4 tables (ekklesia_core_account, ekklesia_core_session, ekklesia_core_user, ekklesia_core_verification).
Identity runtime configuration (signing key, base URL, trusted origins) is not set. Identity runtime configuration (signing key, base URL, trusted origins) is not set.
✓ Direct client-address mode
Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted.
Object storage Object storage
○ Check unavailable ○ Check unavailable
+215 -1
View File
@@ -206,9 +206,11 @@ describe("Ekklesia Doctor diagnostics", () => {
"modules.permissions", "modules.permissions",
"schemas.discovery", "schemas.discovery",
"migrations.discovery", "migrations.discovery",
"database.transport",
"postgresql.connection", "postgresql.connection",
"postgresql.plan", "postgresql.plan",
"identity.configuration", "identity.configuration",
"identity.client-address",
"object-storage.integration", "object-storage.integration",
]); ]);
expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]); expect(check(report, "modules.discovery").details).toEqual(["people 0.1.0", "calendar 0.2.0"]);
@@ -254,9 +256,15 @@ describe("Ekklesia Doctor diagnostics", () => {
dependencies({ createMigrationDatabase, planMigrations }), dependencies({ createMigrationDatabase, planMigrations }),
); );
// Doctor's own read-only connection carries the same validated transport policy every other
// executable uses — it defaults to certificate-verified TLS, exactly like production.
expect(createMigrationDatabase).toHaveBeenCalledWith( expect(createMigrationDatabase).toHaveBeenCalledWith(
"postgresql://not-rendered.invalid/ekklesia", "postgresql://not-rendered.invalid/ekklesia",
{ connectionTimeoutMs: 10_000, operationTimeoutMs: 10_000 }, {
connectionTimeoutMs: 10_000,
operationTimeoutMs: 10_000,
transport: { mode: "verify-full" },
},
); );
expect(planMigrations).toHaveBeenCalledOnce(); expect(planMigrations).toHaveBeenCalledOnce();
expect(check(report, "postgresql.connection")).toMatchObject({ expect(check(report, "postgresql.connection")).toMatchObject({
@@ -613,6 +621,212 @@ describe("Ekklesia Doctor diagnostics", () => {
expect(JSON.stringify(report)).not.toContain(hostileSecret); expect(JSON.stringify(report)).not.toContain(hostileSecret);
}); });
it("passes identity configuration and reports public signup disabled by default", async () => {
const report = await diagnoseEkklesia(
{
...options(),
identityConfiguration: {
baseUrl: "https://identity.example.test",
environment: "production",
secret: "correct-horse-battery-staple-0123456789",
trustedOrigins: ["https://identity.example.test"],
},
},
dependencies(),
);
const identity = check(report, "identity.configuration");
expect(identity.status).toBe("pass");
expect(identity.details.some((detail) => detail.includes("disabled (safe default)"))).toBe(
true,
);
});
it("reports public signup as explicitly enabled when configured in development", async () => {
const report = await diagnoseEkklesia(
{
...options(),
identityConfiguration: {
baseUrl: "http://localhost:3000",
environment: "development",
publicSignupEnabled: "true",
secret: "correct-horse-battery-staple-0123456789",
trustedOrigins: ["http://localhost:3000"],
},
},
dependencies(),
);
const identity = check(report, "identity.configuration");
expect(identity.status).toBe("pass");
expect(identity.details.some((detail) => detail.includes("explicitly ENABLED"))).toBe(true);
});
it("blocks readiness — not merely warns — when production configuration enables public signup", async () => {
const report = await diagnoseEkklesia(
{
...options(),
identityConfiguration: {
baseUrl: "https://identity.example.test",
environment: "production",
publicSignupEnabled: "true",
secret: "correct-horse-battery-staple-0123456789",
trustedOrigins: ["https://identity.example.test"],
},
},
dependencies(),
);
const identity = check(report, "identity.configuration");
expect(identity.status).toBe("failure");
expect(
identity.details.some((detail) => detail.includes("PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION")),
).toBe(true);
expect(report.result).toBe("not-ready");
expect(doctorExitCode(report)).toBe(1);
});
it("fails identity configuration for a misspelled public-signup value, never silently disabling or enabling it", async () => {
const report = await diagnoseEkklesia(
{
...options(),
identityConfiguration: {
baseUrl: "https://identity.example.test",
environment: "production",
publicSignupEnabled: "yes",
secret: "correct-horse-battery-staple-0123456789",
trustedOrigins: ["https://identity.example.test"],
},
},
dependencies(),
);
const identity = check(report, "identity.configuration");
expect(identity.status).toBe("failure");
expect(
identity.details.some((detail) => detail.includes("PUBLIC_SIGNUP_ENABLED_INVALID")),
).toBe(true);
expect(report.result).toBe("not-ready");
});
it("passes database transport by default, reporting verified TLS without revealing any connection detail", async () => {
const report = await diagnoseEkklesia(
{ ...options("postgresql://admin:[email protected]:5432/ekklesia") },
dependencies(),
);
const transport = check(report, "database.transport");
expect(transport.status).toBe("pass");
expect(transport.summary).toBe("Database TLS verified");
const rendered = JSON.stringify(transport);
expect(rendered).not.toContain("hunter2");
expect(rendered).not.toContain("db.internal.example");
expect(rendered).not.toContain("postgresql://");
});
it("blocks readiness when a production deployment would reach PostgreSQL without verified TLS", async () => {
for (const [label, extra] of [
["explicit production plaintext", { databaseTransport: { mode: "disable" } }],
[
"connection-string downgrade",
{ databaseUrl: "postgresql://db.example.test/ekklesia?sslmode=disable" },
],
["malformed mode", { databaseTransport: { mode: "require" } }],
] as const) {
const report = await diagnoseEkklesia(
{ ...options("postgresql://db.example.test/ekklesia"), ...extra },
dependencies(),
);
const transport = check(report, "database.transport");
expect(transport.status, `expected ${label} to block readiness`).toBe("failure");
expect(transport.summary).toBe("Database transport unsafe");
expect(report.result).toBe("not-ready");
expect(doctorExitCode(report)).toBe(1);
}
});
it("passes development plaintext only when development is explicitly selected", async () => {
const report = await diagnoseEkklesia(
{
...options("postgresql://127.0.0.1:5432/ekklesia"),
databaseEnvironment: "development",
},
dependencies(),
);
const transport = check(report, "database.transport");
expect(transport.status).toBe("pass");
expect(transport.summary).toContain("disabled");
});
it("passes client-address configuration with direct mode when nothing is configured", async () => {
const report = await diagnoseEkklesia(options(), dependencies());
const clientAddress = check(report, "identity.client-address");
expect(clientAddress.status).toBe("pass");
expect(clientAddress.summary).toContain("Direct");
expect(report.result).toBe("ready-with-warnings");
});
it("passes client-address configuration with an explicitly configured trusted-proxy mode", async () => {
const report = await diagnoseEkklesia(
{
...options(),
clientAddress: {
header: "x-forwarded-for",
mode: "trusted-proxy",
trustedProxies: ["10.0.0.1", "10.0.0.2"],
},
},
dependencies(),
);
const clientAddress = check(report, "identity.client-address");
expect(clientAddress.status).toBe("pass");
expect(clientAddress.summary).toContain("Trusted-proxy");
expect(clientAddress.details.some((detail) => detail.includes("x-forwarded-for"))).toBe(true);
});
it("fails client-address configuration for an invalid trusted-proxy entry without leaking it", async () => {
const hostileEntry = "not-an-ip-secret-value";
const report = await diagnoseEkklesia(
{
...options(),
clientAddress: {
header: "x-forwarded-for",
mode: "trusted-proxy",
trustedProxies: [hostileEntry],
},
},
dependencies(),
);
const clientAddress = check(report, "identity.client-address");
expect(clientAddress.status).toBe("failure");
expect(
clientAddress.details.some((detail) =>
detail.includes("CLIENT_ADDRESS_TRUSTED_PROXY_INVALID"),
),
).toBe(true);
expect(JSON.stringify(report)).not.toContain(hostileEntry);
expect(report.result).toBe("not-ready");
});
it("fails client-address configuration for an incomplete trusted-proxy mode (missing header)", async () => {
const report = await diagnoseEkklesia(
{
...options(),
clientAddress: { mode: "trusted-proxy", trustedProxies: ["10.0.0.1"] },
},
dependencies(),
);
const clientAddress = check(report, "identity.client-address");
expect(clientAddress.status).toBe("failure");
expect(report.result).toBe("not-ready");
});
it("never constructs a migration database or Better Auth merely to check identity configuration", async () => { it("never constructs a migration database or Better Auth merely to check identity configuration", async () => {
const services = dependencies(); const services = dependencies();
await diagnoseEkklesia( await diagnoseEkklesia(
@@ -37,6 +37,7 @@ export default {
method: "GET", method: "GET",
path: "/records/:id", path: "/records/:id",
permission: "db-facade.records.read", permission: "db-facade.records.read",
maxBodyBytes: 0,
async handler(_request, context) { async handler(_request, context) {
recordEvent({ recordEvent({
contextKeys: Object.keys(context).sort(), contextKeys: Object.keys(context).sort(),
@@ -62,6 +63,7 @@ export default {
// fixture's one authenticated-baseline GET route, used to prove HEAD cannot bypass // fixture's one authenticated-baseline GET route, used to prove HEAD cannot bypass
// authorization the way it could if HEAD skipped the same middleware chain GET uses. // authorization the way it could if HEAD skipped the same middleware chain GET uses.
permission: "db-facade.records.write", permission: "db-facade.records.write",
maxBodyBytes: 0,
async handler(_request, context) { async handler(_request, context) {
recordEvent({ recordEvent({
contextKeys: Object.keys(context).sort(), contextKeys: Object.keys(context).sort(),
@@ -81,6 +83,7 @@ export default {
method: "POST", method: "POST",
path: "/records", path: "/records",
permission: "db-facade.records.write", permission: "db-facade.records.write",
maxBodyBytes: 1024,
async handler(request, context) { async handler(request, context) {
recordEvent({ recordEvent({
contextKeys: Object.keys(context).sort(), contextKeys: Object.keys(context).sort(),
@@ -98,6 +101,7 @@ export default {
method: "PATCH", method: "PATCH",
path: "/records/:id", path: "/records/:id",
permission: "db-facade.records.write", permission: "db-facade.records.write",
maxBodyBytes: 1024,
async handler(request, context) { async handler(request, context) {
recordEvent({ recordEvent({
contextKeys: Object.keys(context).sort(), contextKeys: Object.keys(context).sort(),
@@ -122,6 +126,7 @@ export default {
method: "POST", method: "POST",
path: "/records/transactional", path: "/records/transactional",
permission: "db-facade.records.write", permission: "db-facade.records.write",
maxBodyBytes: 1024,
async handler(request, context) { async handler(request, context) {
recordEvent({ recordEvent({
contextKeys: Object.keys(context).sort(), contextKeys: Object.keys(context).sort(),
@@ -152,6 +157,7 @@ export default {
method: "GET", method: "GET",
path: "/boom", path: "/boom",
permission: "db-facade.records.read", permission: "db-facade.records.read",
maxBodyBytes: 0,
async handler(_request, context) { async handler(_request, context) {
recordEvent({ recordEvent({
contextKeys: Object.keys(context).sort(), contextKeys: Object.keys(context).sort(),
@@ -170,6 +176,7 @@ export default {
method: "GET", method: "GET",
path: "/safe-boom", path: "/safe-boom",
permission: "db-facade.records.read", permission: "db-facade.records.read",
maxBodyBytes: 0,
async handler(_request, context) { async handler(_request, context) {
recordEvent({ recordEvent({
contextKeys: Object.keys(context).sort(), contextKeys: Object.keys(context).sort(),
+350
View File
@@ -0,0 +1,350 @@
import { describe, expect, it } from "vitest";
import {
ClientAddressConfigError,
createClientAddressConfig,
createClientAddressResolver,
findInvalidTrustedProxies,
normalizeClientAddress,
} from "../src/identity/client-address.js";
const INTERNAL_HEADER = "x-ekklesia-client-address";
describe("createClientAddressConfig", () => {
it("defaults to direct mode when mode is entirely omitted", () => {
expect(createClientAddressConfig()).toEqual({ mode: "direct" });
expect(createClientAddressConfig({})).toEqual({ mode: "direct" });
expect(Object.isFrozen(createClientAddressConfig())).toBe(true);
});
it("rejects direct mode combined with a header or trusted proxies", () => {
expect(() => createClientAddressConfig({ header: "x-forwarded-for", mode: "direct" })).toThrow(
ClientAddressConfigError,
);
try {
createClientAddressConfig({ mode: "direct", trustedProxies: ["10.0.0.1"] });
} catch (error) {
expect(error).toMatchObject({ code: "CLIENT_ADDRESS_DIRECT_MODE_CONTRADICTION" });
}
});
it("rejects an invalid mode value", () => {
let captured: unknown;
try {
createClientAddressConfig({ mode: "proxy" });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "CLIENT_ADDRESS_MODE_INVALID" });
});
it("accepts a valid trusted-proxy configuration", () => {
const config = createClientAddressConfig({
header: "X-Forwarded-For",
mode: "trusted-proxy",
trustedProxies: ["10.0.0.5", "192.168.1.0/24"],
});
expect(config).toEqual({
header: "x-forwarded-for",
mode: "trusted-proxy",
trustedProxies: ["10.0.0.5", "192.168.1.0/24"],
});
expect(Object.isFrozen(config)).toBe(true);
if (config.mode === "trusted-proxy") {
expect(Object.isFrozen(config.trustedProxies)).toBe(true);
}
});
it("rejects trusted-proxy mode missing a header", () => {
let captured: unknown;
try {
createClientAddressConfig({ mode: "trusted-proxy", trustedProxies: ["10.0.0.1"] });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "CLIENT_ADDRESS_HEADER_MISSING" });
});
it("rejects an invalid header name", () => {
let captured: unknown;
try {
createClientAddressConfig({
header: "not a header!",
mode: "trusted-proxy",
trustedProxies: ["10.0.0.1"],
});
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "CLIENT_ADDRESS_HEADER_INVALID" });
});
it("rejects the internal platform header as a configured source header", () => {
let captured: unknown;
try {
createClientAddressConfig(
{
header: INTERNAL_HEADER,
mode: "trusted-proxy",
trustedProxies: ["10.0.0.1"],
},
INTERNAL_HEADER,
);
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "CLIENT_ADDRESS_HEADER_INVALID" });
});
it("rejects trusted-proxy mode with an empty trusted-proxy list", () => {
let captured: unknown;
try {
createClientAddressConfig({
header: "x-forwarded-for",
mode: "trusted-proxy",
trustedProxies: [],
});
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "CLIENT_ADDRESS_TRUSTED_PROXIES_EMPTY" });
});
it("rejects a malformed trusted-proxy entry", () => {
for (const trustedProxies of [
["not-an-ip"],
["10.0.0.1/33"],
["10.0.0.1/-1"],
["10.0.0.1/1e3"],
[""],
]) {
let captured: unknown;
try {
createClientAddressConfig({
header: "x-forwarded-for",
mode: "trusted-proxy",
trustedProxies,
});
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "CLIENT_ADDRESS_TRUSTED_PROXY_INVALID" });
}
});
it("never leaks the offending mode, header, or trusted-proxy value in the thrown error", () => {
const hostileHeader = "x-hostile-secret-header";
let captured: unknown;
try {
createClientAddressConfig({
header: hostileHeader,
mode: "trusted-proxy",
trustedProxies: ["definitely-not-an-ip"],
});
} catch (error) {
captured = error;
}
const publicText = `${String(captured)}\n${
captured instanceof Error ? (captured.stack ?? "") : ""
}`;
expect(publicText).not.toContain(hostileHeader);
expect(publicText).not.toContain("definitely-not-an-ip");
});
});
describe("findInvalidTrustedProxies", () => {
it("identifies exactly the malformed entries", () => {
expect(
findInvalidTrustedProxies(["10.0.0.1", "not-an-ip", "192.168.0.0/24", "10.0.0.1/99"]),
).toEqual(["not-an-ip", "10.0.0.1/99"]);
});
it("accepts IPv6 addresses and ranges", () => {
expect(findInvalidTrustedProxies(["::1", "fd00::/8", "2001:db8::1"])).toEqual([]);
});
});
describe("normalizeClientAddress", () => {
it("passes a plain IPv4 address through unchanged", () => {
expect(normalizeClientAddress("192.0.2.10")).toBe("192.0.2.10");
});
it("lowercases a plain IPv6 address", () => {
expect(normalizeClientAddress("2001:DB8::1")).toBe("2001:db8::1");
});
it("collapses an IPv4-mapped IPv6 address to its embedded IPv4 form", () => {
expect(normalizeClientAddress("::ffff:127.0.0.1")).toBe("127.0.0.1");
expect(normalizeClientAddress("::FFFF:203.0.113.5")).toBe("203.0.113.5");
});
it("rejects a zone id as ambiguous", () => {
expect(normalizeClientAddress("fe80::1%eth0")).toBeUndefined();
});
it("rejects malformed, empty, or non-IP input", () => {
for (const value of ["", " ", "not-an-ip", "999.999.999.999", "1.2.3"]) {
expect(normalizeClientAddress(value)).toBeUndefined();
}
});
it("trims surrounding whitespace", () => {
expect(normalizeClientAddress(" 192.0.2.10 ")).toBe("192.0.2.10");
});
});
describe("createClientAddressResolver — direct mode", () => {
it("resolves the actual immediate socket peer, ignoring any forwarded header", () => {
const resolver = createClientAddressResolver({ mode: "direct" });
const result = resolver.resolve({
headers: new Headers({ "x-forwarded-for": "203.0.113.99" }),
peerAddress: "192.0.2.1",
});
expect(result).toEqual({ address: "192.0.2.1", ok: true });
});
it("fails closed when no peer address is available", () => {
const resolver = createClientAddressResolver({ mode: "direct" });
const result = resolver.resolve({ headers: new Headers(), peerAddress: undefined });
expect(result).toEqual({ ok: false, reason: "PEER_ADDRESS_UNAVAILABLE" });
});
it("fails closed when the peer address is not a valid IP", () => {
const resolver = createClientAddressResolver({ mode: "direct" });
const result = resolver.resolve({ headers: new Headers(), peerAddress: "not-an-ip" });
expect(result).toEqual({ ok: false, reason: "PEER_ADDRESS_UNAVAILABLE" });
});
it("normalizes an IPv4-mapped IPv6 peer to its IPv4 form", () => {
const resolver = createClientAddressResolver({ mode: "direct" });
const result = resolver.resolve({ headers: new Headers(), peerAddress: "::ffff:198.51.100.7" });
expect(result).toEqual({ address: "198.51.100.7", ok: true });
});
it("gives distinct real peer addresses distinct resolved identities", () => {
const resolver = createClientAddressResolver({ mode: "direct" });
const first = resolver.resolve({ headers: new Headers(), peerAddress: "127.0.0.2" });
const second = resolver.resolve({ headers: new Headers(), peerAddress: "127.0.0.3" });
expect(first).toEqual({ address: "127.0.0.2", ok: true });
expect(second).toEqual({ address: "127.0.0.3", ok: true });
expect(first).not.toEqual(second);
});
});
describe("createClientAddressResolver — trusted-proxy mode", () => {
function proxyResolver(trustedProxies: readonly string[] = ["10.0.0.1", "10.0.0.2"]) {
return createClientAddressResolver({
header: "x-forwarded-for",
mode: "trusted-proxy",
trustedProxies,
});
}
it("ignores the forwarded header entirely when the immediate peer is not a trusted proxy", () => {
const resolver = proxyResolver();
const result = resolver.resolve({
headers: new Headers({ "x-forwarded-for": "203.0.113.99" }),
peerAddress: "198.51.100.1",
});
// Falls back to direct-mode behavior: the untrusted peer itself, never the header content.
expect(result).toEqual({ address: "198.51.100.1", ok: true });
});
it("honors a single-value forwarded header from a trusted proxy", () => {
const resolver = proxyResolver();
const result = resolver.resolve({
headers: new Headers({ "x-forwarded-for": "203.0.113.5" }),
peerAddress: "10.0.0.1",
});
expect(result).toEqual({ address: "203.0.113.5", ok: true });
});
it("walks a chained forwarded header right-to-left, skipping trusted-proxy hops", () => {
const resolver = proxyResolver();
const result = resolver.resolve({
headers: new Headers({ "x-forwarded-for": "203.0.113.5, 10.0.0.2" }),
peerAddress: "10.0.0.1",
});
expect(result).toEqual({ address: "203.0.113.5", ok: true });
});
it("walks past multiple trusted-proxy hops to find the genuine client", () => {
const resolver = proxyResolver(["10.0.0.1", "10.0.0.2", "10.0.0.3"]);
const result = resolver.resolve({
headers: new Headers({ "x-forwarded-for": "203.0.113.5, 10.0.0.3, 10.0.0.2" }),
peerAddress: "10.0.0.1",
});
expect(result).toEqual({ address: "203.0.113.5", ok: true });
});
it("fails closed when the trusted proxy sends no forwarded header at all", () => {
const resolver = proxyResolver();
const result = resolver.resolve({ headers: new Headers(), peerAddress: "10.0.0.1" });
expect(result).toEqual({ ok: false, reason: "FORWARDED_HEADER_MISSING" });
});
it("fails closed on an empty (but present) forwarded header value", () => {
const resolver = proxyResolver();
const result = resolver.resolve({
headers: new Headers({ "x-forwarded-for": "" }),
peerAddress: "10.0.0.1",
});
expect(result).toEqual({ ok: false, reason: "FORWARDED_HEADER_MALFORMED" });
});
it("fails closed on a malformed token anywhere in the chain", () => {
const resolver = proxyResolver();
const result = resolver.resolve({
headers: new Headers({ "x-forwarded-for": "203.0.113.5, not-an-ip" }),
peerAddress: "10.0.0.1",
});
expect(result).toEqual({ ok: false, reason: "FORWARDED_HEADER_MALFORMED" });
});
it("fails closed on an empty comma-separated segment (ambiguous multi-valued form)", () => {
const resolver = proxyResolver();
const result = resolver.resolve({
headers: new Headers({ "x-forwarded-for": "203.0.113.5,, 10.0.0.1" }),
peerAddress: "10.0.0.1",
});
expect(result).toEqual({ ok: false, reason: "FORWARDED_HEADER_MALFORMED" });
});
it("fails closed when the entire chain resolves to trusted-proxy hops with no genuine client left", () => {
const resolver = proxyResolver();
const result = resolver.resolve({
headers: new Headers({ "x-forwarded-for": "10.0.0.2, 10.0.0.1" }),
peerAddress: "10.0.0.1",
});
expect(result).toEqual({ ok: false, reason: "FORWARDED_CHAIN_EXHAUSTED" });
});
it("normalizes an IPv4-mapped IPv6 hop before matching it against a trusted IPv4 proxy", () => {
const resolver = proxyResolver(["10.0.0.1"]);
const result = resolver.resolve({
headers: new Headers({ "x-forwarded-for": "203.0.113.5, ::ffff:10.0.0.1" }),
peerAddress: "10.0.0.1",
});
expect(result).toEqual({ address: "203.0.113.5", ok: true });
});
it("matches a trusted-proxy CIDR range, not only an exact address", () => {
const resolver = proxyResolver(["192.168.1.0/24"]);
const result = resolver.resolve({
headers: new Headers({ "x-forwarded-for": "203.0.113.5" }),
peerAddress: "192.168.1.42",
});
expect(result).toEqual({ address: "203.0.113.5", ok: true });
});
it("never lets a spoofed header from an untrusted peer choose a client identity that collides with a trusted flow", () => {
const resolver = proxyResolver();
// An attacker connecting directly (not through the real proxy) claims to forward from a
// trusted-proxy address itself — since the *immediate peer* is what gates trust, not header
// content, this must still resolve to the attacker's own real peer address.
const result = resolver.resolve({
headers: new Headers({ "x-forwarded-for": "203.0.113.5, 10.0.0.1" }),
peerAddress: "203.0.113.200",
});
expect(result).toEqual({ address: "203.0.113.200", ok: true });
});
});
+232
View File
@@ -9,12 +9,24 @@ function validInput() {
return { baseUrl: validBaseUrl, secret: validSecret, trustedOrigins: validTrustedOrigins }; return { baseUrl: validBaseUrl, secret: validSecret, trustedOrigins: validTrustedOrigins };
} }
/** The loopback/`http` shape only `{ environment: "development" }` accepts — used by the tests that
* exercise development-only behavior (such as explicitly enabling public signup) so they never
* accidentally pass through production validation. */
function developmentInput() {
return {
baseUrl: "http://localhost:3000",
secret: validSecret,
trustedOrigins: ["http://localhost:3000"],
};
}
describe("Core identity configuration", () => { describe("Core identity configuration", () => {
it("accepts a valid secret, base URL, and trusted origins", () => { it("accepts a valid secret, base URL, and trusted origins", () => {
const config = createIdentityConfig(validInput(), { environment: "production" }); const config = createIdentityConfig(validInput(), { environment: "production" });
expect(config).toEqual({ expect(config).toEqual({
baseUrl: validBaseUrl, baseUrl: validBaseUrl,
publicSignupEnabled: false,
secret: validSecret, secret: validSecret,
trustedOrigins: validTrustedOrigins, trustedOrigins: validTrustedOrigins,
}); });
@@ -167,6 +179,226 @@ describe("Core identity configuration", () => {
expect(config.baseUrl).toBe("http://localhost:3000"); expect(config.baseUrl).toBe("http://localhost:3000");
}); });
it("requires the base URL to be exactly one canonical origin, rejecting every non-origin component", () => {
for (const baseUrl of [
"https://id.example.test/", // a bare trailing slash is a path; `URL.origin` is slashless
"https://id.example.test/auth", // Better Auth is never mounted beneath a configurable path
"https://id.example.test?a=1",
"https://id.example.test#fragment",
"https://user:[email protected]",
"https://:[email protected]",
" https://id.example.test ",
"https://ID.EXAMPLE.TEST", // non-canonical hostname case
"https://id.example.test:443", // redundant default port
"http://id.example.test:80",
"ftp://id.example.test",
"id.example.test",
]) {
let captured: unknown;
try {
createIdentityConfig({ ...validInput(), baseUrl }, { environment: "production" });
} catch (error) {
captured = error;
}
expect(captured, `expected ${JSON.stringify(baseUrl)} to be rejected`).toMatchObject({
code: "BASE_URL_INVALID",
});
}
});
it("stores the base URL and trusted origins as one identical canonical origin representation", () => {
const config = createIdentityConfig(
{
...validInput(),
baseUrl: "https://id.example.test",
trustedOrigins: ["https://id.example.test", "https://dashboard.example.test:8443"],
},
{ environment: "production" },
);
expect(config.baseUrl).toBe("https://id.example.test");
expect(config.trustedOrigins).toEqual([
"https://id.example.test",
"https://dashboard.example.test:8443",
]);
});
it("rejects every loopback and unspecified-host spelling in production, for the base URL and trusted origins alike", () => {
// Canonical spellings the WHATWG parser preserves — these must be rejected as production
// policy failures, by the identical classifier on both fields.
const canonicalLoopback = [
"https://127.0.0.1",
"https://127.0.0.2", // the full 127.0.0.0/8 block, not just .1
"https://127.255.255.254",
"https://[::1]", // IPv6 loopback, compressed
"https://[::ffff:7f00:1]", // IPv4-mapped loopback, hexadecimal notation
"https://localhost",
"https://app.localhost", // RFC 6761 reserved namespace
"https://0.0.0.0",
"https://[::]", // IPv6 unspecified
"https://[::ffff:0:0]", // IPv4-mapped unspecified
];
for (const value of canonicalLoopback) {
let baseCaptured: unknown;
try {
createIdentityConfig({ ...validInput(), baseUrl: value }, { environment: "production" });
} catch (error) {
baseCaptured = error;
}
expect(baseCaptured, `expected base URL ${value} to be rejected`).toMatchObject({
code: "BASE_URL_LOOPBACK_IN_PRODUCTION",
});
let originCaptured: unknown;
try {
createIdentityConfig(
{ ...validInput(), trustedOrigins: [value] },
{ environment: "production" },
);
} catch (error) {
originCaptured = error;
}
expect(originCaptured, `expected trusted origin ${value} to be rejected`).toMatchObject({
code: "TRUSTED_ORIGIN_LOOPBACK_IN_PRODUCTION",
});
}
});
it("rejects non-canonical loopback spellings structurally, never as a misleading production-policy failure", () => {
// WHATWG canonicalization folds some of these into `127.0.0.1`; others simply are not the
// canonical origin text. Either way the honest answer is "this is not a canonical origin".
for (const baseUrl of [
"https://127.1",
"https://2130706433",
"https://0x7f000001",
"https://[0:0:0:0:0:0:0:1]", // expanded IPv6 loopback
"https://[::ffff:127.0.0.1]", // mixed IPv4-mapped notation
"https://LOCALHOST",
]) {
let captured: unknown;
try {
createIdentityConfig({ ...validInput(), baseUrl }, { environment: "production" });
} catch (error) {
captured = error;
}
expect(captured, `expected ${baseUrl} to be structurally rejected`).toMatchObject({
code: "BASE_URL_INVALID",
});
}
});
it("still accepts canonical loopback development origins", () => {
const config = createIdentityConfig(
{
...validInput(),
baseUrl: "http://localhost:3000",
trustedOrigins: ["http://localhost:3000", "http://127.0.0.1:5173"],
},
{ environment: "development" },
);
expect(config.baseUrl).toBe("http://localhost:3000");
expect(config.trustedOrigins).toEqual(["http://localhost:3000", "http://127.0.0.1:5173"]);
});
it("defaults publicSignupEnabled to false when entirely omitted", () => {
const config = createIdentityConfig(validInput(), { environment: "production" });
expect(config.publicSignupEnabled).toBe(false);
});
it("accepts an explicit boolean publicSignupEnabled in development", () => {
expect(
createIdentityConfig(
{ ...developmentInput(), publicSignupEnabled: true },
{ environment: "development" },
).publicSignupEnabled,
).toBe(true);
expect(
createIdentityConfig(
{ ...developmentInput(), publicSignupEnabled: false },
{ environment: "development" },
).publicSignupEnabled,
).toBe(false);
});
it('accepts the exact strings "true"/"false" in development (environment variables are always strings)', () => {
expect(
createIdentityConfig(
{ ...developmentInput(), publicSignupEnabled: "true" },
{ environment: "development" },
).publicSignupEnabled,
).toBe(true);
expect(
createIdentityConfig(
{ ...developmentInput(), publicSignupEnabled: "false" },
{ environment: "development" },
).publicSignupEnabled,
).toBe(false);
});
it("accepts an explicitly disabled publicSignupEnabled in production", () => {
for (const publicSignupEnabled of [false, "false"]) {
expect(
createIdentityConfig(
{ ...validInput(), publicSignupEnabled },
{ environment: "production" },
).publicSignupEnabled,
).toBe(false);
}
});
it("refuses to enable public signup in production until durable abuse controls exist", () => {
// Ekklesia has no verified email, invitation requirement, CAPTCHA, or durable shared/edge
// limiter yet, so a production deployment must not be able to open public registration at all
// — not even deliberately. This is a fail-closed configuration error, never a warning.
for (const publicSignupEnabled of [true, "true"]) {
let captured: unknown;
try {
createIdentityConfig(
{ ...validInput(), publicSignupEnabled },
{ environment: "production" },
);
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(IdentityConfigError);
expect(captured).toMatchObject({ code: "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION" });
}
});
it("defaults to production validation for public signup when environment is not specified", () => {
let captured: unknown;
try {
createIdentityConfig({ ...validInput(), publicSignupEnabled: "true" });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION" });
});
it("reports a malformed public-signup value as malformed even in production, never as unsupported", () => {
let captured: unknown;
try {
createIdentityConfig(
{ ...validInput(), publicSignupEnabled: "TRUE" },
{ environment: "production" },
);
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "PUBLIC_SIGNUP_ENABLED_INVALID" });
});
it("rejects a misspelled, loosely-truthy, or malformed publicSignupEnabled rather than silently defaulting", () => {
for (const publicSignupEnabled of ["1", "0", "yes", "no", "TRUE", "True", " true", "", 1, 0]) {
let captured: unknown;
try {
createIdentityConfig({ ...validInput(), publicSignupEnabled });
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "PUBLIC_SIGNUP_ENABLED_INVALID" });
}
});
it("never leaks the secret, base URL, or trusted origins through a thrown error", () => { it("never leaks the secret, base URL, or trusted origins through a thrown error", () => {
const hostileSecret = "s3cr3t-do-not-log-0123456789abcdef"; const hostileSecret = "s3cr3t-do-not-log-0123456789abcdef";
let captured: unknown; let captured: unknown;
+183 -30
View File
@@ -9,19 +9,61 @@ vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock })); vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
import type { IdentityConfig } from "../src/identity/config.js"; import type { IdentityConfig } from "../src/identity/config.js";
import { createIdentityRuntime, IdentityRuntimeError } from "../src/identity/runtime.js"; import type { IdentityDrizzleDatabase } from "../src/identity/database.js";
import {
createIdentityRuntime,
IdentityRuntimeError,
TRUSTED_CLIENT_ADDRESS_HEADER,
} from "../src/identity/runtime.js";
import { identityAdapterSchema } from "../src/identity/schema.js"; import { identityAdapterSchema } from "../src/identity/schema.js";
function validConfig(): IdentityConfig { function validConfig(overrides: Partial<IdentityConfig> = {}): IdentityConfig {
return Object.freeze({ return Object.freeze({
baseUrl: "https://identity.example.test", baseUrl: "https://identity.example.test",
publicSignupEnabled: false,
secret: "correct-horse-battery-staple-0123456789", secret: "correct-horse-battery-staple-0123456789",
trustedOrigins: Object.freeze(["https://identity.example.test"]), trustedOrigins: Object.freeze(["https://identity.example.test"]),
...overrides,
}); });
} }
function fakeDb(overrides: Partial<{ execute: ReturnType<typeof vi.fn> }> = {}) { interface FakeDbOptions {
return { __fakeDb: true, execute: vi.fn().mockResolvedValue(undefined), ...overrides } as never; /** Makes the session-store readiness read reject, standing in for a missing relation, a dropped
* column, a denied `SELECT` privilege, an unavailable connection, or a query timeout — every one
* of which surfaces to this seam identically, as a rejected query. */
readonly sessionProbeError?: Error;
}
/**
* A fake Identity Drizzle view that records every query actually issued, so a test can prove the
* *absence* of identity database work — not merely that a particular helper was not called. Both
* the legacy `execute` seam and the `select(...).from(...).where(...)` chain the session-store
* readiness check uses are recorded into one ordered list.
*/
function fakeDb(options: FakeDbOptions = {}) {
const queries: string[] = [];
const execute = vi.fn(() => {
queries.push("execute");
return Promise.resolve(undefined);
});
// Drizzle's query builder is awaited directly, so the terminal `where(...)` returns a real
// Promise rather than a hand-rolled thenable object.
const select = vi.fn((columns: Record<string, unknown>) => {
queries.push(`select:${Object.keys(columns).sort().join(",")}`);
const builder = {
from: vi.fn(() => builder),
where: vi.fn(() =>
options.sessionProbeError
? Promise.reject(options.sessionProbeError)
: Promise.resolve([] as unknown[]),
),
};
return builder;
});
const db = { __fakeDb: true, execute, queries, select };
// The runtime only ever uses the narrow surface faked above; the cast keeps the fake honest
// about that instead of stubbing all of `NodePgDatabase`, while `queries` stays observable.
return db as unknown as IdentityDrizzleDatabase & { readonly queries: string[] };
} }
describe("Core identity runtime construction boundary", () => { describe("Core identity runtime construction boundary", () => {
@@ -87,7 +129,7 @@ describe("Core identity runtime construction boundary", () => {
]); ]);
expect(options.emailAndPassword).toEqual({ expect(options.emailAndPassword).toEqual({
autoSignIn: false, autoSignIn: false,
disableSignUp: false, disableSignUp: true,
enabled: true, enabled: true,
maxPasswordLength: 128, maxPasswordLength: 128,
minPasswordLength: 8, minPasswordLength: 8,
@@ -98,13 +140,37 @@ describe("Core identity runtime construction boundary", () => {
expect(options.trustedOrigins).toEqual([...validConfig().trustedOrigins]); expect(options.trustedOrigins).toEqual([...validConfig().trustedOrigins]);
}); });
it("enables sign-up (disableSignUp: false) only when publicSignupEnabled is explicitly true", () => {
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
createIdentityRuntime({ config: validConfig({ publicSignupEnabled: true }), db: fakeDb() });
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
expect(options.emailAndPassword).toMatchObject({ disableSignUp: false });
});
it("explicitly disables Better Auth's default test-environment origin/CSRF skip", () => { it("explicitly disables Better Auth's default test-environment origin/CSRF skip", () => {
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
createIdentityRuntime({ config: validConfig(), db: fakeDb() }); createIdentityRuntime({ config: validConfig(), db: fakeDb() });
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>]; const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
expect(options.advanced).toEqual({ disableCSRFCheck: false, disableOriginCheck: false }); expect(options.advanced).toEqual({
disableCSRFCheck: false,
disableOriginCheck: false,
ipAddress: { ipAddressHeaders: [TRUSTED_CLIENT_ADDRESS_HEADER] },
});
});
it("trusts exactly the one platform-owned client-address header, never Better Auth's own default", () => {
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
createIdentityRuntime({ config: validConfig(), db: fakeDb() });
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
const advanced = options.advanced as { ipAddress?: { ipAddressHeaders?: string[] } };
expect(advanced.ipAddress?.ipAddressHeaders).toEqual([TRUSTED_CLIENT_ADDRESS_HEADER]);
expect(advanced.ipAddress?.ipAddressHeaders).not.toContain("x-forwarded-for");
}); });
it("configures onAPIError.throw so an unexpected failure escapes Better Auth's own unsanitized fallback logger instead of being swallowed internally", () => { it("configures onAPIError.throw so an unexpected failure escapes Better Auth's own unsanitized fallback logger instead of being swallowed internally", () => {
@@ -297,65 +363,152 @@ describe("Core identity runtime — resolvePrincipal", () => {
drizzleAdapterMock.mockClear(); drizzleAdapterMock.mockClear();
}); });
it("maps a valid session to an authenticated principal containing only the opaque user id", async () => { /** The configured production (https) session-cookie name, derived by Better Auth's own
* `getCookies` helper from the exact same `baseUrl` this suite's `validConfig` uses. */
const productionSessionCookie = "__Secure-better-auth.session_token";
it("maps a valid session to an authenticated principal containing only the opaque user id, without any follow-up probe", async () => {
const getSession = vi.fn().mockResolvedValue({ const getSession = vi.fn().mockResolvedValue({
session: { id: "sess_1", token: "tok" }, session: { id: "sess_1", token: "tok" },
user: { email: "[email protected]", id: "user_123", name: "Person" }, user: { email: "[email protected]", id: "user_123", name: "Person" },
}); });
betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() }); betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() });
const db = fakeDb();
const runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb() }); const runtime = createIdentityRuntime({ config: validConfig(), db });
const principal = await runtime.resolvePrincipal( const principal = await runtime.resolvePrincipal(
new Headers({ cookie: "better-auth.session_token=tok" }), new Headers({ cookie: `${productionSessionCookie}=tok` }),
); );
expect(principal).toEqual({ kind: "authenticated", userId: "user_123" }); expect(principal).toEqual({ kind: "authenticated", userId: "user_123" });
expect(Object.keys(principal)).toEqual(["kind", "userId"]); expect(Object.keys(principal)).toEqual(["kind", "userId"]);
expect(Object.isFrozen(principal)).toBe(true); expect(Object.isFrozen(principal)).toBe(true);
// A resolved session needs no readiness confirmation — the store demonstrably worked.
expect(db.queries).toEqual([]);
}); });
it("maps a missing session with no cookie header to anonymous without probing the database", async () => { it("performs zero identity database work for no cookie, unrelated cookies, or session-cookie lookalikes", async () => {
const getSession = vi.fn().mockResolvedValue(null); const getSession = vi.fn().mockResolvedValue(null);
const execute = vi.fn().mockResolvedValue(undefined);
betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() }); betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() });
const runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb({ execute }) }); for (const cookie of [
const principal = await runtime.resolvePrincipal(new Headers()); undefined,
"x=y",
expect(principal).toEqual({ kind: "anonymous" }); "a=1; b=2; c=3",
expect(execute).not.toHaveBeenCalled(); // Lookalikes: a prefix, a suffix, a differently-cased spelling, a percent-encoded name, and
}); // the unprefixed development name (wrong for this https configuration).
`${productionSessionCookie}_extra=v`,
it("maps an expired/revoked/malformed session (cookie present, null session) to anonymous when the database is reachable", async () => { `extra_${productionSessionCookie}=v`,
const getSession = vi.fn().mockResolvedValue(null); `${productionSessionCookie.toUpperCase()}=v`,
const execute = vi.fn().mockResolvedValue(undefined); "__Secure-better-auth%2Esession_token=v",
betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() }); "better-auth.session_token=v",
]) {
const runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb({ execute }) }); const db = fakeDb();
const runtime = createIdentityRuntime({ config: validConfig(), db });
const principal = await runtime.resolvePrincipal( const principal = await runtime.resolvePrincipal(
new Headers({ cookie: "better-auth.session_token=stale-or-garbage" }), cookie === undefined ? new Headers() : new Headers({ cookie }),
); );
expect(principal).toEqual({ kind: "anonymous" }); expect(principal).toEqual({ kind: "anonymous" });
expect(execute).toHaveBeenCalledTimes(1); expect(db.queries).toEqual([]);
}
}); });
it("never downgrades an infrastructure failure to anonymous: throws when the database is unreachable", async () => { it("reads the real session relation and its expected columns only when the configured session cookie was presented", async () => {
const getSession = vi.fn().mockResolvedValue(null);
betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() });
const db = fakeDb();
const runtime = createIdentityRuntime({ config: validConfig(), db });
const principal = await runtime.resolvePrincipal(
new Headers({ cookie: `${productionSessionCookie}=stale-or-garbage` }),
);
// A healthy session store plus an unresolvable token is a genuinely anonymous caller.
expect(principal).toEqual({ kind: "anonymous" });
// The readiness check is a real read of the session relation's expected columns — never
// `select 1`, and never a generic pool ping.
expect(db.queries).toEqual(["select:expiresAt,id,token,userId"]);
});
it("detects the session cookie among several cookies and with irregular whitespace", async () => {
const getSession = vi.fn().mockResolvedValue(null); const getSession = vi.fn().mockResolvedValue(null);
const execute = vi.fn().mockRejectedValue(new Error("connection terminated unexpectedly"));
betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() }); betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() });
const runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb({ execute }) }); for (const cookie of [
`a=1; ${productionSessionCookie}=tok; b=2`,
` ${productionSessionCookie}=tok `,
`a=1;${productionSessionCookie}=tok`,
// A duplicated session cookie is ambiguous and must fail safe *towards* checking the store.
`${productionSessionCookie}=one; ${productionSessionCookie}=two`,
// A valueless session cookie still means Better Auth saw the name.
`${productionSessionCookie}`,
]) {
const db = fakeDb();
const runtime = createIdentityRuntime({ config: validConfig(), db });
await runtime.resolvePrincipal(new Headers({ cookie }));
expect(db.queries).toEqual(["select:expiresAt,id,token,userId"]);
}
});
it("uses the unprefixed development cookie name when the configured base URL is http", async () => {
const getSession = vi.fn().mockResolvedValue(null);
betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() });
const developmentConfig = validConfig({
baseUrl: "http://localhost:3000",
trustedOrigins: Object.freeze(["http://localhost:3000"]),
});
const unprefixed = fakeDb();
await createIdentityRuntime({ config: developmentConfig, db: unprefixed }).resolvePrincipal(
new Headers({ cookie: "better-auth.session_token=tok" }),
);
expect(unprefixed.queries).toEqual(["select:expiresAt,id,token,userId"]);
// The production `__Secure-` name is *not* what an http deployment reads.
const prefixed = fakeDb();
await createIdentityRuntime({ config: developmentConfig, db: prefixed }).resolvePrincipal(
new Headers({ cookie: `${productionSessionCookie}=tok` }),
);
expect(prefixed.queries).toEqual([]);
});
it("never downgrades a broken session store to anonymous, and leaks no database detail", async () => {
const getSession = vi.fn().mockResolvedValue(null);
betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() });
// Every distinguishable session-store failure reaches this seam as a rejected read: a missing
// relation, a dropped column, a denied privilege, a dead connection, and a timeout alike.
const hostileFailures = [
new Error('relation "ekklesia_core_session" does not exist'),
new Error('column "token" does not exist'),
new Error("permission denied for table ekklesia_core_session at 10.1.2.3:5432"),
new Error("connection terminated unexpectedly"),
new Error("timed out"),
];
for (const sessionProbeError of hostileFailures) {
const runtime = createIdentityRuntime({
config: validConfig(),
db: fakeDb({ sessionProbeError }),
});
let captured: unknown; let captured: unknown;
try { try {
await runtime.resolvePrincipal(new Headers({ cookie: "better-auth.session_token=x" })); await runtime.resolvePrincipal(new Headers({ cookie: `${productionSessionCookie}=x` }));
} catch (error) { } catch (error) {
captured = error; captured = error;
} }
expect(captured).toBeInstanceOf(IdentityRuntimeError); expect(captured).toBeInstanceOf(IdentityRuntimeError);
expect(captured).toMatchObject({ code: "PRINCIPAL_RESOLUTION_FAILED" }); expect(captured).toMatchObject({ code: "PRINCIPAL_RESOLUTION_FAILED" });
const publicText = `${String(captured)}\n${
captured instanceof Error ? (captured.stack ?? "") : ""
}\n${JSON.stringify(captured)}`;
expect(publicText).not.toContain("ekklesia_core_session");
expect(publicText).not.toContain("permission denied");
expect(publicText).not.toContain("10.1.2.3");
expect(publicText).not.toContain("does not exist");
}
}); });
it("treats an unexpected throw from getSession itself as a sanitized infrastructure failure", async () => { it("treats an unexpected throw from getSession itself as a sanitized infrastructure failure", async () => {
+45 -6
View File
@@ -200,7 +200,7 @@ class FakeMigrationDatabaseSession implements MigrationDatabaseSession {
} }
stagedTableExists = true; stagedTableExists = true;
}, },
executeStatement: async (sql) => { executeMigrationSql: async (sql) => {
this.#database.events.push("sql:execute"); this.#database.events.push("sql:execute");
this.#database.executedStatements.push(sql); this.#database.executedStatements.push(sql);
const statementIndex = this.#statementCount; const statementIndex = this.#statementCount;
@@ -655,7 +655,7 @@ describe("module migration apply", () => {
); );
}); });
it("preserves exact Drizzle breakpoints without naively splitting semicolons", async () => { it("sends each migration's complete SQL to PostgreSQL in exactly one call, byte for byte", async () => {
const sql = const sql =
'CREATE TABLE "people_records" ("id" integer);\n--> statement-breakpoint\nALTER TABLE "people_records" ADD COLUMN "name" text; SELECT 2;'; 'CREATE TABLE "people_records" ("id" integer);\n--> statement-breakpoint\nALTER TABLE "people_records" ADD COLUMN "name" text; SELECT 2;';
const initial = migration("people", 0, { sql }); const initial = migration("people", 0, { sql });
@@ -666,15 +666,54 @@ describe("module migration apply", () => {
database, database,
}); });
expect(database.executedStatements).toEqual([ // One call, unmodified: the runner never splits, trims, normalizes, or rewrites migration SQL.
'CREATE TABLE "people_records" ("id" integer);\n', // PostgreSQL parses the batch, and Drizzle's marker lines are ordinary `--` comments to it.
'\nALTER TABLE "people_records" ADD COLUMN "name" text; SELECT 2;', expect(database.executedStatements).toEqual([sql]);
]);
expect(database.events.indexOf(`history:insert:${initial.identity}`)).toBeGreaterThan( expect(database.events.indexOf(`history:insert:${initial.identity}`)).toBeGreaterThan(
database.events.lastIndexOf("sql:execute"), database.events.lastIndexOf("sql:execute"),
); );
}); });
it.each([
[
"a string literal containing the marker bytes",
`INSERT INTO "notes" ("body") VALUES ('keep this: --> statement-breakpoint intact');`,
],
[
"a dollar-quoted function body containing the marker and semicolons",
`CREATE FUNCTION touch() RETURNS void AS $$\nBEGIN\n -- --> statement-breakpoint\n PERFORM 1;\n PERFORM 2;\nEND;\n$$ LANGUAGE plpgsql;`,
],
["a line comment containing the marker", `-- note: --> statement-breakpoint\nSELECT 1;`],
["a block comment containing the marker", `/* --> statement-breakpoint */ SELECT 1;`],
[
"a quoted identifier containing the marker",
`CREATE TABLE "odd--> statement-breakpoint name" ("id" integer);`,
],
])("never fragments %s", async (_label, sql) => {
const initial = migration("people", 0, { sql });
const database = new FakeMigrationDatabase();
await applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [initial] }),
database,
});
expect(database.executedStatements).toEqual([sql]);
});
it("sends several generated migrations as one call each, in order", async () => {
const first = migration("people", 0, { sql: `SELECT 1;\n--> statement-breakpoint\nSELECT 2;` });
const second = migration("people", 1, { sql: `SELECT 3;` });
const database = new FakeMigrationDatabase();
await applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [first, second] }),
database,
});
expect(database.executedStatements).toEqual([first.sql, second.sql]);
});
it("rolls back the whole pending batch on a later SQL failure and preserves prior history", async () => { it("rolls back the whole pending batch on a later SQL failure and preserves prior history", async () => {
const prior = migration("people", 0); const prior = migration("people", 0);
const pendingFirst = migration("people", 1); const pendingFirst = migration("people", 1);
+376 -20
View File
@@ -369,10 +369,13 @@ describe("module runtime", () => {
const error = await captureRuntimeError(modulesDirectory); const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toEqual(["MODULE_START_FAILED", "MODULE_ROLLBACK_FAILED"]); expect(runtimeIssueCodes(error)).toEqual(["MODULE_START_FAILED", "MODULE_ROLLBACK_FAILED"]);
// The module whose `start` threw is rolled back *first*, then the previously started modules in
// reverse order — its own hook may have partial initialization to release before it threw.
expect(recorder.events).toEqual([ expect(recorder.events).toEqual([
"one:start", "one:start",
"two:start", "two:start",
"three:start", "three:start",
"three:stop",
"two:stop", "two:stop",
"one:stop", "one:stop",
]); ]);
@@ -444,6 +447,7 @@ describe("module HTTP routes", () => {
method: "GET", method: "GET",
path: "/items", path: "/items",
permission: "single.items.read", permission: "single.items.read",
maxBodyBytes: 1024,
handler: ${noopHandlerSource()}, handler: ${noopHandlerSource()},
}); });
} }
@@ -473,8 +477,8 @@ describe("module HTTP routes", () => {
serverSource: `export default { serverSource: `export default {
id: "alpha", version: "0.1.0", id: "alpha", version: "0.1.0",
register({ http }) { register({ http }) {
http.route({ method: "GET", path: "/first", permission: "alpha.first.read", handler: ${noopHandlerSource()} }); http.route({ method: "GET", path: "/first", permission: "alpha.first.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
http.route({ method: "GET", path: "/second", permission: "alpha.second.read", handler: ${noopHandlerSource()} }); http.route({ method: "GET", path: "/second", permission: "alpha.second.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
} }
};`, };`,
}); });
@@ -485,7 +489,7 @@ describe("module HTTP routes", () => {
serverSource: `export default { serverSource: `export default {
id: "beta", version: "0.1.0", id: "beta", version: "0.1.0",
register({ http }) { register({ http }) {
http.route({ method: "GET", path: "/third", permission: "beta.third.read", handler: ${noopHandlerSource()} }); http.route({ method: "GET", path: "/third", permission: "beta.third.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
} }
};`, };`,
}); });
@@ -509,8 +513,8 @@ describe("module HTTP routes", () => {
serverSource: `export default { serverSource: `export default {
id: "same-path", version: "0.1.0", id: "same-path", version: "0.1.0",
register({ http }) { register({ http }) {
http.route({ method: "GET", path: "/items", permission: "same-path.items.read", handler: ${noopHandlerSource()} }); http.route({ method: "GET", path: "/items", permission: "same-path.items.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
http.route({ method: "POST", path: "/items", permission: "same-path.items.write", handler: ${noopHandlerSource()} }); http.route({ method: "POST", path: "/items", permission: "same-path.items.write", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
} }
};`, };`,
}); });
@@ -532,8 +536,8 @@ describe("module HTTP routes", () => {
serverSource: `export default { serverSource: `export default {
id: "dup", version: "0.1.0", id: "dup", version: "0.1.0",
register({ http }) { register({ http }) {
http.route({ method: "GET", path: "/items", permission: "dup.items.read", handler: ${noopHandlerSource()} }); http.route({ method: "GET", path: "/items", permission: "dup.items.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
http.route({ method: "GET", path: "/items", permission: "dup.items.read", handler: ${noopHandlerSource()} }); http.route({ method: "GET", path: "/items", permission: "dup.items.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
} }
};`, };`,
}); });
@@ -551,8 +555,8 @@ describe("module HTTP routes", () => {
serverSource: `export default { serverSource: `export default {
id: "param-shape", version: "0.1.0", id: "param-shape", version: "0.1.0",
register({ http }) { register({ http }) {
http.route({ method: "GET", path: "/events/:eventId", permission: "param-shape.events.read", handler: ${noopHandlerSource()} }); http.route({ method: "GET", path: "/events/:eventId", permission: "param-shape.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
http.route({ method: "GET", path: "/events/:id", permission: "param-shape.events.read", handler: ${noopHandlerSource()} }); http.route({ method: "GET", path: "/events/:id", permission: "param-shape.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
} }
};`, };`,
}); });
@@ -561,6 +565,141 @@ describe("module HTTP routes", () => {
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_DUPLICATE"]); expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_DUPLICATE"]);
}); });
it.each([
["parameter registered first", "/events/:id", "/events/new"],
["static registered first", "/events/new", "/events/:id"],
["multi-position, parameter first", "/events/:id/edit", "/events/new/:action"],
["multi-position, static first", "/events/new/:action", "/events/:id/edit"],
["parameters on both sides", "/a/:left/c", "/a/b/:right"],
])("rejects routes that can both match one request path (%s) regardless of registration order", async (_label, firstPath, secondPath) => {
const modulesDirectory = await createModulesDirectory();
// Deliberately *different* permissions: the defect this rejects is that whichever route
// registered first would decide which permission governs the ambiguous path.
await createRuntimeModule(modulesDirectory, "overlap", {
manifest: manifestFor("overlap", {
permissions: [
permissionDeclaration("overlap.events.read"),
permissionDeclaration("overlap.events.create"),
],
}),
serverSource: `export default {
id: "overlap", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "${firstPath}", permission: "overlap.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
http.route({ method: "GET", path: "${secondPath}", permission: "overlap.events.create", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
}
};`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_OVERLAP"]);
});
it.each([
["two different static literals", "/events/new", "/events/archive"],
["different segment counts", "/events/:id", "/events/:id/edit"],
["a different static prefix", "/events/:id", "/people/:id"],
["module root against a one-segment path", "/", "/events"],
])("still allows disjoint routes (%s) and preserves their registration order", async (_label, firstPath, secondPath) => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "disjoint", {
manifest: manifestFor("disjoint", {
permissions: [permissionDeclaration("disjoint.events.read")],
}),
serverSource: `export default {
id: "disjoint", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "${firstPath}", permission: "disjoint.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
http.route({ method: "GET", path: "${secondPath}", permission: "disjoint.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
}
};`,
});
const runtime = await startModuleRuntime({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
services: fakeModuleRuntimeServices(),
});
expect(runtime.routes.map((route) => route.path)).toEqual([firstPath, secondPath]);
await runtime.stop();
});
it("allows the same overlapping local path under different methods", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "methods", {
manifest: manifestFor("methods", {
permissions: [permissionDeclaration("methods.events.read")],
}),
serverSource: `export default {
id: "methods", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/events/:id", permission: "methods.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
http.route({ method: "POST", path: "/events/new", permission: "methods.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
}
};`,
});
const runtime = await startModuleRuntime({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
services: fakeModuleRuntimeServices(),
});
expect(runtime.routes).toHaveLength(2);
await runtime.stop();
});
it("allows two different modules to declare the same overlapping local paths", async () => {
const modulesDirectory = await createModulesDirectory();
for (const moduleId of ["alpha", "beta"]) {
await createRuntimeModule(modulesDirectory, moduleId, {
manifest: manifestFor(moduleId, {
permissions: [permissionDeclaration(`${moduleId}.events.read`)],
}),
serverSource: `export default {
id: "${moduleId}", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/events/:id", permission: "${moduleId}.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
}
};`,
});
}
// Each module has its own platform-owned mount prefix, so these can never collide.
const runtime = await startModuleRuntime({
moduleSdkVersion: "0.1.0",
modulesDirectory,
platformVersion: "0.1.0",
services: fakeModuleRuntimeServices(),
});
expect(runtime.routes.map((route) => route.moduleId).sort()).toEqual(["alpha", "beta"]);
await runtime.stop();
});
it("publishes no route from a module whose registration was rejected for an overlap", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "partial", {
manifest: manifestFor("partial", {
permissions: [permissionDeclaration("partial.events.read")],
}),
serverSource: `export default {
id: "partial", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/healthy", permission: "partial.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
http.route({ method: "GET", path: "/events/:id", permission: "partial.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
http.route({ method: "GET", path: "/events/new", permission: "partial.events.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
},
start() { throw new Error("start must never run for a rejected registration"); }
};`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toEqual(["MODULE_ROUTE_OVERLAP"]);
// The earlier, perfectly valid `/healthy` route is not published either: registration is
// all-or-nothing, and the start hook above would have thrown had it ever run.
});
it("fails a malformed route with its specific structured issue code", async () => { it("fails a malformed route with its specific structured issue code", async () => {
const badMethodDirectory = await createModulesDirectory(); const badMethodDirectory = await createModulesDirectory();
await createRuntimeModule(badMethodDirectory, "bad-method", { await createRuntimeModule(badMethodDirectory, "bad-method", {
@@ -570,7 +709,7 @@ describe("module HTTP routes", () => {
serverSource: `export default { serverSource: `export default {
id: "bad-method", version: "0.1.0", id: "bad-method", version: "0.1.0",
register({ http }) { register({ http }) {
http.route({ method: "get", path: "/items", permission: "bad-method.items.read", handler: ${noopHandlerSource()} }); http.route({ method: "get", path: "/items", permission: "bad-method.items.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
} }
};`, };`,
}); });
@@ -586,7 +725,7 @@ describe("module HTTP routes", () => {
serverSource: `export default { serverSource: `export default {
id: "bad-path", version: "0.1.0", id: "bad-path", version: "0.1.0",
register({ http }) { register({ http }) {
http.route({ method: "GET", path: "items", permission: "bad-path.items.read", handler: ${noopHandlerSource()} }); http.route({ method: "GET", path: "items", permission: "bad-path.items.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
} }
};`, };`,
}); });
@@ -594,6 +733,22 @@ describe("module HTTP routes", () => {
"MODULE_ROUTE_PATH_INVALID", "MODULE_ROUTE_PATH_INVALID",
]); ]);
const badMaxBodyBytesDirectory = await createModulesDirectory();
await createRuntimeModule(badMaxBodyBytesDirectory, "bad-max-body-bytes", {
manifest: manifestFor("bad-max-body-bytes", {
permissions: [permissionDeclaration("bad-max-body-bytes.items.read")],
}),
serverSource: `export default {
id: "bad-max-body-bytes", version: "0.1.0",
register({ http }) {
http.route({ method: "GET", path: "/items", permission: "bad-max-body-bytes.items.read", maxBodyBytes: -1, handler: ${noopHandlerSource()} });
}
};`,
});
expect(runtimeIssueCodes(await captureRuntimeError(badMaxBodyBytesDirectory))).toEqual([
"MODULE_ROUTE_MAX_BODY_BYTES_INVALID",
]);
const badDefinitionDirectory = await createModulesDirectory(); const badDefinitionDirectory = await createModulesDirectory();
await createRuntimeModule(badDefinitionDirectory, "bad-shape", { await createRuntimeModule(badDefinitionDirectory, "bad-shape", {
manifest: manifestFor("bad-shape", { manifest: manifestFor("bad-shape", {
@@ -624,7 +779,7 @@ describe("module HTTP routes", () => {
serverSource: `export default { serverSource: `export default {
id: "undeclared", version: "0.1.0", id: "undeclared", version: "0.1.0",
register({ http }) { register({ http }) {
http.route({ method: "GET", path: "/items", permission: "undeclared.items.read", handler: ${noopHandlerSource()} }); http.route({ method: "GET", path: "/items", permission: "undeclared.items.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
} }
};`, };`,
}); });
@@ -644,6 +799,7 @@ describe("module HTTP routes", () => {
method: "GET", method: "GET",
path: "/items", path: "/items",
permission: "core.authorization.grants.manage", permission: "core.authorization.grants.manage",
maxBodyBytes: 1024,
handler: ${noopHandlerSource()}, handler: ${noopHandlerSource()},
}); });
} }
@@ -667,7 +823,7 @@ describe("module HTTP routes", () => {
serverSource: `export default { serverSource: `export default {
id: "beta", version: "0.1.0", id: "beta", version: "0.1.0",
register({ http }) { register({ http }) {
http.route({ method: "GET", path: "/items", permission: "alpha.items.read", handler: ${noopHandlerSource()} }); http.route({ method: "GET", path: "/items", permission: "alpha.items.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
} }
};`, };`,
}); });
@@ -689,9 +845,9 @@ describe("module HTTP routes", () => {
serverSource: `export default { serverSource: `export default {
id: "baselines", version: "0.1.0", id: "baselines", version: "0.1.0",
register({ http }) { register({ http }) {
http.route({ method: "GET", path: "/public", permission: "baselines.public.read", handler: ${noopHandlerSource()} }); http.route({ method: "GET", path: "/public", permission: "baselines.public.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
http.route({ method: "GET", path: "/authenticated", permission: "baselines.authenticated.read", handler: ${noopHandlerSource()} }); http.route({ method: "GET", path: "/authenticated", permission: "baselines.authenticated.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
http.route({ method: "GET", path: "/restricted", permission: "baselines.restricted.read", handler: ${noopHandlerSource()} }); http.route({ method: "GET", path: "/restricted", permission: "baselines.restricted.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
} }
};`, };`,
}); });
@@ -701,6 +857,7 @@ describe("module HTTP routes", () => {
for (const route of runtime.routes) { for (const route of runtime.routes) {
expect(Object.keys(route).sort()).toEqual([ expect(Object.keys(route).sort()).toEqual([
"handler", "handler",
"maxBodyBytes",
"method", "method",
"moduleId", "moduleId",
"path", "path",
@@ -724,6 +881,7 @@ describe("module HTTP routes", () => {
method: "GET", method: "GET",
path: "/items", path: "/items",
permission: "never-invoked.items.read", permission: "never-invoked.items.read",
maxBodyBytes: 1024,
handler: () => { ${recorder.expression}.push("handler:called"); return new Response(null, { status: 204 }); }, handler: () => { ${recorder.expression}.push("handler:called"); return new Response(null, { status: 204 }); },
}); });
} }
@@ -745,7 +903,7 @@ describe("module HTTP routes", () => {
serverSource: `export default { serverSource: `export default {
id: "frozen", version: "0.1.0", id: "frozen", version: "0.1.0",
register({ http }) { register({ http }) {
http.route({ method: "GET", path: "/items", permission: "frozen.items.read", handler: ${noopHandlerSource()} }); http.route({ method: "GET", path: "/items", permission: "frozen.items.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
} }
};`, };`,
}); });
@@ -772,6 +930,7 @@ describe("module HTTP routes", () => {
method: "GET", method: "GET",
path: "/items", path: "/items",
permission: "mutate.items.read", permission: "mutate.items.read",
maxBodyBytes: 1024,
handler: ${noopHandlerSource()}, handler: ${noopHandlerSource()},
}; };
http.route(definition); http.route(definition);
@@ -800,7 +959,7 @@ describe("module HTTP routes", () => {
serverSource: `export default { serverSource: `export default {
id: "first-ok", version: "0.1.0", id: "first-ok", version: "0.1.0",
register({ http }) { register({ http }) {
http.route({ method: "GET", path: "/items", permission: "first-ok.items.read", handler: ${noopHandlerSource()} }); http.route({ method: "GET", path: "/items", permission: "first-ok.items.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
} }
};`, };`,
}); });
@@ -809,7 +968,7 @@ describe("module HTTP routes", () => {
serverSource: `export default { serverSource: `export default {
id: "second-bad", version: "0.1.0", id: "second-bad", version: "0.1.0",
register({ http }) { register({ http }) {
http.route({ method: "GET", path: "/items", permission: "second-bad.items.read", handler: ${noopHandlerSource()} }); http.route({ method: "GET", path: "/items", permission: "second-bad.items.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
} }
};`, };`,
}); });
@@ -829,7 +988,7 @@ describe("module HTTP routes", () => {
serverSource: `export default { serverSource: `export default {
id: "routed", version: "0.1.0", id: "routed", version: "0.1.0",
register({ http }) { register({ http }) {
http.route({ method: "GET", path: "/items", permission: "routed.items.read", handler: ${noopHandlerSource()} }); http.route({ method: "GET", path: "/items", permission: "routed.items.read", maxBodyBytes: 1024, handler: ${noopHandlerSource()} });
}, },
start() { ${recorder.expression}.push("routed:start"); }, start() { ${recorder.expression}.push("routed:start"); },
stop() { ${recorder.expression}.push("routed:stop"); } stop() { ${recorder.expression}.push("routed:stop"); }
@@ -995,3 +1154,200 @@ describe("module runtime services", () => {
expect(vi.mocked(services.database.transaction)).not.toHaveBeenCalled(); expect(vi.mocked(services.database.transaction)).not.toHaveBeenCalled();
}); });
}); });
/**
* Pass 4: a module whose `start` hook fails owns cleanup of whatever it managed to acquire, and
* every stop/rollback hook runs under a finite platform deadline.
*/
describe("module startup rollback and bounded stop hooks", () => {
it("releases a partially initialized resource through the failing module's own stop hook", async () => {
const modulesDirectory = await createModulesDirectory();
const recorder = createEventRecorder();
await createRuntimeModule(modulesDirectory, "partial", {
serverSource: `let acquired = false;
export default {
id: "partial", version: "0.1.0",
start() {
acquired = true;
${recorder.expression}.push("partial:acquired");
throw new Error("secret-token at /private/path");
},
stop() {
if (acquired) { acquired = false; ${recorder.expression}.push("partial:released"); }
}
};`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toEqual(["MODULE_START_FAILED"]);
expect(recorder.events).toEqual(["partial:acquired", "partial:released"]);
expect(JSON.stringify(error.issues)).not.toContain("secret-token");
expect(JSON.stringify(error.issues)).not.toContain("/private/path");
});
it("reports the failing module's own rollback failure and still rolls back earlier modules", async () => {
const modulesDirectory = await createModulesDirectory();
const recorder = createEventRecorder();
await createRuntimeModule(modulesDirectory, "earlier", {
serverSource: `export default {
id: "earlier", version: "0.1.0",
stop() { ${recorder.expression}.push("earlier:stop"); }
};`,
});
await createRuntimeModule(modulesDirectory, "failing", {
manifest: manifestFor("failing", {
dependencies: { modules: { required: { earlier: "^0.1.0" } } },
}),
serverSource: `export default {
id: "failing", version: "0.1.0",
start() { throw new Error("start blew up"); },
stop() { ${recorder.expression}.push("failing:stop"); throw new Error("rollback blew up"); }
};`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(runtimeIssueCodes(error)).toEqual(["MODULE_START_FAILED", "MODULE_ROLLBACK_FAILED"]);
expect(recorder.events).toEqual(["failing:stop", "earlier:stop"]);
});
it("gives a ModuleRuntimeSignal thrown during start the same rollback treatment", async () => {
const modulesDirectory = await createModulesDirectory();
const recorder = createEventRecorder();
// `capabilities.require` for a capability this module never declared throws a
// `ModuleRuntimeSignal` from inside `start`, rather than an ordinary Error.
await createRuntimeModule(modulesDirectory, "signal", {
serverSource: `export default {
id: "signal", version: "0.1.0",
start({ capabilities }) { capabilities.require("undeclared.thing"); },
stop() { ${recorder.expression}.push("signal:stop"); }
};`,
});
const error = await captureRuntimeError(modulesDirectory);
expect(error.issues[0]?.phase).toBe("start");
expect(recorder.events).toEqual(["signal:stop"]);
});
it("invokes each stop hook at most once across rollback", async () => {
const modulesDirectory = await createModulesDirectory();
const recorder = createEventRecorder();
await createRuntimeModule(modulesDirectory, "counted", {
serverSource: `export default {
id: "counted", version: "0.1.0",
start() { throw new Error("nope"); },
stop() { ${recorder.expression}.push("counted:stop"); }
};`,
});
await captureRuntimeError(modulesDirectory);
expect(recorder.events.filter((event) => event === "counted:stop")).toHaveLength(1);
});
it("bounds a never-settling rollback hook and continues with the earlier module", async () => {
const modulesDirectory = await createModulesDirectory();
const recorder = createEventRecorder();
await createRuntimeModule(modulesDirectory, "earlier", {
serverSource: `export default {
id: "earlier", version: "0.1.0",
stop() { ${recorder.expression}.push("earlier:stop"); }
};`,
});
await createRuntimeModule(modulesDirectory, "hanging", {
manifest: manifestFor("hanging", {
dependencies: { modules: { required: { earlier: "^0.1.0" } } },
}),
serverSource: `export default {
id: "hanging", version: "0.1.0",
start() { throw new Error("nope"); },
stop() { ${recorder.expression}.push("hanging:stop"); return new Promise(() => {}); }
};`,
});
// A short test-only deadline; production uses MODULE_STOP_HOOK_TIMEOUT_MS unchanged.
let captured: ModuleRuntimeError | undefined;
try {
await startModuleRuntime({ ...runtimeOptions(modulesDirectory), hookTimeoutMs: 25 });
} catch (error) {
captured = error as ModuleRuntimeError;
}
expect(captured).toBeInstanceOf(ModuleRuntimeError);
expect(runtimeIssueCodes(captured as ModuleRuntimeError)).toEqual([
"MODULE_START_FAILED",
"MODULE_ROLLBACK_TIMEOUT",
]);
// The timed-out hook did not block the earlier module's rollback.
expect(recorder.events).toEqual(["hanging:stop", "earlier:stop"]);
});
it("bounds a never-settling normal stop hook and continues with the earlier module", async () => {
const modulesDirectory = await createModulesDirectory();
const recorder = createEventRecorder();
await createRuntimeModule(modulesDirectory, "earlier", {
serverSource: `export default {
id: "earlier", version: "0.1.0",
stop() { ${recorder.expression}.push("earlier:stop"); }
};`,
});
await createRuntimeModule(modulesDirectory, "hanging", {
manifest: manifestFor("hanging", {
dependencies: { modules: { required: { earlier: "^0.1.0" } } },
}),
serverSource: `export default {
id: "hanging", version: "0.1.0",
stop() { ${recorder.expression}.push("hanging:stop"); return new Promise(() => {}); }
};`,
});
const runtime = await startModuleRuntime({
...runtimeOptions(modulesDirectory),
hookTimeoutMs: 25,
});
let captured: ModuleRuntimeError | undefined;
try {
await runtime.stop();
} catch (error) {
captured = error as ModuleRuntimeError;
}
expect(runtimeIssueCodes(captured as ModuleRuntimeError)).toEqual(["MODULE_STOP_TIMEOUT"]);
expect(recorder.events).toEqual(["hanging:stop", "earlier:stop"]);
// Repeated and concurrent callers share the memoized promise and never restart cleanup.
const [first, second] = await Promise.allSettled([runtime.stop(), runtime.stop()]);
expect(first.status).toBe("rejected");
expect(second.status).toBe("rejected");
expect(recorder.events.filter((event) => event === "hanging:stop")).toHaveLength(1);
});
it("never lets a hook that rejects after its deadline become an unhandled rejection", async () => {
const modulesDirectory = await createModulesDirectory();
await createRuntimeModule(modulesDirectory, "late", {
serverSource: `export default {
id: "late", version: "0.1.0",
stop() { return new Promise((_resolve, reject) => { setTimeout(() => reject(new Error("late")), 40); }); }
};`,
});
const unhandled: unknown[] = [];
const onUnhandled = (reason: unknown): void => {
unhandled.push(reason);
};
process.on("unhandledRejection", onUnhandled);
try {
const runtime = await startModuleRuntime({
...runtimeOptions(modulesDirectory),
hookTimeoutMs: 10,
});
await expect(runtime.stop()).rejects.toBeInstanceOf(ModuleRuntimeError);
// Give the late rejection time to fire, then confirm nothing escaped.
await new Promise((resolve) => setTimeout(resolve, 80));
expect(unhandled).toEqual([]);
} finally {
process.off("unhandledRejection", onUnhandled);
}
});
});
@@ -6,6 +6,8 @@ vi.mock("pg", () => ({ Client: clientConstructor }));
import { import {
createPostgresMigrationDatabase, createPostgresMigrationDatabase,
MIGRATION_CONNECTION_TIMEOUT_MS,
MIGRATION_OPERATION_TIMEOUT_MS,
PostgresMigrationDatabaseError, PostgresMigrationDatabaseError,
} from "../src/migrations/postgres-database.js"; } from "../src/migrations/postgres-database.js";
@@ -110,3 +112,187 @@ describe("PostgreSQL migration database composition", () => {
expect(client.end).toHaveBeenCalledOnce(); expect(client.end).toHaveBeenCalledOnce();
}); });
}); });
/**
* Pass 4: finite migration timeouts live at this one construction boundary, and the pinned driver's
* connection-string precedence cannot weaken them.
*/
describe("PostgreSQL migration timeout policy", () => {
function mockClient() {
const client = {
connect: vi.fn().mockResolvedValue(undefined),
end: vi.fn().mockResolvedValue(undefined),
on: vi.fn(),
};
clientConstructor.mockImplementation(
class {
readonly connect = client.connect;
readonly end = client.end;
readonly on = client.on;
} as unknown as () => typeof client,
);
return client;
}
it("applies the fixed 10-second defaults when a caller omits them", async () => {
mockClient();
const database = createPostgresMigrationDatabase("postgresql://example.test/ekklesia");
const session = await database.openSession();
// This is exactly what `migrations:plan` and `migrations:apply` construct: no timeout options.
expect(clientConstructor).toHaveBeenCalledWith({
connectionString: "postgresql://example.test/ekklesia",
connectionTimeoutMillis: MIGRATION_CONNECTION_TIMEOUT_MS,
query_timeout: MIGRATION_OPERATION_TIMEOUT_MS,
statement_timeout: MIGRATION_OPERATION_TIMEOUT_MS,
});
expect(MIGRATION_CONNECTION_TIMEOUT_MS).toBe(10_000);
expect(MIGRATION_OPERATION_TIMEOUT_MS).toBe(10_000);
await session.close();
});
it("still honours an explicit narrower test override", async () => {
mockClient();
const database = createPostgresMigrationDatabase("postgresql://example.test/ekklesia", {
connectionTimeoutMs: 250,
operationTimeoutMs: 500,
});
const session = await database.openSession();
expect(clientConstructor).toHaveBeenCalledWith({
connectionString: "postgresql://example.test/ekklesia",
connectionTimeoutMillis: 250,
query_timeout: 500,
statement_timeout: 500,
});
await session.close();
});
it.each([
"postgresql://example.test/ekklesia?statement_timeout=0",
"postgresql://example.test/ekklesia?STATEMENT_TIMEOUT=0",
"postgresql://example.test/ekklesia?query_timeout=0",
"postgresql://example.test/ekklesia?connect_timeout=0",
"postgresql://example.test/ekklesia?options=-c%20statement_timeout%3D0",
"postgresql://example.test/ekklesia?statement_timeout=900000",
])("refuses a connection string that would override the policy (%s)", (databaseUrl) => {
// The pinned [email protected] merges as `Object.assign({}, config, parse(connectionString))`, so the
// URL wins over the explicit values above — verified against the real driver, where an explicit
// `statement_timeout: 10000` alongside `?statement_timeout=0` produced a server-reported `0`.
const constructionsBefore = clientConstructor.mock.calls.length;
let captured: unknown;
try {
createPostgresMigrationDatabase(databaseUrl);
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(PostgresMigrationDatabaseError);
expect(captured).toMatchObject({ code: "POSTGRES_MIGRATION_CONFIGURATION_INVALID" });
// The rejection never echoes the URL, the offending parameter, or its value.
const publicText = `${String(captured)}\n${
captured instanceof Error ? (captured.stack ?? "") : ""
}`;
expect(publicText).not.toContain("example.test");
expect(publicText).not.toContain("statement_timeout=");
expect(publicText).not.toContain("900000");
// Rejected before any client is constructed, so no connection is ever attempted.
expect(clientConstructor.mock.calls.length).toBe(constructionsBefore);
});
it("leaves unrelated connection-string parameters alone", async () => {
mockClient();
const database = createPostgresMigrationDatabase(
"postgresql://example.test/ekklesia?application_name=ekklesia&lock_timeout=5000",
);
const session = await database.openSession();
expect(clientConstructor).toHaveBeenCalledWith(
expect.objectContaining({ statement_timeout: MIGRATION_OPERATION_TIMEOUT_MS }),
);
await session.close();
});
});
/**
* Pass 4 correction: `pg-connection-string` retains *arbitrary* query keys, so the driver's own
* configuration key `connectionTimeoutMillis` reaches `ConnectionParameters` from a URL. It was not
* on the rejected list. These pin the corrected boundary; the measured effect of each key on the
* real driver and a real server lives in `core/tests/postgres-migration.integration.ts`.
*/
describe("PostgreSQL migration timeout precedence", () => {
it.each([
"postgresql://example.test/ekklesia?connectionTimeoutMillis=0",
"postgresql://example.test/ekklesia?connectionTimeoutMillis=900000",
"postgresql://example.test/ekklesia?connectionTimeoutMillis=",
"postgresql://example.test/ekklesia?connectionTimeoutMillis=-1",
"postgresql://example.test/ekklesia?connectionTimeoutMillis=abc",
"postgresql://example.test/ekklesia?CONNECTIONTIMEOUTMILLIS=0",
"postgresql://example.test/ekklesia?connectiontimeoutmillis=0",
])("refuses the driver's own connectionTimeoutMillis key in a URL (%s)", (databaseUrl) => {
const constructionsBefore = clientConstructor.mock.calls.length;
let captured: unknown;
try {
createPostgresMigrationDatabase(databaseUrl);
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(PostgresMigrationDatabaseError);
expect(captured).toMatchObject({ code: "POSTGRES_MIGRATION_CONFIGURATION_INVALID" });
// Rejected before any client exists, so no connection is ever attempted.
expect(clientConstructor.mock.calls.length).toBe(constructionsBefore);
const publicText = `${String(captured)}\n${
captured instanceof Error ? (captured.stack ?? "") : ""
}`;
expect(publicText).not.toContain("example.test");
expect(publicText).not.toContain("connectionTimeoutMillis");
expect(publicText).not.toContain("900000");
});
it("also refuses it alongside a narrower explicit test override", () => {
let captured: unknown;
try {
createPostgresMigrationDatabase(
"postgresql://example.test/ekklesia?connectionTimeoutMillis=0",
{ connectionTimeoutMs: 300, operationTimeoutMs: 300 },
);
} catch (error) {
captured = error;
}
expect(captured).toMatchObject({ code: "POSTGRES_MIGRATION_CONFIGURATION_INVALID" });
});
it("still accepts unrelated parameters and produces the exact expected client configuration", async () => {
const client = {
connect: vi.fn().mockResolvedValue(undefined),
end: vi.fn().mockResolvedValue(undefined),
on: vi.fn(),
};
clientConstructor.mockImplementation(
class {
readonly connect = client.connect;
readonly end = client.end;
readonly on = client.on;
} as unknown as () => typeof client,
);
const url = "postgresql://example.test/ekklesia?application_name=ekklesia&lock_timeout=5000";
const session = await createPostgresMigrationDatabase(url).openSession();
expect(clientConstructor).toHaveBeenLastCalledWith({
connectionString: url,
connectionTimeoutMillis: MIGRATION_CONNECTION_TIMEOUT_MS,
query_timeout: MIGRATION_OPERATION_TIMEOUT_MS,
statement_timeout: MIGRATION_OPERATION_TIMEOUT_MS,
});
await session.close();
});
});
@@ -1,4 +1,5 @@
import { createHash, randomBytes } from "node:crypto"; import { createHash, randomBytes } from "node:crypto";
import net from "node:net";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { Client, type QueryResult, type QueryResultRow } from "pg"; import { Client, type QueryResult, type QueryResultRow } from "pg";
@@ -13,6 +14,7 @@ import {
MigrationRunnerError, MigrationRunnerError,
type MigrationRunnerErrorCode, type MigrationRunnerErrorCode,
planModuleMigrations, planModuleMigrations,
PostgresMigrationDatabaseError,
} from "../src/migrations/index.js"; } from "../src/migrations/index.js";
import type { import type {
DiscoveredModuleMigration, DiscoveredModuleMigration,
@@ -1457,3 +1459,395 @@ describe.sequential("live PostgreSQL Core identity migration execution", () => {
}); });
}); });
}); });
/**
* Pass 4: the migration commands' finite timeout defaults, and whole-batch SQL execution, proven
* against real PostgreSQL and a real TCP endpoint rather than a fake.
*
* Every listener, client, and disposable database here is closed in `finally`/`afterAll`, including
* after a failed assertion.
*/
describe.sequential("live PostgreSQL migration command hardening", () => {
it("bounds a connection to an endpoint that accepts TCP but never completes PostgreSQL startup", async () => {
// A real blackhole listener bound only to loopback: it accepts the socket and then says
// nothing, which is exactly the case `connectionTimeoutMillis` exists for.
const sockets: net.Socket[] = [];
const listener = net.createServer((socket) => {
sockets.push(socket);
});
try {
await new Promise<void>((resolve, reject) => {
listener.once("error", reject);
listener.listen(0, "127.0.0.1", () => resolve());
});
const address = listener.address();
if (!address || typeof address !== "object") {
throw harnessError("The blackhole listener has no bound address.");
}
// A short, test-only override; production keeps MIGRATION_CONNECTION_TIMEOUT_MS unchanged.
const blackholeDatabase = createPostgresMigrationDatabase(
`postgresql://ekklesia:[email protected]:${address.port}/ekklesia`,
{ connectionTimeoutMs: 300, operationTimeoutMs: 300 },
);
const startedAt = Date.now();
let captured: unknown;
try {
await blackholeDatabase.openSession();
} catch (error) {
captured = error;
}
expect(captured).toBeInstanceOf(PostgresMigrationDatabaseError);
expect(captured).toMatchObject({ code: "POSTGRES_MIGRATION_CONNECTION_FAILED" });
// Finite, and nowhere near an unbounded wait.
expect(Date.now() - startedAt).toBeLessThan(10_000);
const publicText = `${String(captured)}\n${(captured as Error).stack ?? ""}`;
expect(publicText).not.toContain("127.0.0.1");
expect(publicText).not.toContain("ekklesia:ekklesia");
expect(publicText).not.toContain(String(address.port));
} finally {
for (const socket of sockets) {
socket.destroy();
}
await new Promise<void>((resolve) => {
listener.close(() => resolve());
});
}
});
it("aborts a migration that exceeds the operation deadline and commits no history row", async () => {
await withIsolatedDatabase("operation_timeout", async (database) => {
const slow = migration("people", 0, {
sql: "SELECT pg_sleep(5);",
tag: "0000_slow",
});
// The operation deadline is the behavior under test, so a bounded server-side delay is the
// only way to exercise it.
const migrationDatabase = createPostgresMigrationDatabase(database.url, {
connectionTimeoutMs: 5_000,
operationTimeoutMs: 250,
});
const error = await captureRunnerError(
applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [slow] }),
database: migrationDatabase,
}),
"MIGRATION_EXECUTION_FAILED",
);
expect(error.migrationIdentity).toBe(slow.identity);
const publicText = `${String(error)}\n${error.stack ?? ""}`;
expect(publicText).not.toContain("pg_sleep");
expect(publicText).not.toContain(database.name);
// Nothing was committed: the transaction rolled back with its history insert.
const history = await safeQuery<{ identity: string }>(
database.client,
`select identity from public.ekklesia_core_migrations`,
).catch(() => undefined);
expect(history?.rows ?? []).toHaveLength(0);
});
});
it("executes marker bytes inside literals, dollar-quoted bodies, and comments intact", async () => {
await withIsolatedDatabase("intact_sql", async (database) => {
const marker = "--> statement-breakpoint";
const sql = [
`CREATE TABLE "people_notes" ("id" integer primary key, "body" text not null);`,
marker,
`INSERT INTO "people_notes" ("id", "body") VALUES (1, 'keep this: ${marker} intact');`,
marker,
`CREATE FUNCTION people_touch() RETURNS integer AS $$`,
`BEGIN`,
` -- ${marker}`,
` PERFORM 1;`,
` RETURN 42;`,
`END;`,
`$$ LANGUAGE plpgsql;`,
marker,
`/* block comment with ${marker} */`,
`CREATE TABLE "odd${marker}name" ("id" integer);`,
].join("\n");
const intact = migration("people", 0, { sql, tag: "0000_intact" });
const result = await applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [intact] }),
database: createPostgresMigrationDatabase(database.url),
});
expect(result.appliedCount).toBe(1);
// The literal survived byte-for-byte, so the marker was never treated as a split point.
const stored = await safeQuery<{ body: string }>(
database.client,
`select body from "people_notes" where id = 1`,
);
expect(stored.rows[0]?.body).toBe(`keep this: ${marker} intact`);
// The dollar-quoted body executed as one function, not as fragments.
const called = await safeQuery<{ result: number }>(
database.client,
`select people_touch() as result`,
);
expect(Number(called.rows[0]?.result)).toBe(42);
// The quoted identifier kept the marker in its name.
const relations = await safeQuery<{ table_name: string }>(
database.client,
`select table_name from information_schema.tables
where table_schema = 'public' and table_name like 'odd%'`,
);
expect(relations.rows.map((row) => row.table_name)).toEqual([`odd${marker}name`]);
});
});
it("rolls back the whole multi-statement migration when a later statement fails", async () => {
await withIsolatedDatabase("intact_rollback", async (database) => {
const failing = migration("people", 0, {
sql: [
`CREATE TABLE "people_partial" ("id" integer primary key);`,
`--> statement-breakpoint`,
`INSERT INTO "people_partial" ("id") VALUES (1);`,
`--> statement-breakpoint`,
`INSERT INTO "people_partial" ("id") VALUES (1);`,
].join("\n"),
tag: "0000_rollback",
});
const error = await captureRunnerError(
applyModuleMigrations({
aggregate: aggregate({ id: "people", migrations: [failing] }),
database: createPostgresMigrationDatabase(database.url),
}),
"MIGRATION_EXECUTION_FAILED",
);
expect(error.migrationIdentity).toBe(failing.identity);
// The earlier statements in the same batch rolled back with it.
const relations = await safeQuery(
database.client,
`select table_name from information_schema.tables
where table_schema = 'public' and table_name = 'people_partial'`,
);
expect(relations.rows).toHaveLength(0);
});
});
});
/**
* Pass 4 correction: what each timeout-shaped connection-string key and inherited environment
* variable *actually* does to the pinned `[email protected]`, measured rather than inferred from its merge.
*
* The distinction matters: the boundary rejects several keys, but only one of them genuinely
* weakens the selected policy. Documenting a key as "overrides the explicit field" when it does not
* is the kind of claim this suite exists to prevent.
*
* Every listener, socket, client, and disposable database is closed in `finally`.
*/
describe.sequential("live PostgreSQL migration timeout precedence", () => {
const productionClientFields = Object.freeze({
connectionTimeoutMillis: 10_000,
query_timeout: 10_000,
statement_timeout: 10_000,
});
it("does not let a URL weaken the effective connect timer, whichever spelling is used", async () => {
const sockets: net.Socket[] = [];
const listener = net.createServer((socket) => {
sockets.push(socket);
});
try {
await new Promise<void>((resolve, reject) => {
listener.once("error", reject);
listener.listen(0, "127.0.0.1", () => resolve());
});
const address = listener.address();
if (!address || typeof address !== "object") {
throw harnessError("The blackhole listener has no bound address.");
}
const base = `postgresql://ekklesia:[email protected]:${address.port}/ekklesia`;
// A short explicit deadline stands in for the production 10 s so the measurement is fast.
for (const query of [
"",
"?connectionTimeoutMillis=0",
"?connectionTimeoutMillis=900000",
"?connect_timeout=0",
]) {
const client = new Client({
connectionString: `${base}${query}`,
connectionTimeoutMillis: 500,
});
client.on("error", () => undefined);
const startedAt = Date.now();
let settled = false;
try {
await client.connect();
} catch {
settled = true;
}
const elapsed = Date.now() - startedAt;
try {
await client.end();
} catch {
// Already unusable.
}
// The pure-JS client reads `connectionTimeoutMillis` from the object handed to its
// constructor, before any connection-string merge; the merged value only reaches
// `ConnectionParameters.connect_timeout`, which solely feeds `getLibpqConnectionString()`.
expect(settled, `expected the connect timer to fire for ${JSON.stringify(query)}`).toBe(
true,
);
expect(elapsed, `unexpected timer for ${JSON.stringify(query)}`).toBeLessThan(3_000);
}
// ...but the driver-visible field really is polluted, which is why the boundary rejects the
// key rather than relying on the pure-JS path staying this way. `connectionParameters` is
// real on the pinned client but absent from its published types, so it is read through a
// narrow structural view rather than asserted away.
const connectTimeoutOf = (connectionString: string): string => {
const client = new Client({ connectionString, ...productionClientFields }) as unknown as {
readonly connectionParameters: { readonly connect_timeout: unknown };
};
(client as unknown as Client).on("error", () => undefined);
return String(client.connectionParameters.connect_timeout);
};
expect(connectTimeoutOf(`${base}?connectionTimeoutMillis=0`)).toBe("0");
expect(connectTimeoutOf(base)).toBe("10");
} finally {
for (const socket of sockets) {
socket.destroy();
}
await new Promise<void>((resolve) => {
listener.close(() => resolve());
});
}
});
it("records what each timeout source does to the real server's statement_timeout", async () => {
await withIsolatedDatabase("timeout_sources", async (database) => {
async function effectiveStatementTimeout(
query: string,
environment: Readonly<Record<string, string>> = {},
): Promise<string> {
const saved = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(environment)) {
saved.set(key, process.env[key]);
process.env[key] = value;
}
const client = new Client({
connectionString: `${database.url}${query}`,
...productionClientFields,
});
client.on("error", () => undefined);
try {
await client.connect();
const result = await client.query<{ statement_timeout: string }>(
"show statement_timeout",
);
return result.rows[0]?.statement_timeout ?? "unknown";
} catch {
return "connection failed";
} finally {
try {
await client.end();
} catch {
// Already unusable.
}
for (const [key, value] of saved) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
}
// The baseline the policy selects.
expect(await effectiveStatementTimeout("")).toBe("10s");
// The one key that genuinely wins, and the reason it must stay rejected.
expect(await effectiveStatementTimeout("?statement_timeout=0")).toBe("0");
expect(await effectiveStatementTimeout("?statement_timeout=900000")).toBe("15min");
// `query_timeout` from the URL *does* override the explicit field, but it does not weaken the
// bound — it breaks the client. `client.query` computes its read timer as
// `queryConfig.query_timeout || connectionParameters.query_timeout`, and the parsed URL value
// is the *string* `"0"`, which is truthy, so every query gets a 0 ms read timer and fails
// nondeterministically ("Query read timeout") depending on which wins the race. The
// deterministic, assertable fact is the parameter the URL installed.
const racyClient = new Client({
connectionString: `${database.url}?query_timeout=0`,
...productionClientFields,
}) as unknown as { readonly connectionParameters: { readonly query_timeout: unknown } };
(racyClient as unknown as Client).on("error", () => undefined);
expect(racyClient.connectionParameters.query_timeout).toBe("0");
// `options` does *not* override the named startup parameter — including a backslash-escaped
// spelling — so rejecting it is unsupported-configuration hygiene, not a precedence fix.
expect(
await effectiveStatementTimeout(`?options=${encodeURIComponent("-c statement_timeout=0")}`),
).toBe("10s");
expect(
await effectiveStatementTimeout(
`?options=${encodeURIComponent("-c statement_timeout=900s")}`,
),
).toBe("10s");
expect(
await effectiveStatementTimeout(
`?options=${encodeURIComponent("-c statement_timeout\\=0")}`,
),
).toBe("10s");
// Inherited environment cannot reach the selected policy either.
expect(await effectiveStatementTimeout("", { PGOPTIONS: "-c statement_timeout=0" })).toBe(
"10s",
);
expect(await effectiveStatementTimeout("", { PGCONNECT_TIMEOUT: "0" })).toBe("10s");
// An unrelated parameter changes nothing.
expect(await effectiveStatementTimeout("?application_name=ekklesia")).toBe("10s");
});
});
it("refuses every rejected key at the shared boundary before a client is constructed", async () => {
await withIsolatedDatabase("timeout_boundary", async (database) => {
for (const query of [
"?connectionTimeoutMillis=0",
"?connectionTimeoutMillis=900000",
"?connect_timeout=0",
"?query_timeout=0",
"?statement_timeout=0",
`?options=${encodeURIComponent("-c statement_timeout=0")}`,
]) {
let captured: unknown;
try {
createPostgresMigrationDatabase(`${database.url}${query}`);
} catch (error) {
captured = error;
}
expect(captured, `expected ${query} rejected`).toBeInstanceOf(
PostgresMigrationDatabaseError,
);
expect(captured).toMatchObject({ code: "POSTGRES_MIGRATION_CONFIGURATION_INVALID" });
const publicText = `${String(captured)}\n${(captured as Error).stack ?? ""}`;
expect(publicText).not.toContain(database.name);
expect(publicText).not.toContain("127.0.0.1");
}
// The clean URL the commands actually use still works, end to end, with the real defaults.
const applied = await applyModuleMigrations({
aggregate: aggregate({
id: "people",
migrations: [migration("people", 0, { sql: "SELECT 1;", tag: "0000_ok" })],
}),
database: createPostgresMigrationDatabase(database.url),
});
expect(applied.appliedCount).toBe(1);
});
});
});
@@ -90,6 +90,16 @@ aggregate order, and its history row is inserted only after that migration succe
failure rolls back every schema change and history insert from the current invocation. Migrations failure rolls back every schema change and history insert from the current invocation. Migrations
committed by earlier invocations remain applied. committed by earlier invocations remain applied.
> **Partial correction — see [ADR 0020](0020-module-runtime-and-migration-execution-hardening.md).**
> Splitting on the literal marker, described in the paragraph immediately below, split a migration
> whose SQL legitimately contained those bytes inside a string literal, dollar-quoted body, quoted
> identifier, or comment. Core now sends each migration's complete SQL to PostgreSQL in one call
> inside the same transaction, so the server does all SQL parsing and the marker lines are ordinary
> `--` comments. Everything else recorded here — one transaction across the pending batch, history
> rows inserted only after their migration succeeds, no semicolon splitting, no parsing,
> classifying, reordering, or repairing SQL, and no non-transactional fallback — is unchanged and
> still accepted. This section is left as originally accepted, as the historical record.
Core matches Drizzle's generated statement-breakpoint behavior: it splits only on the exact Core matches Drizzle's generated statement-breakpoint behavior: it splits only on the exact
literal `--> statement-breakpoint` marker. It never splits SQL on semicolons or tries to parse, literal `--> statement-breakpoint` marker. It never splits SQL on semicolons or tries to parse,
classify, reorder, or repair arbitrary SQL. Transaction-incompatible SQL fails the transaction; classify, reorder, or repair arbitrary SQL. Transaction-incompatible SQL fails the transaction;
@@ -129,6 +129,17 @@ touching grant semantics.
### Platform-owner bootstrap is explicit, CLI-only, globally single-use, permanently consumed even after revocation, concurrency-safe, and audited atomically ### Platform-owner bootstrap is explicit, CLI-only, globally single-use, permanently consumed even after revocation, concurrency-safe, and audited atomically
> **Partial correction — see [ADR 0019](0019-durable-authorization-history-and-grant-management.md).**
> Defining "bootstrap has already been consumed" as *any row ever created in
> `ekklesia_core_platform_owner`*, described immediately below, was not durable: that table's
> `user_id` cascades on user deletion, so deleting the bootstrapped owner's user row removed the
> owner row and reopened the one-time operation. ADR 0019 moves that authority to a dedicated
> `ekklesia_core_platform_owner_bootstrap` singleton with no user foreign key, backfilled for
> existing installations. The intent recorded here — explicit, CLI-only, globally single-use,
> permanently consumed even after revocation, concurrency-safe, and audited atomically — is
> unchanged and still accepted; only the marker it is read from moved. This section is left as
> originally accepted, as the historical record.
There is no signup-order or first-user inference anywhere in this repository. The only way to There is no signup-order or first-user inference anywhere in this repository. The only way to
establish a platform owner is `pnpm authorization:bootstrap-owner -- --user-id <id> establish a platform owner is `pnpm authorization:bootstrap-owner -- --user-id <id>
--confirm-user-id <same-id>` (`core/src/cli/bootstrap-platform-owner.ts`, --confirm-user-id <same-id>` (`core/src/cli/bootstrap-platform-owner.ts`,
@@ -127,7 +127,15 @@ decides this mapping, and a module has no way to choose or influence its own mou
ordering is load-bearing, not cosmetic: Hono's router resolves two routes whose patterns overlap at ordering is load-bearing, not cosmetic: Hono's router resolves two routes whose patterns overlap at
the same depth (a static segment and a same-position `:parameter`) by registration order, not by the same depth (a static segment and a same-position `:parameter`) by registration order, not by
static-path specificity, so preserving the runtime's exact publication order is what lets a module static-path specificity, so preserving the runtime's exact publication order is what lets a module
author's more-specific-first declaration continue to take precedence after mounting. Mounting uses author's more-specific-first declaration continue to take precedence after mounting.
> **Partial correction — see [ADR 0020](0020-module-runtime-and-migration-execution-hardening.md).**
> Relying on registration order to resolve an overlap let the *permission* guarding an ambiguous
> path depend on declaration sequence. Core now rejects any two same-module, same-method routes
> that can both match one request path (`MODULE_ROUTE_OVERLAP`) during staged registration, so no
> such pair ever reaches mounting and no ordering decides which permission applies. Preserving the
> publication order of *disjoint* routes, and everything else in this section, is unchanged and
> still accepted. Mounting uses
one fixed-arity `app.on(method, path, ...middleware, handler)` call per route rather than spreading one fixed-arity `app.on(method, path, ...middleware, handler)` call per route rather than spreading
a variable-length handler array — Hono's overload resolution is arity-sensitive, and a spread call a variable-length handler array — Hono's overload resolution is arity-sensitive, and a spread call
silently resolves to an unrelated, incompatible overload. silently resolves to an unrelated, incompatible overload.
@@ -0,0 +1,254 @@
# 0017: Public request perimeter hardening — client address, signup gating, body limits, timeouts
- Status: Accepted
- Date: 2026-07-26
- Amends: [0011](0011-identity-http-transport-and-public-enrollment.md)
- Extends: [0015](0015-static-module-http-routing-and-runtime-lifecycle.md)
## Context
An independent security audit against `main` (Phase 5C checkpoint, commit `5e093300`) found that
`apps/api`'s public HTTP perimeter had four related gaps before any Surface work or additional
module-platform capability could responsibly proceed:
1. Better Auth's rate limiter resolved client identity from a caller-controlled
`X-Forwarded-For` header by default (ADR 0011 explicitly left trusted-proxy configuration
unset, but the pinned release's own fallback still trusts a *single-value* header with no
proxy policy at all). A caller could rotate the header value on every request to make each
attempt look like a new, unlimited client, or collapse unrelated callers into one bucket when
no header was present.
2. Public registration (ADR 0011) had no verified email, CAPTCHA, invitation, or durable
distributed limiter, and — more immediately — no way to disable it at all. Every deployment
was permanently open to unlimited account creation.
3. Neither Better Auth's endpoints, Core's grant-mutation endpoint, nor any module route enforced
a request-body size limit. `core/src/grants-routes.ts`'s `c.req.json()` and any module
handler reading a body could be driven with an arbitrarily large or indefinitely streamed
payload.
4. The Node HTTP server had no configured `headersTimeout`/`requestTimeout`, leaving Node's own
generous defaults (60s/300s) as the only bound on a slow-header or slow-body connection.
This ADR records the pass-1 fix for all four, as one bounded slice (Phase 5D pass 1). Passes 2–5
(identity/database transport hardening, authorization integrity, module/runtime/migration
correctness, and deployment/supply-chain closure) remain separate, later work.
## Decision
### One platform-owned client-address resolution boundary
`core/src/identity/client-address.ts` defines a framework-agnostic policy —
`ClientAddressConfig` (`{mode: "direct"}` or `{mode: "trusted-proxy", header, trustedProxies}`),
`createClientAddressConfig` (strict validation, safe default), and
`createClientAddressResolver` (the actual per-request resolution algorithm) — built only on
`node:net` and the standard `Headers` type, so it stays importable by Core without pulling Hono
into it. It never reads a request header for "direct" mode: it trusts only the value it is given
as the *actual immediate socket peer* address. For "trusted-proxy" mode, a forwarded header is
consulted **only** when the actual immediate peer matches a configured trusted-proxy
address/CIDR; otherwise the peer address itself is used, exactly as if no header existed. A
forwarded chain (comma-separated) is walked right to left: trailing hops matching a trusted proxy
are skipped, and the first non-matching hop from the right is the resolved address. A missing,
empty, malformed, or entirely-trusted-hops chain from an otherwise-trusted peer fails closed
(`PEER_ADDRESS_UNAVAILABLE` / `FORWARDED_HEADER_MISSING` / `FORWARDED_HEADER_MALFORMED` /
`FORWARDED_CHAIN_EXHAUSTED`) rather than guessing or falling back to a shared bucket.
Node's own runtime cannot be told about the real socket peer from inside Hono — only the raw
`IncomingMessage` (`@hono/node-server`'s `HttpBindings.incoming`) has it. `apps/api/src/
client-address.ts` therefore adds exactly one Hono-adjacent piece, `wrapFetchWithClientAddress`,
which wraps the `fetch` callback passed to `@hono/node-server`'s `serve()` in `apps/api/src/
server.ts`: it resolves the trusted address from `env.incoming.socket.remoteAddress` before the
wrapped callback (and therefore every route, including Better Auth's own handler) ever sees the
request, then unconditionally overwrites — never appends to — any externally supplied copy of a
fixed internal header (`TRUSTED_CLIENT_ADDRESS_HEADER = "x-ekklesia-client-address"`,
`core/src/identity/runtime.ts`) with the resolved value. A resolution failure returns a sanitized
`503 {"code":"CLIENT_ADDRESS_UNRESOLVED"}` directly from this wrapper — the wrapped callback,
Better Auth, Core, and every module handler are all unreachable in that case.
Better Auth is configured with `advanced.ipAddress.ipAddressHeaders: [TRUSTED_CLIENT_ADDRESS_HEADER]`
and no `trustedProxies` of its own: the pinned release's own `trustedProxies` option only inspects
header *content* for proxy-shaped addresses and cannot verify who actually sent the request (its
own doc comment says as much), so the real trust decision is made exactly once, at the transport
boundary, using the genuine socket peer — never asked of Better Auth a second time. Core never
reinterprets an untrusted proxy header itself; it only ever receives the one already-resolved,
already-sanitized header apps/api produced.
This closes the original reproduction directly: rotating a spoofed `X-Forwarded-For` on every
request no longer changes the rate-limit bucket in direct mode (the default), because the header
is never consulted at all. The in-memory rate-limit store remains per-process (`core/src/identity/
runtime.ts`, unchanged from ADR 0011) — this pass does not add distributed storage; a genuinely
multi-instance deployment still needs a shared or edge limiter, reserved for pass 5.
### Public signup is disabled by default, and cannot be enabled in production at all
`IdentityConfig.publicSignupEnabled` (`core/src/identity/config.ts`) is a strict boolean:
`undefined` input means `false` (disabled); only an explicit boolean or the exact strings
`"true"`/`"false"` are accepted, so an absent, misspelled, or loosely parsed environment variable
can never enable it.
**Production refuses enablement outright.** `validatePublicSignupEnabled` takes the same
`IdentityEnvironment` the rest of this config already validates against, and throws the stable
`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION` error whenever the parsed value is `true` in production.
The durable signup-abuse prerequisites a public registration endpoint requires — verified email or
an invitation requirement, plus durable shared/edge throttling rather than this release's
per-process in-memory limiter — do not exist in this repository, so there is no production
configuration in which the flag could be honored safely; permitting a deliberate opt-in would be
offering a switch whose safe position does not exist yet. Parsing is kept separate from this policy
so a malformed value still reports `PUBLIC_SIGNUP_ENABLED_INVALID` in every environment, and an
operator who typo'd the flag is never told the misleading "unsupported in production" story
instead. Because `apps/api`'s executable entry point calls `createApiRuntimeConfig` *before*
`startApi`, such a deployment fails before any PostgreSQL pool, Better Auth instance, module
runtime, or listener exists. `pnpm doctor` surfaces the same state as a blocking `NOT READY`
failure (via its existing `IdentityConfigError` path), never a warning. Development and test
composition may still enable it explicitly through the identical typed path — that is how this
repository's own integration suite creates accounts over HTTP. When the prerequisites are actually
implemented and proven, this one function is the single place that changes.
`core/src/identity/runtime.ts` wires the validated flag directly to Better Auth's own
`emailAndPassword.disableSignUp` flag: when disabled, `POST /sign-up/email` rejects every call
with a fixed `400 {"code":"EMAIL_PASSWORD_SIGN_UP_DISABLED"}` before Better Auth ever looks up the
email, so a disabled deployment cannot leak whether an address is already registered, and
`apps/api/src/app.ts` now forces `Cache-Control: no-store` on every `/api/auth/*` response
(previously unset on the success path). Sign-in, session resolution, and sign-out are entirely
unaffected — this is Better Auth's own native, already-audited gate, not a second, competing
platform-owned check, since the pinned release's own mechanism already satisfies every required
property (no email-existence leak, no business logic invoked, a stable code). Since email/password
is the only enabled provider (ADR 0011), there is no second signup entry point to separately guard.
This amends ADR 0011's framing ("Anyone may create an Ekklesia account") for production entirely —
public registration remains the intended eventual product policy, but is currently reachable only
in development/test composition (`publicSignupEnabled: true` with `environment: "development"`);
ADR 0011's
architectural description of what a registered account *is* (an authentication identity, not a
membership record) is otherwise unchanged and still authoritative. This pass adds no email
verification, CAPTCHA, invitation, or durable abuse control — those remain explicitly deferred
prerequisites for a real future public-enrollment mode.
### One platform-owned request-body policy, and a module route's own conscious body contract
`apps/api/src/body-limits.ts` centralizes three named, typed values built on Hono's pinned
`hono/body-limit` middleware (verified directly against its source: it rejects an oversized
declared `Content-Length` synchronously from the header alone, and rejects an oversized
declared-length-free body by reading and counting the stream incrementally, aborting as soon as
the running total exceeds the limit — never buffering the whole body first):
- `AUTH_REQUEST_MAX_BODY_BYTES` = 8 KiB, applied to every `/api/auth/*` request before Better
Auth's handler ever runs.
- `CORE_MUTATION_MAX_BODY_BYTES` = 16 KiB, applied to `POST /api/v1/grants` and
`POST /api/v1/grants/:grantId/revoke` after `requireTrustedOrigin` (so a rejected cross-origin
mutation's body is never read) but before permission evaluation.
- `MODULE_ROUTE_MAX_BODY_BYTES_CEILING` = 1 MiB (re-exported from `@ekklesia/contracts`), the hard
ceiling no module route may exceed and the platform's overall body-size ceiling.
Every `hono/body-limit` instance shares one sanitized public contract,
`requestBodyTooLargeResponse` — a stable `413 {"code":"REQUEST_BODY_TOO_LARGE"}` with
`Cache-Control: no-store` — so auth, Core, and module rejections are indistinguishable in shape.
A module route's body contract becomes a fifth, required field on the existing route-definition
contract (`packages/contracts/src/module-http.ts`): `maxBodyBytes: number`, where `0` means the
route accepts no body at all (a non-empty declared or streamed body is rejected) and any other
value is the exact byte maximum, validated as a non-negative, finite integer no greater than
`MODULE_ROUTE_MAX_BODY_BYTES_CEILING` (`MODULE_ROUTE_MAX_BODY_BYTES_INVALID` otherwise). This is a
security contract, not a manifest capability: it does not touch `manifestVersion` or the static
manifest at all, only the executable `http.route(...)` call every module already makes during
`register`. `apps/api/src/module-http-routes.ts` applies `platformBodyLimit(route.maxBodyBytes)`
to every mounted route, positioned identically to the Core mutation routes — after
`requireTrustedOrigin` for a state-changing method, before `requirePermission` — so an oversized
request never reaches authorization work, a module handler, or module database behavior, and a
rejected cross-origin mutation's body is never read for size at all.
This is a deliberate, minimal public-contract change to an existing executable contract (not the
manifest): every existing module fixture, test, and the template (`templates/module/src/server/
index.ts`) are updated in this same pass to declare it explicitly, per module-authoring
convention documented in [Module development](../../module-development/README.md#http-routes).
### Finite Node header/request timeouts, and two non-obvious operational details
`apps/api/src/server.ts` configures `@hono/node-server`'s `serverOptions` (passed straight through
to `http.createServer`) with `headersTimeout: 20s`, `requestTimeout: 30s`, and
`keepAliveTimeout: 5s` (Node's own default, made explicit and typed). Node's own documented
behavior for `requestTimeout`/`headersTimeout` — verified directly against the pinned Node 24
runtime with a standalone reproduction — is to respond `408` and close the connection without
ever invoking a request listener, so a timed-out connection cannot invoke a handler or receive a
second response by construction; this pass does not reimplement that behavior.
That same reproduction surfaced a non-obvious operational requirement: Node's `http.Server` only
*checks* for a `headersTimeout`/`requestTimeout` violation when its periodic
`connectionsCheckingInterval` fires, and that option's own default is 30 seconds — leaving it
unset would silently let a violating connection sit for up to a further 30 seconds past its
configured timeout before Node ever closes it, on top of the configured value. `NodeServerTimeouts`
therefore also sets `connectionsCheckingIntervalMs: 5s` explicitly, so a violation is detected
promptly after it actually elapses rather than after an unrelated, much longer default interval.
The second detail concerns *observing* those timeouts, and corrects a claim this ADR originally
made. Node's general server `'timeout'` event belongs to `server.setTimeout()`'s idle-socket timer,
which this server never arms — it does **not** fire for `headersTimeout`/`requestTimeout`. Driving
a real slow-header and a real slow-body connection through a real socket against the pinned runtime
shows the only server-level event either produces is `'clientError'`, carrying
`err.code === "ERR_HTTP_REQUEST_TIMEOUT"`. A `server.on("timeout", ...)` listener therefore never
fires in production, and the sanitized operational line it was meant to emit was never actually
emitted.
`apps/api/src/server.ts` consequently observes `'clientError'` instead. That carries an obligation:
registering *any* `'clientError'` listener suppresses Node's own default response entirely
(`_http_server.js` falls back only when `server.emit("clientError", ...)` returns `false`), so a
listener that merely watched would silently turn every `408`/`400`/`431` into a bare connection
reset. The handler therefore reproduces Node's default byte-for-byte — `408 Request Timeout` for
`ERR_HTTP_REQUEST_TIMEOUT`, `431 Request Header Fields Too Large` for `HPE_HEADER_OVERFLOW`, `400
Bad Request` otherwise — writing it only when the socket is still writable and no response has
already begun for the in-flight request (mirroring Node's own `_httpMessage`/`headersSent` guard, so
a handler that already responded before an unread body timed out never receives a second, corrupting
status line), then destroys the socket exactly as Node would.
Exactly one fixed, sanitized line is logged per event — one message for a timeout, one for any other
malformed or oversized request. Neither ever contains the request path, query, body, cookies,
authorization value, client-address header, forwarding chain, raw peer address, stack, or the caught
error's own message, since a parse failure's message is derived directly from attacker-controlled
input. This is proven at the real transport boundary rather than by manual event emission: raw-socket
scenarios in `apps/api/tests/api-http.integration.ts` drive a genuine slow-header connection, a
genuine slow-body connection, a malformed request line, and an oversized header block, each
asserting the exact response bytes, exactly one status line, socket closure, non-invocation of any
handler, and exactly one sanitized log.
Bounded graceful-shutdown deadlines for module `stop()` hooks are a related but distinct concern,
explicitly deferred to pass 4 and not touched here.
## Alternatives
- **Setting Better Auth's `advanced.ipAddress.trustedProxies` alone** was rejected: that option
only inspects the forwarded header's own content for proxy-shaped addresses and can never
verify who actually connected, so any direct caller could still choose its own bucket by
crafting a header that merely *looks* trusted-proxy-shaped.
- **Resolving the client address inside Core's identity runtime directly from headers** was
rejected: Core has no access to the real socket peer (it only ever sees a Fetch `Request`), and
duplicating proxy-trust logic inside Core would create a second, divergent implementation of
exactly the policy this ADR centralizes once, at the transport boundary.
- **A separate CAPTCHA, email-verification, or invitation system to gate signup in this pass**
was rejected as out of scope: pass 1 closes the *default-open* exposure; the deeper abuse-control
prerequisites for a real public-enrollment mode are reserved, honestly, for later work.
- **Adding `maxBodyBytes` to the static module manifest instead of the executable route
definition** was rejected, matching ADR 0015's own reasoning for keeping routes executable data:
a manifest field can't be enforced without also trusting the manifest as a security boundary the
platform doesn't otherwise treat that way, and it would create a second, potentially-drifting
place to declare the same route.
- **A single blanket global body-limit middleware applied before per-route logic** was rejected in
favor of positioning `platformBodyLimit` individually within each route's own middleware chain:
a single global instance mounted ahead of `requireTrustedOrigin` would read (or begin reading) a
rejected cross-origin mutation's body before the origin check ever ran, which this ADR's ordering
requirement forbids.
- **Leaving Node's `connectionsCheckingInterval` at its default** was rejected once the
reproduction showed it would silently widen both configured timeouts by up to 30 additional
seconds — a security-relevant gap, not merely a latency one.
## Consequences
Direct mode (the default) trusts only the real socket peer for every request; explicit
trusted-proxy mode requires a header name and a concrete, non-empty trusted-proxy list, validated
the same way in `core/src/identity/client-address.ts` and surfaced by Doctor
(`identity.client-address`), which distinguishes safe direct mode, an explicitly configured proxy
mode, and an invalid configuration. Public signup is closed by default everywhere, and production
cannot open it at all until the durable abuse-control prerequisites exist — only development and
test composition may enable it. Every body-consuming public route — auth, Core, and every module
route — now has an explicit, enforced byte ceiling with one sanitized `413` contract. The Node
server itself now bounds a slow-header or slow-body connection instead of relying on Node's
generous multi-minute defaults. None of this constitutes distributed rate limiting, a durable
signup-abuse control, or a claim that the origin is unreachable except through a configured proxy
— the deployment's own reverse proxy and firewall remain responsible for that, and durable/shared
throttling for a multi-instance or public-signup deployment remains explicitly reserved for pass 5.
@@ -0,0 +1,217 @@
# 0018: Identity session resolution, canonical public origins, and PostgreSQL transport policy
- Status: Accepted
- Date: 2026-07-26
- Amends: [0010](0010-core-identity-and-better-auth-adoption.md), [0011](0011-identity-http-transport-and-public-enrollment.md)
- Extends: [0017](0017-public-request-perimeter-hardening.md)
## Context
Phase 5D pass 1 ([ADR 0017](0017-public-request-perimeter-hardening.md), commits `d3c6bd73`,
`1161f5a076b66548767d67b58853bf83ab8f24b4`, `3d6c85b0`) closed the public request perimeter's
default-open exposures. Pass 2 addresses the layer immediately behind it: how a request's principal
is actually resolved, what a "public origin" is allowed to be, and how Core reaches PostgreSQL at
all. Five defects were reproduced against the pass-1 tree before anything was changed:
1. **Any cookie triggered identity database work.** `resolvePrincipal`'s ambiguity check ran
whenever the request carried *a* `Cookie` header. `Cookie: x=y` on an otherwise anonymous
request caused a database round trip on every request — an unauthenticated caller could drive
identity database load with a header they chose themselves.
2. **The readiness probe proved almost nothing.** It issued `select 1`. A dropped session table, a
drifted or removed column, or a revoked `SELECT` privilege on the session relation all still
answered "reachable", so Better Auth's ambiguous `null` was reported as a genuinely anonymous
request. A deployment whose session store was broken in any of those ways served every request
as anonymous instead of failing.
3. **The base URL / trusted-origin boundary accepted non-origins.** Validation accepted any
absolute `http`/`https` URL, so a value carrying a path, a trailing slash, credentials, a
default port written explicitly, or non-canonical case became configuration that no longer
compared equal to the origin a browser would send. The loopback rejection was also a short
literal list (`localhost`, `127.0.0.1`, `::1`), missing `127.0.0.2`, the rest of `127.0.0.0/8`,
`0.0.0.0`, `::`, the expanded `0:0:0:0:0:0:0:1` spelling, IPv4-mapped forms such as
`::ffff:127.0.0.1`, and the RFC 6761 `.localhost` namespace.
4. **Every PostgreSQL connection was plaintext, in every environment.** No connection site set
`ssl` at all. Credentials, session tokens, and row data crossed the network in the clear, and
nothing in configuration, startup, or `pnpm doctor` said so.
5. **A connection string could silently downgrade an explicit TLS decision.** The pinned
`pg` 8.22.0 builds its parameters as
`Object.assign({}, config, parse(config.connectionString))`, so a `?sslmode=disable` in
`DATABASE_URL` *overrides* an explicit `ssl` object passed alongside it. A policy that only set
`ssl` would have been silently defeated by the URL it was given.
## Decision
### Only the configured session cookie makes `null` ambiguous
`core/src/identity/runtime.ts` exports `hasConfiguredSessionCookie(headers, cookieName)`. It parses
the `Cookie` header the way RFC 6265's `cookie-string` grammar (and Better Auth's own parser)
requires — split on `;`, take each pair's name as the text before its first `=`, trim surrounding
whitespace — and compares the whole name with an exact string comparison. Never `includes`, never a
substring test, never an unanchored regular expression: `better-auth.session_token_x`,
`x_better-auth.session_token`, a differently-cased spelling, and a percent-encoded lookalike are all
correctly *not* the session cookie (cookie names are case-sensitive per RFC 6265 §4.1.1 and are
never percent-decoded). Ambiguity fails safe *towards* checking: a duplicated session-cookie name
still reports `true`, because reporting `false` is what would let a broken session store be served
as an anonymous request. Reporting `true` never grants access on its own — it only means the
readiness check runs, and Better Auth's own result still governs the principal.
The cookie name is not a second, hard-coded copy. One `betterAuthOptions` object feeds both
`betterAuth()` and Better Auth's own supported `getCookies` helper (`better-auth/cookies`), so the
name this runtime detects — including the `__Secure-` prefix an `https` `baseURL` implies — is
derived from the exact configuration Better Auth itself uses. Drift is structurally impossible
rather than merely tested.
The practical effect: a request with no cookies, or with only unrelated cookies, performs **zero**
identity database work. Better Auth never attempted to resolve an Ekklesia session for it, so its
`null` is unambiguous and the anonymous principal is returned directly.
### The readiness check reads the real session relation
`probeSessionStore` replaces `select 1` with a planned read of the exact session relation and the
exact columns the Better Auth adapter depends on (`id`, `token`, `userId`, `expiresAt`), under a
`where false` predicate. PostgreSQL still resolves the relation, every named column, and the
caller's `SELECT` privilege at plan time, while guaranteeing zero rows are matched, read, or
returned — so no session row, token, or user id can be enumerated, logged, or exposed by the check
itself. It uses the injected Identity Drizzle view (the API's one shared pool) and never constructs
a second pool, client, connection string, or Drizzle instance, and it stays bounded by the existing
3-second timeout whose timer is always cleared.
A missing relation, a dropped column, a revoked privilege, an unreachable connection, and a query
timeout all surface at this seam identically — as a rejected query — and all produce the same
outcome: a sanitized `PRINCIPAL_RESOLUTION_FAILED`, which `apps/api` already maps to a
`503 {"code":"PRINCIPAL_RESOLUTION_FAILED"}` with no relation name, column name, SQL, driver text,
host, or stack in it. A genuinely missing, expired, revoked, or malformed session still resolves to
the anonymous principal exactly as ADR 0011 specifies. This is the distinction the pass-1 tree
could not make.
### The public identity URL boundary is exactly one canonical origin
`parseCanonicalHttpOrigin` (`core/src/identity/config.ts`) requires `url.origin === value` — the
WHATWG URL parser's own canonical serialization must equal the supplied string exactly. A path,
a trailing slash, a query, a fragment, embedded credentials, an explicitly written default port, or
non-canonical case therefore fails with the existing `BASE_URL_INVALID` /
`TRUSTED_ORIGIN_INVALID` codes rather than being normalized behind the operator's back, and both
`validateBaseUrl` and `validateTrustedOrigin` return that canonical origin, so what is stored is
what Better Auth compares against a browser's `Origin` header. The base URL is now an origin, not a
URL that merely happens to have one.
`isLoopbackOrUnspecifiedHostname` replaces the literal list. It classifies `localhost` and the whole
reserved `.localhost` namespace (RFC 6761), all of `127.0.0.0/8`, `0.0.0.0`, `::1` in both its
compressed and fully expanded spellings, `::`, and IPv4-mapped forms such as `::ffff:127.0.0.1` and
`::ffff:7f00:1` — by expanding the address to its eight numeric groups rather than by comparing
strings. Production rejects every one of them for both the base URL and every trusted origin
(`BASE_URL_LOOPBACK_IN_PRODUCTION` / `TRUSTED_ORIGIN_LOOPBACK_IN_PRODUCTION`), and development
composition must still opt in explicitly with `environment: "development"`.
This is a configuration-shape guarantee and nothing more. **It does not resolve DNS and does not
prove reachability.** A public hostname that resolves to a loopback or private address still passes
this validation, and a firewall, network policy, or reverse proxy — not URL parsing — is what
actually determines who can reach the deployment.
### Production PostgreSQL requires TLS with certificate *and* hostname verification
`core/src/database/transport.ts` is the one typed transport policy. `DatabaseTlsMode` has exactly
two values:
- `verify-full` — TLS required, certificate chain verified, and the server's identity verified
against the configured host. **The default in production.**
- `disable` — no TLS. **The default in development/test, and refused outright in production.**
There is deliberately no encryption-only mode (`require`), no chain-only mode (`verify-ca`), and no
`rejectUnauthorized: false` escape hatch anywhere in the configuration surface: encryption without
identity verification is exactly what an interception attack defeats, so offering it as a
supported setting would offer a switch whose safe position is the only one worth having. Every
loose, aliased, or coercible spelling (`VERIFY-FULL`, `" verify-full"`, `require`, `prefer`,
`no-verify`, `true`, `1`, `""`, `null`) is rejected with `DATABASE_TLS_MODE_INVALID` in *both*
environments, so a typo never quietly becomes a weaker mode.
**Production has no plaintext exception at all — not even for a loopback database.**
`assertProductionDatabaseTransport` refuses `mode: "disable"` in production whether the URL points
at `db.example.test`, `127.0.0.1`, `localhost`, or `[::1]`, with the stable
`DATABASE_TLS_REQUIRED_IN_PRODUCTION` code. A "same host, therefore safe" carve-out would be a
claim about the deployment's process isolation, container networking, and socket namespace that
this repository cannot verify from a URL string.
`databaseTlsConnectionOptions` builds the actual `ssl` object: `rejectUnauthorized: true`, an
optional explicit private CA (`certificateAuthority`, supplied as PEM text — a path, a filesystem
read, and a secret store are all out of scope here), and — critically — an explicit
`checkServerIdentity` pinned to the host parsed from the connection string. That last part closes a
real gap in the pinned driver rather than restating a default: `pg` sets TLS `servername` only for
non-IP hosts (`connection.js`), and Node performs **no** identity check when `servername` is unset,
so `rejectUnauthorized: true` alone accepts a certificate from any host the configured CA ever
signed when connecting by IP address. Both halves of that claim are proven against a real
TLS-enabled PostgreSQL in `core/tests/database-tls.integration.ts`: the policy rejects the mismatch
with `ERR_TLS_CERT_ALTNAME_INVALID`, and the same connection *without* the pinned callback succeeds.
### A connection string may not carry its own TLS decision
`assertNoConnectionStringTlsParameters` rejects a `DATABASE_URL` carrying any TLS parameter the
pinned `pg-connection-string` 2.14.0 recognizes — `ssl`, `sslmode`, `sslcert`, `sslkey`,
`sslrootcert`, `sslpassword`, `sslnegotiation`, `uselibpqcompat`, case-insensitively — with
`DATABASE_TLS_CONNECTION_STRING_CONFLICT`. This is not stylistic. The pinned driver's
`Object.assign({}, config, parse(config.connectionString))` gives the *connection string* the last
word, so a `?sslmode=disable` in the URL would silently replace the policy's verified `ssl` object;
`sslmode=require` is additionally aliased to verify-full behavior with a deprecation warning in this
release, so even the "stricter-looking" spellings do not mean what an operator would assume. There
is exactly one place TLS is decided, and it is the policy — never the URL. `databaseTlsConnectionOptions`
performs this assertion itself, so no connection site can forget it.
### One policy, every executable, and Doctor
`core/src/cli/database-transport-environment.ts` is the single environment reader
(`EKKLESIA_DATABASE_TLS_MODE`, `EKKLESIA_DATABASE_CA`), keeping `process.env` out of Core's
reusable constructors: `createValidatedPostgresPool`, `createIdentityDrizzleDatabase`,
`createAuthorizationDrizzleDatabase`, and `createPostgresMigrationDatabase` all take an explicit
optional `transport` policy instead. Every executable that opens a PostgreSQL connection resolves
the policy the same way — `apps/api` (via `ApiRuntimeConfig.databaseTransport`, validated in
`createApiRuntimeConfig` *before* any pool, Better Auth instance, module runtime, or listener
exists), `pnpm migrations:plan`, `pnpm migrations:apply`, the platform-owner bootstrap CLI, and
`pnpm doctor`'s own connection.
`pnpm doctor` gains a `database.transport` check reported in its PostgreSQL section. A verified
production policy passes; an unsafe or invalid one is a blocking `NOT READY` **failure**, never a
warning, and its output names the effective mode only — never the URL, host, port, credentials, or
CA material.
## Alternatives
- **Keeping `select 1` and adding a separate table-existence query** was rejected: it would be a
second, drifting description of what the adapter actually needs. Planning the real read against
the real relation and the real columns is the only check that fails when any of them changes.
- **Probing the session store on every request** was rejected: it makes an unauthenticated caller
able to drive database load, which is the defect being fixed, not a stricter version of it.
- **Normalizing a non-canonical base URL instead of rejecting it** was rejected: silently rewriting
an operator's origin is how a deployment ends up with a trusted-origin list that does not match
what browsers actually send, and the failure then appears as an unexplained CSRF rejection.
- **Resolving DNS during configuration validation to catch a public hostname pointing at a private
address** was rejected: it makes startup depend on resolver state, is trivially defeated by a
later DNS change, and would invite exactly the false confidence this ADR explicitly disclaims.
- **Supporting `sslmode=require` (encryption without identity verification)** was rejected: it is
the mode that looks secure and is not, and having it available makes it the one an operator picks
when verify-full is inconvenient.
- **A production plaintext exception for a loopback database** was rejected: the URL cannot prove
the database is actually process-local, and the exception would be the first thing a
containerized deployment mistakenly claimed.
- **Trusting `rejectUnauthorized: true` alone** was rejected once the reproduction showed the pinned
driver leaves `servername` unset for IP hosts and Node then performs no identity check at all.
- **Reading `EKKLESIA_DATABASE_*` inside Core's database constructors** was rejected: it would put
`process.env` inside reusable library code and give every test and future embedder an implicit,
invisible input.
## Consequences
A request without the configured session cookie performs no identity database work at all; a
request that presents it, and that Better Auth resolved to `null`, is disambiguated by a real
session-store read, so a broken store produces a sanitized `503` instead of a silent anonymous
downgrade. The identity base URL and every trusted origin are now exactly one canonical origin
each, with a complete loopback/unspecified classifier enforced in production — a shape guarantee
only, which does not resolve DNS, does not prove reachability, and does not replace a firewall or
reverse proxy. Production PostgreSQL connections require TLS with both certificate and hostname
verification, from every executable, with no plaintext exception and no way to express a weaker
mode; a connection string that tries to decide TLS for itself is refused rather than honored.
None of this makes an internet-exposed database safe: TLS protects the connection, not the exposure,
and the network boundary remains the deployment's own responsibility. This pass adds no secret
store, no CA file loading, no certificate rotation, no client-certificate authentication, no
connection-level authorization change, and no distributed rate limiting. Phase 5D passes 3–5
(authorization integrity, module/runtime/migration correctness, and deployment/supply-chain
closure) remain outstanding — Phase 5D is not complete.
@@ -0,0 +1,270 @@
# 0019: Durable authorization history and grant management
- Status: Accepted
- Date: 2026-07-26
- Amends: [0013](0013-core-authorization-and-owner-bootstrap.md)
- Extends: [0017](0017-public-request-perimeter-hardening.md), [0018](0018-identity-session-resolution-and-database-transport.md)
## Context
Phase 5D passes 1 and 2 closed the public request perimeter and the identity/database transport
behind it. Pass 3 addresses the authorization layer those protect: whether security history
survives, whether the one-time bootstrap that creates a platform's first administrator is really
one-time, and whether explicit-grant creation, revocation, validation, and listing are unambiguous
and bounded. Ten defects were reproduced against the accepted pass-2 tree before anything changed.
**Security history did not survive.** `ekklesia_core_authorization_audit.subject_user_id` carried a
cascading foreign key to the live Better Auth user row. Deleting a user with two audit rows left
zero: the record of what that user had been granted, by whom, and when it was revoked disappeared
at precisely the moment it mattered most.
**Bootstrap's single use was not durable.** "Already consumed" meant "a row exists in
`ekklesia_core_platform_owner`" — and that table also cascades on user deletion. Deleting the
bootstrapped owner's user row removed the owner row *and* its audit evidence, after which
`bootstrapPlatformOwner` succeeded again for a different user. There was no state independent of
user lifecycle recording that bootstrap had ever run.
**Equivalent grants multiplied silently.** Two identical `createGrant` calls produced two grants;
five concurrent identical calls produced five. Revoking one returned `success` while an equivalent
issuance kept authorizing the same user — an operator could believe they had removed access that
was still live.
**The request boundary leaked into the database.** The generic HTTP API accepted a `scope` even
though no route middleware supplies one to the evaluator, so a scoped grant persisted, appeared in
listings, and could authorize nothing. `GrantId` accepted any 1–128-character string, so a
malformed path parameter reached PostgreSQL's own UUID comparison and returned an indistinguishable
`503`. `expiresAt` went through bare `new Date()`, which silently normalized `2026-02-31` to March
3rd and accepted `"March 5, 2027"`, date-only, offset, and millisecond-free forms; a past
expiration was persisted as a grant that was dead on arrival. Listing had no bound at all and
returned a user's entire authorization history in one response.
## Decision
### Security history is retained, not referential
`ekklesia_core_authorization_audit.subject_user_id` stays a non-null `text` column and loses its
foreign key to `ekklesia_core_user`. It is now a **historical snapshot**: an identifier that may
name a user row which no longer exists. No name, email, or other profile data is copied into
authorization tables — a deleted user's audit history says what happened, not who they were. The
subject index is preserved, and the Drizzle relation to the user table is deliberately removed
because modelling a retained snapshot as a live relation would misdescribe the data.
Live rows still cascade on user deletion, which is intended: deleting a user removes their grants
and their platform-owner row, so nothing continues authorizing a principal that no longer exists.
Only the *history* is exempt.
**Append-only remains an application/store convention, not a database guarantee.** Core's
authorization store exposes no update or delete path for audit rows. Nothing in PostgreSQL enforces
that: no role, trigger, or rule prevents a database administrator, a future migration, or anything
else holding the same credentials from modifying them. This ADR claims exactly the property that
exists.
### Bootstrap consumption is its own durable record
`ekklesia_core_platform_owner_bootstrap` is a dedicated single-row table meaning "platform-owner
bootstrap was successfully consumed." Its `id` is pinned by a check constraint to the literal
`'singleton'`, so the primary key makes a second row structurally impossible rather than merely
unwritten by convention. It has **no foreign key to any user**, and Core never deletes from it, so
it survives owner revocation and user deletion alike. An optional `consumed_for_user_id` records
which user the original bootstrap made an owner, in the same retained-identifier style as the audit
subject.
`bootstrapPlatformOwner` consults and claims that record — never the continued existence of a
platform-owner row. The claim, the target-user check, the owner insert, and the audit entry are one
transaction, serialized by the existing transaction-scoped advisory lock, so exactly one of
concurrent first attempts succeeds and the other receives
`PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED`. Consumption is tied to *success*: a missing target
user (`PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND`), a constraint violation, or any other failure
rolls the sentinel back with everything else, leaving the one legitimate first use available.
There is deliberately no reset command, deletion path, HTTP endpoint, environment override, force
flag, or second bootstrap mechanism. The operational consequence is explicit: **deleting the last
platform owner does not reactivate bootstrap.** Re-establishing an owner after that is a
separately designed owner-management flow that does not exist yet.
Core migration `0002_authorization_integrity` backfills the sentinel for any installation that
already holds a platform-owner row — active or revoked — so an existing deployment cannot gain a
fresh first use by upgrading. It deliberately leaves `consumed_for_user_id` null, because a backfill
cannot know which of several historical owner rows the original single use targeted and guessing
would fabricate history. An installation that never had an owner is left untouched, because a
legitimately never-bootstrapped deployment must retain its one first use.
**One upgrade limitation is irrecoverable and is stated rather than papered over.** If an operator
manually deleted the bootstrapped owner's user row *before* this migration, the old cascade already
erased both the owner row and its audit evidence, and the migration has nothing left to detect. Such
an installation backfills as never-bootstrapped and retains one bootstrap use. At the accepted
checkpoint no supported Better Auth or API user-deletion path exists that could produce this state;
it requires direct database access. The migration cannot reconstruct an event for which no durable
evidence remains, and does not pretend to.
### Grant equivalence is exactly five values
Two grant issuances are the same authorization source when all five are equal, with `NULL` equal
only to `NULL`:
1. `user_id`
2. `permission_id`
3. `scope`
4. `source_kind`
5. `source_ref`
`expires_at`, creation metadata, actor metadata, and the grant id deliberately do **not**
distinguish them. Re-issuing the identical authority with a different expiry, or by a different
administrator, is the same grant — not a second one.
Creation fails with the stable `AUTHORIZATION_GRANT_ALREADY_EXISTS` when an equivalent grant is
currently effective (`revoked_at IS NULL` and `expires_at IS NULL OR expires_at > now()`). A revoked
or already-expired equivalent does not block a fresh issuance. A rejected duplicate creates no grant
and no audit row.
**Conflict, not idempotent return.** Returning the existing grant would silently answer a different
question than the caller asked — the caller wanted a new authorization recorded with their own
actor and expiry, and telling them "done" while recording neither is the ambiguity this pass exists
to remove. An explicit 409 lets an operator see that the authority already exists and decide.
### Serialization is database-backed, not process-local
Every create and revoke touching one equivalence tuple takes a transaction-scoped
`pg_advisory_xact_lock` keyed by a 64-bit hash of that tuple encoded as a JSON array — which keeps
`NULL` distinct from the empty string and makes separator forgery through a crafted scope or source
reference impossible. A hash collision is harmless by construction: it can only make two unrelated
tuples serialize against each other, never make a real conflict go unnoticed, because the
authoritative decision is always an exact SQL predicate and never the hash.
A process-local mutex would not do this — nothing constrains the number of API processes — and
neither would an unlocked check-then-insert. `pg_advisory_xact_lock(bigint)` uses PostgreSQL's
single-key advisory-lock space, verified disjoint from the two-key space the migration runner and
platform-owner bootstrap use, so grant serialization can never block or be blocked by either.
A **time-dependent partial unique index** (`... WHERE revoked_at IS NULL AND expires_at > now()`)
was rejected outright: `now()` is not immutable, so PostgreSQL cannot index on it, and any
approximation would either over-constrain legitimate re-issuance after expiry or silently stop
enforcing.
### Revocation resolves the whole equivalence class
Revocation is still addressed by one grant id and retains the explicit-versus-relationship source
guard. Once the target is found, one successful revocation revokes **every** currently unrevoked
issuance sharing its equivalence tuple — including expired-but-unrevoked legacy equivalents, so the
history is left in one unambiguous state — and inserts one `grant.revoke` audit row per grant
actually revoked, each carrying that row's own grant id. All updates and audit inserts are one
transaction. Unknown, already-revoked, and wrong-source ids keep the existing not-found behavior,
and the public success response remains `200 {"success":true}`.
Historical duplicates are **not** silently revoked by migration. The store now prevents creating
another effective equivalent, and revocation removes all equivalents when an operator explicitly
revokes one. Choosing a historical "winner" or changing live authorization inside a data migration,
with no operator action, is not something this pass will do.
### The generic HTTP surface creates unscoped explicit grants only
`POST /api/v1/grants` rejects the presence of a `scope` field — including `null`, empty, malformed,
and otherwise valid scope strings — with `400 {"code":"INVALID_REQUEST"}` before any store mutation.
Core's `AuthorizationScope`, the evaluator's exact-scope semantics, and the store's scope column are
all unchanged and remain available to trusted internal callers and to future server-owned scope
work; listing and serialization still show a scope on historical or internally created grants.
No route-parameter-to-scope resolver is added in this pass, and no module route declaration or
handler context changes. Accepting a scope that nothing can evaluate would be offering a switch that
is not connected to anything.
### Canonical identifiers and future-only expiration
`GrantId` accepts exactly the canonical lowercase hyphenated UUID PostgreSQL emits (`8-4-4-4-12`
lowercase hexadecimal), with no trimming. Uppercase, whitespace-padded, shortened, overlong, and
non-UUID values are rejected rather than repaired, so exactly one spelling of any grant is valid and
a malformed id is a `400` decided after authorization but strictly before any store or database
call. A canonical id that does not exist still returns the sanitized `404`.
`expiresAt` accepts exactly `YYYY-MM-DDTHH:mm:ss.sssZ`, byte-for-byte equal to the parsed `Date`'s
own `toISOString()`, and must be strictly later than the authoritative acceptance clock — equal-to-
now and past are invalid. The round-trip comparison is what rejects normalized impossible dates.
The Core store independently rejects an invalid or non-future `Date` before any SQL, using the
stable internal `AUTHORIZATION_GRANT_EXPIRATION_INVALID`, so trusted internal callers get the same
guarantee; the public HTTP response stays exactly `400 {"code":"INVALID_REQUEST"}` with
`Cache-Control: no-store`. A rejected expiration creates no grant and no audit row. A deterministic
clock is injectable only at the narrow Core store and API route factory seams needed for tests;
production leaves it unset. No maximum lifetime, renewal workflow, scheduler, or background
expiration job is introduced.
### Listing is bounded keyset pagination with an opaque, unsigned cursor
`GET /api/v1/grants?userId=<id>&limit=<1..100>&cursor=<opaque>` returns
`{"grants": [...], "nextCursor": string | null}`. `limit` defaults to 50 and must be a canonical
base-10 integer; unknown query keys, duplicated keys, empty values, non-canonical numbers, and
malformed cursors are rejected with `400 {"code":"INVALID_REQUEST"}` before any store or database
work. Ordering is `created_at DESC, id DESC` with a strict row-value keyset boundary on **both**
columns, so rows sharing a `created_at` — which concurrent creation makes routine — can neither be
skipped nor repeated. At most `limit + 1` rows are read and at most `limit` returned.
`sourceKind: "explicit"` filtering stays server-side on every page. Offset pagination was rejected
because it does exactly the skipping and repeating this ordering avoids.
The cursor is a small versioned payload containing only the subject user id, boundary creation
timestamp, and boundary grant id, encoded as canonical unpadded base64url. Decoding is strict in
every dimension — exact version, exact keys, matching requested subject, canonical `toISOString()`
timestamp, canonical `GrantId`, and a re-encoding that reproduces the supplied string byte-for-byte
— and a rejected cursor reveals nothing it decoded.
**The cursor is a pagination position, not an authorization token.** It is deliberately unsigned:
the permission check and the server-side subject filter remain authoritative on every page, so a
forged cursor can at most name a different position within grants the caller is already entitled to
list. It carries no permission, scope, source reference, actor data, credential, or secret, and it
is not a consistency snapshot — rows created or revoked between pages are simply seen or not seen
according to the ordering.
Core's list method requires and validates a finite bound; no internal call site retains an unbounded
fallback. No total count, offset pagination, search, sorting options, or filtering beyond the
existing subject and explicit-source restriction is added.
## Alternatives
- **Keeping the audit foreign key and adding `ON DELETE SET NULL`** was rejected: a null subject is
not history, and the column's whole purpose is naming who the security event concerned.
- **Copying the user's name or email into the audit row so history stays readable** was rejected: it
converts an authorization table into a shadow profile store and would survive a deletion the user
may have requested.
- **Treating "an owner row exists" as consumed, but blocking the cascade instead** was rejected: an
owner row is live authorization state that an operator may legitimately want removed, while the
fact that bootstrap ran is a historical event. Conflating the two is what produced the defect.
- **A `--force`/reset path for bootstrap** was rejected as directly reintroducing the vulnerability
under a different name.
- **Guessing "consumed" for every upgraded database** was rejected: it would burn the one legitimate
first use of a never-bootstrapped installation.
- **Idempotently returning the existing grant on duplicate creation** — see above.
- **A partial unique index over the equivalence tuple** was rejected for the `now()` immutability
reason above; the advisory lock covers the complete tuple with no time dependence.
- **Revoking only the addressed grant and reporting how many equivalents remain** was rejected: it
puts the burden of resolving an ambiguous state on the operator, in a response that would have to
disclose other grants' existence.
- **Silently revoking historical duplicates in the migration** was rejected as changing live
authorization without operator action.
- **Adding a route-parameter-to-scope resolver in this pass** was rejected as scope creep with
security consequences; rejecting scoped creation is the honest interim state.
- **Signing the pagination cursor** was rejected: it would imply the cursor carries authority it does
not have, and invite treating it as a capability rather than a position.
## Consequences
Authorization audit history survives user deletion; live grants and platform-owner rows still
cascade, as intended. Bootstrap's single use is durable across owner revocation and user deletion,
backfilled for existing installations with one documented, irrecoverable exception, and permanently
unavailable once consumed — including after the last owner is deleted. Two equivalent grants can no
longer exist effectively, sequentially or concurrently, and one revocation leaves no equivalent
issuance authorizing the user. Malformed grant ids and expirations are request-validation results
decided before any database call, never infrastructure failures. Explicit-grant listing is bounded
and deterministically traversable.
Core gains one table and one forward migration (`0002_authorization_integrity`), classified
`{"mode":"none"}`: it creates a table, drops one referential constraint specifically to *retain*
data, and inserts a backfill row. It drops no table, column, or row. The `DROP CONSTRAINT` text is
not itself a reason to classify it approval-required — the production CLI has no trusted external
approval provider, so an approval-blocked security fix would be undeployable. Migration 0002 must be
applied before the new code starts; migrations 0000 and 0001 are byte-identical with unchanged
checksums.
No roles, groups, teams, tenants, service accounts, API tokens, scope resolver, user-deletion
endpoint, owner-recovery workflow, or module-facing change is introduced, and the trusted compiled-
backend module model is unchanged. Audit append-only behavior remains an application convention, not
a cryptographic or database-role guarantee. **Phase 5D is not complete** — passes 4 (module/runtime/
migration correctness) and 5 (deployment/supply chain plus an independent whole-repository re-audit)
remain outstanding, and Phase 6 remains blocked.
@@ -0,0 +1,233 @@
# 0020: Module runtime and migration execution hardening
- Status: Accepted
- Date: 2026-07-27
- Amends: [0009](0009-transactional-module-migrations.md), [0015](0015-static-module-http-routing-and-runtime-lifecycle.md)
- Extends: [0017](0017-public-request-perimeter-hardening.md), [0019](0019-durable-authorization-history-and-grant-management.md)
## Context
Phase 5D passes 1–3 hardened the public perimeter, the identity/database transport, and
authorization integrity. Pass 4 addresses the module runtime and the migration commands underneath
them. Seven defects were reproduced against the accepted pass-3 tree before anything changed.
**Registration order decided which permission governed a request.** A module may register both
`GET /events/:id` and `GET /events/new`. Driven through the pinned router, `/events/new` reached the
parameter handler with `demo.events.read` when the parameter route registered first, and the static
handler with `demo.events.create` when it registered second. Nothing rejected the pair. A permission
policy was being decided by array order.
**Decoded parameters looked safer than they are.** The declaration grammar admits only lowercase
kebab-case literals and `:name` segments, but that constrains declarations, not requests. Verified
through real requests: `%2F..%2Fadmin` arrives as `/../admin`, `a%5Cb` as `a\b`, `a%00b` with an
embedded NUL, `a%0D%0Ab` with CR/LF, `1%3B%20drop` as `1; drop`, and `%C3%A9v%C3%A9nement` as
`événement`.
**A module that failed to start was never cleaned up.** `startModules` appended to its rollback set
only *after* `start()` resolved, so a hook that acquired a resource and then threw had its own
`stop()` skipped while every previously completed module was rolled back.
**Nothing bounded shutdown.** `RunningApi.shutdown()` awaited `server.close()` with no deadline, so
one open connection could hold it forever, and each module `stop()`/rollback hook was awaited
without a deadline, so a hook that never settled prevented pool closure indefinitely.
**Migration commands had no timeouts.** `migrations:plan` and `migrations:apply` constructed their
connection with neither a connection deadline nor an operation deadline; a real server confirmed
`statement_timeout = 0` on the migration connection.
**Migration SQL was split on literal bytes.** The runner did
`migration.sql.split("--> statement-breakpoint")`. Those bytes inside a string literal, a
dollar-quoted body, a quoted identifier, or a comment split the migration into fragments that were
no longer valid SQL — each verified to produce two fragments where one was required.
## Decision
### Overlapping same-module routes are rejected, not ranked
Two routes of one module overlap when they share an HTTP method and a segment count, and at every
position either both segments are the same static literal or at least one is a parameter. Under
this grammar — static literals or `:name` parameters, with no wildcard, regex, optional, or
catch-all segment — that condition is exactly "some concrete request path matches both".
`moduleRoutesOverlap` (`packages/contracts/src/module-http.ts`) implements it, kept framework-neutral
because path semantics already live in that package and Hono stays confined to `apps/api`. Core's
staged registration checks it after the existing duplicate check, so identical shapes — including
paths differing only in parameter name — keep returning `MODULE_ROUTE_DUPLICATE`, and a genuine
non-duplicate intersection returns the new `MODULE_ROUTE_OVERLAP`. Detection happens during the
registering module's staged registration, so the module publishes **none** of its staged routes or
capabilities, no start hook runs, and the HTTP listener never binds.
Rejection rather than specificity ordering is the point. A specificity rule ("static beats
parameter") would silently pick a winner, and the thing being picked is which permission guards the
path — that is a policy decision, not a routing preference, and it must not be inferred. Disjoint
routes keep their registration and publication order untouched; different methods, different
modules (each has its own platform-owned mount prefix), and different segment counts remain legal.
No priority, specificity, wildcard, regex, catch-all, optional-segment, mount-prefix, or manifest
field was added.
### Decoded route parameters are untrusted, module-owned input
`context.params` continues to carry the router's decoded string values in a frozen context.
Decoding is not validation, and validating a *declaration* says nothing about what a *request* may
send. Every handler must validate each parameter it uses against its own exact domain grammar —
UUID, slug, date, numeric id, or whatever that module owns — before any database, filesystem,
command, identifier, or other side-effecting use, and reject missing, repeated, malformed, or
semantically invalid values with its own sanitized response. Re-encoding or a decode/re-encode round
trip is not a substitute.
No generic parameter grammar, validator service, path-normalization layer, or new route field was
added: module parameters are module-owned domain types, and one platform-wide pattern would either
reject legitimate values or approve meaningless ones. The platform keeps owning authentication,
authorization, `Origin`, CORS, body limits, cache behavior, and uncaught-handler sanitization, and
permission enforcement always completes before a handler — and therefore any parameter validation —
can run.
### A module that fails to start rolls itself back first
A module joins the rollback set the moment its `start` hook is *invoked*. Its own `stop` hook runs
first, then previously completed modules in reverse order. The original start issue stays first in
`ModuleRuntimeError.issues`; a failure or timeout stopping the failing module is appended and never
prevents earlier modules from being rolled back. Every applicable hook is invoked at most once, a
runtime that fails to start is never returned, and every hook receives the same frozen
`ModuleRuntimeServices` identity its start hook received. A module with no stop hook gets no
synthetic callback.
The consequence for module authors is explicit: **`stop()` may run after a partially completed
`start()`** and must be safe and idempotent about resources it may or may not have acquired. The
platform cannot undo arbitrary side effects a trusted module performed outside its stop hook, and
does not claim to.
### Fixed, finite deadlines for hooks and for HTTP drain
- Per-module `stop`/rollback hook: `MODULE_STOP_HOOK_TIMEOUT_MS` = **10 000 ms**.
- Graceful HTTP drain: `HTTP_DRAIN_TIMEOUT_MS` = **30 000 ms**.
Neither is configurable in this pass — no environment variable, manifest field, runtime service, or
flag. Narrow test-only overrides exist so tests need not wait out production durations.
A hook that reaches its deadline records `MODULE_STOP_TIMEOUT` or `MODULE_ROLLBACK_TIMEOUT` and the
sequence continues with the next module in reverse order. The whole module-stop sequence is finite
because the module count is fixed at discovery: its worst case is
`startedModuleCount × 10 000 ms`, **not** one global ten-second bound. Timers that lose the race are
cleared, the losing promise gets a permanent rejection handler so a late throw can never become an
unhandled rejection, and a timed-out hook is never awaited again. `stop()` remains at-most-once and
memoizes one shared promise for concurrent and later callers.
Shutdown stops accepting connections with `server.close()`, waits up to the drain deadline, and at
the deadline closes idle and then active connections through Node's own public
`closeIdleConnections`/`closeAllConnections` — after `server.close()`, never touching a private
socket or server field — and stops waiting for the close callback. It then runs the bounded module
stop sequence and closes the one shared pool. The accepted order
`HTTP drain/forced close → bounded module stop → pool close` is unchanged, and a drain timeout or
module-stop failure never skips later cleanup. Both outcomes are reported together as
`ApiShutdownError`, carrying fixed booleans and the already-sanitized module error — never a
request, socket, URL, credential, SQL, or stack detail. A forced drain is a shutdown *failure*, not
a success; signal shutdown exits `0` only after a clean shutdown and `1` otherwise, after cleanup
has been attempted.
**A deadline bounds waiting, not execution.** An in-process promise cannot be canceled: a timed-out
hook keeps running and may settle later, and force-closing a connection ends the transport without
proving that arbitrary handler JavaScript was canceled. What is guaranteed is that shutdown no
longer waits, and that the pool closes afterwards, so late database work fails. Graceful ordering
holds until the drain deadline; after it, the platform closes connections and proceeds so process
shutdown stays bounded.
### Finite migration connection and operation defaults, enforced against driver precedence
`createPostgresMigrationDatabase` — the one construction boundary every migration command shares —
now defaults to `MIGRATION_CONNECTION_TIMEOUT_MS` = **10 000 ms** (`connectionTimeoutMillis`) and
`MIGRATION_OPERATION_TIMEOUT_MS` = **10 000 ms** for both `query_timeout` (client-side) and
`statement_timeout` (server-side). Defaults live there so a future CLI call site cannot silently
omit them; explicit test overrides may still narrow them. History reads, advisory-lock queries,
transaction control, migration SQL, and history writes are all covered. The existing migration
lock-wait deadline and error codes, and the Pass 2 TLS/hostname/certificate policy, are unchanged,
as are Doctor's and the bootstrap CLI's own timeouts. No timeout environment variable or flag was
added.
A connection string can reach those values, because `[email protected]` builds parameters as
`Object.assign({}, config, parse(config.connectionString))` and `pg-connection-string` retains
*arbitrary* query keys. What each key actually does was measured against the installed driver, a
real blackhole listener, and a real PostgreSQL rather than inferred from that merge, and only one of
them genuinely weakens the policy:
| Source | Measured effect on the selected policy |
| --- | --- |
| `?statement_timeout=0` / `=900000` | **Wins.** Server reported `0` (disabled) and `15min` against an explicit `statement_timeout: 10000`. The one real downgrade. |
| `?query_timeout=0` | Overrides the explicit field, but breaks the client rather than loosening it: the parsed value is the string `"0"`, which is truthy, so every query gets a 0 ms read timer and fails nondeterministically with `Query read timeout`. |
| `?connectionTimeoutMillis=0` / `=900000` | Pollutes `ConnectionParameters.connect_timeout` (10 → `0`/`900` s), but that field feeds only `getLibpqConnectionString()`, which the pure-JS client never calls. The **effective** connect timer is unchanged — an explicit 500 ms still expired at ~500 ms with either value. |
| `?connect_timeout=0` | No effect: `ConnectionParameters` recomputes it from `connectionTimeoutMillis`. |
| `?options=-c statement_timeout=…` (incl. a backslash-escaped spelling) | No effect: the named startup parameter wins. Server still reported `10s`. |
| inherited `PGOPTIONS=-c statement_timeout=0` | No effect. Server still reported `10s`. |
| inherited `PGCONNECT_TIMEOUT=0` | No effect: consulted only when `connectionTimeoutMillis` is `undefined`, which this boundary never leaves unset. |
| unrelated parameters (`application_name`, …) | No effect; deliberately untouched. |
A `DATABASE_URL` carrying `statement_timeout`, `query_timeout`, `connectionTimeoutMillis`,
`connect_timeout`, or an `options` value naming `statement_timeout` is refused with the existing
fixed `POSTGRES_MIGRATION_CONFIGURATION_INVALID`, before any client is constructed and without
echoing the URL, parameter, or value. Only the first is a precedence fix; the rest are rejected as
**unsupported configuration and defense in depth** — no legitimate `DATABASE_URL` carries a driver
option, `connectionTimeoutMillis` would take effect on the native binding, and `query_timeout`
produces intermittent failures an operator would struggle to diagnose. Stating that honestly matters:
an earlier revision of this ADR claimed `connect_timeout` overrode the explicit field, which
measurement disproved.
### Each migration is one PostgreSQL-parsed batch
The runner no longer splits migration SQL. Each pending migration's exact string is passed once to
the client — no values, no prepared-statement name, no trimming, normalizing, or rewriting — inside
the existing transaction, and its history row is inserted only after that batch succeeds. One
transaction still spans all pending migrations and their history records, and advisory locking,
checksum validation, approval preflight, ordering, rollback, cleanup, and sanitized error identity
are unchanged. The transaction port method was renamed `executeStatement` → `executeMigrationSql` so
the contract states what it actually receives.
PostgreSQL was chosen as the parser because it is the only correct one. A homegrown splitter would
need to track single quotes, dollar quoting with arbitrary tags, quoted identifiers, line comments,
block-comment nesting, and escape syntax — reimplementing a SQL lexer to answer a question the
server already answers — and a parser dependency would add supply-chain surface for the same
reason. Drizzle's `--> statement-breakpoint` lines are ordinary `--` comments when the batch is sent
whole, so the same bytes inside a literal, dollar-quoted body, quoted identifier, or comment retain
their ordinary PostgreSQL meaning. **No custom SQL parser exists in this repository.**
## Alternatives
- **Ordering static routes ahead of parameter routes** was rejected: it silently resolves an
ambiguity whose resolution is a permission decision, and it would make the governing permission a
function of an implementation detail rather than of a declaration.
- **A platform-wide route-parameter grammar** was rejected: parameters are module-owned domain
types, so any single pattern is simultaneously too strict and too permissive.
- **Normalizing or re-encoding decoded parameters** was rejected: it produces a value the module
never declared and still does not answer whether the value is a valid domain identifier.
- **A second, dedicated rollback hook** was rejected: it would double the lifecycle surface to
express something `stop` already means, and every module would have to implement both.
- **Making the deadlines configurable** was rejected for this pass: a configurable shutdown bound
invites setting it to infinity, which is the defect being fixed.
- **Claiming a timed-out hook is terminated** was rejected as untrue; the honest guarantee is that
the platform stops waiting.
- **Trusting explicit `pg` client fields over the connection string** was rejected once the pinned
driver was shown to do the opposite.
- **A timeout environment variable** was rejected as out of scope; a fixed default that cannot be
weakened is the safer starting point.
- **Writing a SQL statement splitter, or adding a SQL parser dependency** — see above.
## Consequences
An ambiguous same-method route pair fails module registration with a stable code instead of
resolving by array order, and no request can reach a match whose permission depended on registration
sequence. Decoded parameters are documented as untrusted input that handlers must validate, with no
platform grammar implied. A module whose `start` fails is cleaned up through its own `stop` hook
before earlier modules. Shutdown is finite in every stage, observable when it is not clean, and
always reaches pool closure. Migration commands have finite connection, query, and statement
deadlines that a connection string cannot weaken, and each migration reaches PostgreSQL byte-for-byte
in a single parsed batch.
This pass adds no production schema, migration, manifest field, `manifestVersion` change,
`ModuleRuntimeServices` field, `ModuleDatabase` operation, route-declaration field, handler-context
field, second runtime, route registry, or runtime pool; the three accepted Core migrations and their
checksums are byte-identical, one API runtime still owns one physical pool, migrations remain
explicit operator work that never runs at application startup, and Hono remains confined to
`apps/api`. Modules remain trusted compiled backend code: they are **not** isolated by process,
PostgreSQL role, schema, or tenant, and these deadlines do not sandbox them.
**Phase 5D is not complete.** Pass 5 — deployment/supply-chain work and an independent
whole-repository re-audit — remains outstanding, and Phase 6 remains blocked.
+4
View File
@@ -25,3 +25,7 @@ ADRs capture durable choices that affect more than one package or phase.
- [0014: Module permission declarations and platform permission registry](0014-module-permission-declarations-and-platform-registry.md) — Accepted - [0014: Module permission declarations and platform permission registry](0014-module-permission-declarations-and-platform-registry.md) — Accepted
- [0015: Static module HTTP routing and production runtime lifecycle](0015-static-module-http-routing-and-runtime-lifecycle.md) — Accepted - [0015: Static module HTTP routing and production runtime lifecycle](0015-static-module-http-routing-and-runtime-lifecycle.md) — Accepted
- [0016: Module database access](0016-module-database-access.md) — Accepted - [0016: Module database access](0016-module-database-access.md) — Accepted
- [0017: Public request perimeter hardening — client address, signup gating, body limits, timeouts](0017-public-request-perimeter-hardening.md) — Accepted
- [0018: Identity session resolution, canonical public origins, and PostgreSQL transport policy](0018-identity-session-resolution-and-database-transport.md) — Accepted
- [0019: Durable authorization history and grant management](0019-durable-authorization-history-and-grant-management.md) — Accepted
- [0020: Module runtime and migration execution hardening](0020-module-runtime-and-migration-execution-hardening.md) — Accepted
+43 -8
View File
@@ -4,6 +4,11 @@ Ekklesia is a long-lived platform from which churches can compose many applicati
experiences. It begins as a modular monolith so contracts can stabilize before distribution is experiences. It begins as a modular monolith so contracts can stabilize before distribution is
introduced. introduced.
> **Trust boundary.** Installed modules are trusted compiled backend code, not sandboxed
> plugins: a module runs arbitrary JavaScript in the API process and shares its PostgreSQL
> role. The layering described below is a correctness and ownership boundary, not hostile-code
> isolation. See [Security model and deployment trust boundaries](../development/security.md).
## Dependency model ## Dependency model
The arrows below mean "depends on." Presentation and composition depend on stable lower-level The arrows below mean "depends on." Presentation and composition depend on stable lower-level
@@ -90,8 +95,9 @@ established, ordered before module migrations in the same aggregate. See
Phase 4B adds identity HTTP transport: a minimal Hono-based `apps/api` composition root mounts Phase 4B adds identity HTTP transport: a minimal Hono-based `apps/api` composition root mounts
Better Auth at `/api/auth/*` and exposes one Ekklesia-owned route, `GET /api/v1/principal`, behind Better Auth at `/api/auth/*` and exposes one Ekklesia-owned route, `GET /api/v1/principal`, behind
Core's own Fetch-compatible identity runtime (`@ekklesia/core/identity`) — Core still does not Core's own Fetch-compatible identity runtime (`@ekklesia/core/identity`) — Core still does not
depend on Hono. Public email/password registration is intentional; every account receives depend on Hono. Public email/password registration is the intended eventual product policy — every
identical lowest-privilege baseline status, and signup order never grants elevation. See account receives identical lowest-privilege baseline status, and signup order never grants
elevation — but is disabled by default as of Phase 5D pass 1, below. See
[ADR 0011](decisions/0011-identity-http-transport-and-public-enrollment.md) and [ADR 0011](decisions/0011-identity-http-transport-and-public-enrollment.md) and
[the API guide](../development/api.md), and [the API guide](../development/api.md), and
[the identity development guide](../development/identity.md) for the underlying schema and [the identity development guide](../development/identity.md) for the underlying schema and
@@ -108,16 +114,45 @@ Phase 5A wired module-declared permissions into that same authorization system
manifest, one composed platform permission registry spanning Core's own declarations and every manifest, one composed platform permission registry spanning Core's own declarations and every
installed module's, enforced through the identical middleware Core's own routes use. Phase 5B let a installed module's, enforced through the identical middleware Core's own routes use. Phase 5B let a
module mount its own framework-neutral HTTP routes in production, under module mount its own framework-neutral HTTP routes in production, under
`/api/v1/modules/<module-id>`, enforced and response-sanitized exactly like Core's own routes. This `/api/v1/modules/<module-id>`, enforced and response-sanitized exactly like Core's own routes. Phase
branch completes Phase 5C: typed module database access (`context.services.database`) for a 5C added typed module database access (`context.services.database`) for a module's lifecycle hooks
module's lifecycle hooks and authorized HTTP handlers, over the platform's one existing PostgreSQL and authorized HTTP handlers, over the platform's one existing PostgreSQL pool.
pool. See [ADR 0014](decisions/0014-module-permission-declarations-and-platform-registry.md), See [ADR 0014](decisions/0014-module-permission-declarations-and-platform-registry.md),
[ADR 0015](decisions/0015-static-module-http-routing-and-runtime-lifecycle.md), [ADR 0015](decisions/0015-static-module-http-routing-and-runtime-lifecycle.md),
[ADR 0016](decisions/0016-module-database-access.md), and the repository root [ADR 0016](decisions/0016-module-database-access.md), and the repository root
[README](../../README.md#current-status) for the complete, current status of every phase. [README](../../README.md#current-status) for the complete, current status of every phase.
The project continues finishing the module platform's backend foundations before Phase 6 Surface **Phase 5D is complete.** Five passes hardened the platform against an independent security audit of
work begins. Target architecture not yet implemented includes production approval-provider the Phase 5C checkpoint, and an independent whole-repository re-audit closed the phase:
1. the public request perimeter — a server-owned client-address resolution boundary for the
authentication rate limiter, public sign-up disabled by default behind an explicit typed opt-in,
an enforced request-body ceiling on every public route (including a module route's own declared
contract), and finite Node header/request timeouts;
2. identity session resolution, and a mandatory certificate- **and** hostname-verified PostgreSQL
transport with no plaintext or `rejectUnauthorized: false` exception;
3. durable authorization history that survives user deletion, a one-time owner bootstrap that
deleting a user cannot reopen, and bounded, unambiguous explicit-grant management;
4. module route-overlap rejection at registration, finite startup rollback and shutdown deadlines,
and hardened migration command timeouts and SQL execution;
5. deployment and supply-chain closure plus the final re-audit.
See [ADR 0017](decisions/0017-public-request-perimeter-hardening.md),
[ADR 0018](decisions/0018-identity-session-resolution-and-database-transport.md),
[ADR 0019](decisions/0019-durable-authorization-history-and-grant-management.md), and
[ADR 0020](decisions/0020-module-runtime-and-migration-execution-hardening.md).
These guarantees hold under a **trusted-contributor, not-yet-deployed** threat model. The remaining
limitations are operational rather than architectural: CI is a trusted-code-only boundary whose
runner topology and job-token authority are unverified, and Ekklesia is not deployed, so the origin,
proxy, and throttling properties a public launch requires are unproven. Neither set of requirements
blocks Phase 6. Read
[Security model and deployment trust boundaries](../development/security.md) for what each boundary
does and does not protect against, and the repository root
[README](../../README.md#current-status) for the current status of every phase.
The module platform's backend foundations are now in place and Phase 6 Surface work is unblocked.
Target architecture not yet implemented includes production approval-provider
composition, physical storage-bucket provisioning, jobs/workflow automation, richer public resource composition, physical storage-bucket provisioning, jobs/workflow automation, richer public resource
and action contracts, a Dashboard, a Builder, and reference feature modules; none of these are and action contracts, a Dashboard, a Builder, and reference feature modules; none of these are
scheduled by this document. scheduled by this document.
+249 -23
View File
@@ -11,15 +11,27 @@ timeouts, idempotent shutdown, and pinned password-length boundaries) without ch
above; see [ADR 0012](../architecture/decisions/0012-identity-origin-csrf-protection-under-test.md). 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 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). bootstrap command plus an enforced explicit-grant HTTP API — see [Authorization](authorization.md).
Phase 5D pass 1 hardened this same public perimeter against an independent security audit: a
server-owned client-address resolution boundary for the rate limiter, public sign-up disabled by
default, an explicit request-body-size policy for every public route (including a module route's
own declared contract), and finite Node header/request timeouts — see
[ADR 0017](../architecture/decisions/0017-public-request-perimeter-hardening.md). Pass 3 made the
explicit-grant API's own request contracts exact: canonical UUID grant ids, one canonical
future-only expiration format, unscoped-only creation, a `409` duplicate conflict, and bounded
keyset listing — see
[Authorization](authorization.md#explicit-grant-http-api) and
[ADR 0019](../architecture/decisions/0019-durable-authorization-history-and-grant-management.md).
## What this is, and is not ## What this is, and is not
After this slice it is possible to start the API, publicly create an ordinary account, sign in, After this slice it is possible to start the API, sign in, resolve a request to an Ekklesia
resolve a request to an Ekklesia principal, and sign out. It is **not** possible to verify an email principal, and sign out; public account creation is also possible once explicitly enabled (see
address, recover a forgotten password, use any provider besides email/password, reach a Dashboard, [Public registration policy](#public-registration-policy)). It is **not** possible to verify an
or hold any role or permission — none of that exists yet. **Public registration is not ready for a email address, recover a forgotten password, use any provider besides email/password, reach a
real public launch after this PR**: there is no email delivery service, so an unverified email is Dashboard, or hold any role or permission — none of that exists yet. **Public registration is not
trusted as entered and a forgotten password has no recovery path. ready for a real public launch**: there is no email delivery service, so an unverified email is
trusted as entered and a forgotten password has no recovery path, and no durable, distributed
abuse control exists yet even with signup enabled.
## Starting the API ## Starting the API
@@ -42,12 +54,18 @@ No real values are shown here — configure per environment and never commit the
| Variable | Requirement | | Variable | Requirement |
| --- | --- | | --- | --- |
| `DATABASE_URL` | An absolute `postgres://` or `postgresql://` URL to an already-migrated database. | | `DATABASE_URL` | An absolute `postgres://` or `postgresql://` URL to an already-migrated database. It must **not** carry a TLS parameter (`sslmode`, `ssl`, `sslrootcert`, …) — see [Database transport (TLS)](#database-transport-tls). |
| `EKKLESIA_DATABASE_TLS_MODE` | Optional. Exactly `"verify-full"` or `"disable"`. Defaults to `verify-full` in production and `disable` in development/test. Production refuses `disable`. |
| `EKKLESIA_DATABASE_CA` | Optional. PEM text of a private certificate authority to trust in addition to the system store, for a database using an internal CA. |
| `EKKLESIA_API_HOST` | Optional. Listen address. Defaults to loopback (`127.0.0.1`). An explicit non-loopback address is permitted for future deployment. | | `EKKLESIA_API_HOST` | Optional. Listen address. Defaults to loopback (`127.0.0.1`). An explicit non-loopback address is permitted for future deployment. |
| `EKKLESIA_API_PORT` | Optional. Listen port, `0`–`65535` (`0` requests an OS-assigned port). Defaults to `3000`. | | `EKKLESIA_API_PORT` | Optional. Listen port, `0`–`65535` (`0` requests an OS-assigned port). Defaults to `3000`. |
| `EKKLESIA_IDENTITY_SECRET` | At least 32 characters, high entropy (for example `openssl rand -base64 32`). Same requirement as Phase 4A. | | `EKKLESIA_IDENTITY_SECRET` | At least 32 characters, high entropy (for example `openssl rand -base64 32`). Same requirement as Phase 4A. |
| `EKKLESIA_IDENTITY_BASE_URL` | An absolute `http`/`https` URL. `https` is required in production; loopback hosts are rejected in production. | | `EKKLESIA_IDENTITY_BASE_URL` | Exactly one canonical `http`/`https` origin — scheme, host, and non-default port only, with no path, trailing slash, query, fragment, credentials, or explicit default port. `https` is required in production; loopback and unspecified hosts are rejected in production. |
| `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` | Comma-separated list of absolute origins (no path/query/fragment, no wildcards). Used for both Better Auth's own CSRF/origin checks and the API's CORS policy — there is only one trusted-origin list. | | `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` | Comma-separated list of canonical origins, validated identically to the base URL (no wildcards). Used for both Better Auth's own CSRF/origin checks and the API's CORS policy — there is only one trusted-origin list. |
| `EKKLESIA_PUBLIC_SIGNUP_ENABLED` | Optional, **development/test only**. Exactly `"true"` enables public sign-up when `NODE_ENV=development`; defaults to disabled, and a production configuration rejects `"true"` outright. Omitted, misspelled, or any other value never enables it. See [Public registration policy](#public-registration-policy). |
| `EKKLESIA_CLIENT_ADDRESS_MODE` | Optional. `"direct"` (default when unset) or `"trusted-proxy"`. See [Client-address resolution](#client-address-resolution). |
| `EKKLESIA_CLIENT_ADDRESS_HEADER` | Required only when `EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy`. The one forwarded-header name the reverse proxy is trusted to set (for example `x-forwarded-for`). |
| `EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES` | Required only when `EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy`. Comma-separated list of the proxy's own IPv4/IPv6 addresses or CIDR ranges — never a broad private range that also covers real clients. |
| `NODE_ENV` | `development` explicitly opts into relaxed (loopback/`http`) origin validation for local development. Anything else, including unset, validates as production. | | `NODE_ENV` | `development` explicitly opts into relaxed (loopback/`http`) origin validation for local development. Anything else, including unset, validates as production. |
## Routes ## Routes
@@ -56,7 +74,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`, ...). | | `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 /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). | | `GET\|POST /api/v1/grants*` | Ekklesia | Explicit authorization grant management, gated by `core.authorization.grants.manage`. Listing is bounded keyset pagination; creation is unscoped-only and conflicts (`409`) on an equivalent effective grant; revocation clears every unrevoked equivalent. See [Authorization](authorization.md#explicit-grant-http-api). |
| `/api/v1/modules/<module-id>/*` | Installed modules | Every route an installed module registered during its `register` hook, gated by that route's own declared permission. See [Module HTTP routes](#module-http-routes). | | `/api/v1/modules/<module-id>/*` | Installed modules | Every route an installed module registered during its `register` hook, gated by that route's own declared permission. See [Module HTTP routes](#module-http-routes). |
Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a
@@ -76,7 +94,7 @@ Authenticated:
{ "kind": "authenticated", "userId": "2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1" } { "kind": "authenticated", "userId": "2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1" }
``` ```
Infrastructure failure (the identity database could not be reached while resolving the principal — Infrastructure failure (the identity session store was not usable while resolving the principal —
never silently reported as anonymous): never silently reported as anonymous):
```json ```json
@@ -84,6 +102,13 @@ never silently reported as anonymous):
``` ```
returned with HTTP `503`. returned with HTTP `503`.
A request carrying no cookies, or only unrelated cookies, never reaches that path and never performs
identity database work at all: only a request presenting the *configured session cookie* that Better
Auth resolved to `null` is ambiguous enough to check. When it is checked, a missing session table, a
dropped column, a revoked `SELECT` privilege, an unreachable database, and a query timeout all
produce this same sanitized `503` — carrying no relation name, column name, SQL, driver message,
host, or stack. See [Identity § How a principal is resolved](identity.md#how-a-principal-is-resolved).
This is deliberately not the same thing as Better Auth's own `GET /api/auth/get-session` response, This is deliberately not the same thing as Better Auth's own `GET /api/auth/get-session` response,
which returns Better Auth's full `{session, user}` shape (session id, expiry, IP address, user which returns Better Auth's full `{session, user}` shape (session id, expiry, IP address, user
agent, email, name, image, email-verification status, and more). `/api/v1/principal` is the agent, email, name, image, email-verification status, and more). `/api/v1/principal` is the
@@ -93,7 +118,35 @@ permission, membership, or volunteer data.
## Public registration policy ## Public registration policy
Anyone may create an account (`emailAndPassword.enabled: true`, `disableSignUp: false`). A **Public sign-up is disabled by default** (`EKKLESIA_PUBLIC_SIGNUP_ENABLED` unset or anything other
than exactly `"true"`). This is a strict, typed gate (`IdentityConfig.publicSignupEnabled`,
`core/src/identity/config.ts`) wired directly to Better Auth's own `emailAndPassword.disableSignUp`
flag (`core/src/identity/runtime.ts`) — there is no second, platform-owned signup gate, because the
pinned release's own mechanism already has every required property: every `POST
/api/auth/sign-up/email` call is rejected with a fixed, sanitized `400
{"code":"EMAIL_PASSWORD_SIGN_UP_DISABLED"}` and `Cache-Control: no-store` *before* Better Auth ever
looks up the email, so a disabled deployment never reveals whether an address is already
registered. Sign-in, session resolution, and sign-out are entirely unaffected by this gate.
**Production cannot enable public signup at all right now.** A production configuration that sets
the flag to `true` is rejected outright with the stable
`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION` configuration error, and `pnpm doctor` reports that state
as a blocking `NOT READY` failure rather than a warning. This is not a preference: the durable
signup-abuse prerequisites a public registration endpoint needs — verified email or an invitation
requirement, and durable shared/edge throttling rather than this release's per-process in-memory
limiter — **do not exist in this repository yet**, so there is no production configuration in which
the flag could be honored safely. The rejection happens in `createIdentityConfig`, which
`apps/api`'s executable entry point calls *before* `startApi`, so such a deployment never opens a
PostgreSQL pool, constructs Better Auth, starts the module runtime, or binds a listener.
Development and test composition may still enable it explicitly, through the same typed path
(`EKKLESIA_PUBLIC_SIGNUP_ENABLED=true` with `NODE_ENV=development`, or
`{ environment: "development" }` programmatically) — that is how this repository's own integration
suite creates accounts over HTTP. When the prerequisites above are actually implemented and proven,
`validatePublicSignupEnabled` (`core/src/identity/config.ts`) is the single place that changes. See
[ADR 0017](../architecture/decisions/0017-public-request-perimeter-hardening.md).
Once enabled, the rest of this section's behavior is unchanged from ADR 0011. A
registered account is an **authentication identity** — proof that someone can present this email registered account is an **authentication identity** — proof that someone can present this email
and password — not a church-membership record; it does not imply attendance or involvement, and and password — not a church-membership record; it does not imply attendance or involvement, and
nothing about it is recorded beyond Better Auth's own required `user`/`account` rows. Every nothing about it is recorded beyond Better Auth's own required `user`/`account` rows. Every
@@ -160,8 +213,9 @@ for the root-cause investigation and the full account of what changed.
Cookies keep Better Auth's defaults: `HttpOnly` always, `SameSite=Lax` always, `Secure` when the Cookies keep Better Auth's defaults: `HttpOnly` always, `SameSite=Lax` always, `Secure` when the
configured base URL is `https://`. Cross-subdomain cookies are not enabled and no cookie `Domain` configured base URL is `https://`. Cross-subdomain cookies are not enabled and no cookie `Domain`
attribute is set, so every cookie is host-only. Trusted proxy headers are not configured in this attribute is set, so every cookie is host-only. See [Client-address resolution](#client-address-resolution)
slice — a deployment behind a reverse proxy is future work. for how (and whether) a reverse proxy's forwarded headers are trusted — a separate concern from
CORS/CSRF above.
## Logging ## Logging
@@ -195,17 +249,126 @@ none of it reaches `console.error`, and separately asserts `onAPIError` is confi
integration test forces a real identity-database failure through Better Auth's own sign-in handler integration test forces a real identity-database failure through Better Auth's own sign-in handler
and asserts nothing at all reaches `console.error`/`warn`/`log` in the real process. and asserts nothing at all reaches `console.error`/`warn`/`log` in the real process.
## Client-address resolution
Better Auth's rate limiter (below) needs a trustworthy per-client identity, and the pinned
release's own client-address resolution only ever reads request headers — it cannot see the real
TCP socket peer. `apps/api` resolves and sanitizes that identity once, at the Node/API transport
boundary (`apps/api/src/client-address.ts`, wrapping the `fetch` callback passed to
`@hono/node-server`'s `serve()` in `apps/api/src/server.ts`), *before* Better Auth or any other
route ever sees the request. The resolution policy itself (`core/src/identity/client-address.ts`)
is framework-agnostic and lives in Core; only the Node-specific glue that reads
`env.incoming.socket.remoteAddress` lives in `apps/api`.
- **Direct mode** (the default, `EKKLESIA_CLIENT_ADDRESS_MODE` unset or `"direct"`). Trusts only
the actual immediate socket peer. No `X-Forwarded-For`, `Forwarded`, `CF-Connecting-IP`,
`X-Real-IP`, or any other request header is ever consulted for identity.
- **Trusted-proxy mode** (`EKKLESIA_CLIENT_ADDRESS_MODE=trusted-proxy`, with
`EKKLESIA_CLIENT_ADDRESS_HEADER` and `EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES` both required).
A forwarded header's content is trusted **only** when the actual immediate socket peer matches
one of the configured trusted-proxy addresses/CIDR ranges; otherwise the peer address itself is
used, exactly as in direct mode. A comma-separated forwarded chain is walked **right to left**:
trailing hops matching a trusted proxy are skipped, and the first hop from the right that does
not match is the resolved client address. A missing, empty, malformed, or entirely-trusted-hops
chain from an otherwise-trusted peer fails closed with a sanitized
`503 {"code":"CLIENT_ADDRESS_UNRESOLVED"}` — Better Auth, Core, and every module handler are all
unreachable in that case. IPv4, IPv6, and IPv4-mapped IPv6 (`::ffff:a.b.c.d`, the form a
dual-stack socket or a real proxy actually emits) all normalize consistently before comparison.
Whichever address is resolved is written into one fixed internal header
(`TRUSTED_CLIENT_ADDRESS_HEADER = "x-ekklesia-client-address"`, `core/src/identity/runtime.ts`),
**overwriting** — never appending to — any externally supplied copy of that exact header name, so
a caller can never choose its own "resolved" identity by sending it directly. Better Auth is
configured to trust only this one header (`advanced.ipAddress.ipAddressHeaders`), never its own
default (`x-forwarded-for`) and never `trustedProxies` (that pinned-release option only inspects
header *content*, never who actually connected — see [ADR 0017](../architecture/decisions/0017-public-request-perimeter-hardening.md)
for why that is insufficient on its own).
**A reverse proxy in front of this API must be configured to *overwrite*, not append to, the
configured forwarded header**, and the origin must not be reachable except through that proxy —
neither is something application-level validation can enforce; both are the deployment's own
network/firewall responsibility. `pnpm doctor`'s `identity.client-address` check distinguishes
three states: safe direct mode, an explicitly configured (and structurally valid) trusted-proxy
mode, and an invalid/contradictory configuration (which fails Doctor, not merely warns).
## Rate limiting ## Rate limiting
Better Auth's built-in rate limiting is always enabled, including by the executable entry point — Better Auth's built-in rate limiting is always enabled, including by the executable entry point —
it is never disabled. Its built-in per-path rules (a strict default of 3 requests per 10 seconds on it is never disabled. Its built-in per-path rules (a strict default of 3 requests per 10 seconds on
sign-in/sign-up-prefixed paths) are preserved unmodified in production composition; only the sign-in/sign-up-prefixed paths) are preserved unmodified in production composition; only the
integration test suite injects a narrow, explicit override so it is not itself rate-limited by its integration test suite injects a narrow, explicit override so it is not itself rate-limited by its
own many requests. Storage remains the default in-memory backend, which has two honest limitations: own many requests. Storage remains the default in-memory backend, which is **per-process**: no
it is **per-process** (no protection across multiple API instances or restarts), and because no protection across multiple API instances or restarts. Client identity for this limiter is now
trusted-proxy IP policy is configured, Better Auth may fall back to a single shared bucket per path always the resolved, server-owned address described above — never a caller-controlled header — so
rather than a genuine per-client bucket. This is not yet the final, distributed, production-grade rotating a spoofed `X-Forwarded-For` on every request no longer lets a caller choose a fresh
abuse-control design. bucket, and unrelated real peers are never collapsed into one shared bucket merely because no
header was present. This closes the rate-limit-identity gap; it does not add distributed storage —
a genuinely multi-instance deployment still needs a shared or edge limiter, reserved for pass 5.
## Request body limits
Every public body-consuming route has an explicit, platform-enforced byte ceiling
(`apps/api/src/body-limits.ts`, built on Hono's pinned `hono/body-limit` middleware — verified
directly against its source to reject both an oversized declared `Content-Length` and an oversized
declared-length-free streamed body without ever buffering the whole payload first):
| Surface | Limit |
| --- | --- |
| `/api/auth/*` (every Better Auth route) | 8 KiB |
| `POST /api/v1/grants`, `POST /api/v1/grants/:grantId/revoke` | 16 KiB |
| A module route (`http.route(...)`'s own `maxBodyBytes`) | Up to 1 MiB, the platform ceiling (`MODULE_ROUTE_MAX_BODY_BYTES_CEILING`, `@ekklesia/contracts`) |
An oversized request always receives the identical stable, sanitized contract —
`413 {"code":"REQUEST_BODY_TOO_LARGE"}` with `Cache-Control: no-store` — and never invokes Better
Auth's business logic, a Core mutation, permission evaluation (where avoidable), a module handler,
or module database behavior. For a state-changing route the body limit is enforced after
`requireTrustedOrigin` but before permission evaluation, so a rejected cross-origin mutation's body
is never read for size at all, while an oversized same-origin request still fails before any
authorization work runs. See [Module HTTP routes](#module-http-routes) for the module-route body
contract itself, and [Module development](../module-development/README.md#http-routes) for the
module-authoring side of it. Large media transfer is explicitly out of scope for ordinary module
HTTP bodies — that belongs to a future dedicated storage design, never to widening this ceiling.
## Node request and header timeouts
`apps/api/src/server.ts` configures the underlying Node HTTP server (via `@hono/node-server`'s
`serverOptions`, passed straight through to `http.createServer`) with finite
`headersTimeout` (20s), `requestTimeout` (30s), and `keepAliveTimeout` (5s, Node's own default,
made explicit). Node's own documented behavior responds `408` and closes the connection without
ever invoking a request listener when either timeout is exceeded, so a timed-out connection cannot
invoke a handler or receive a second response by construction.
Two non-obvious details, both found only by reproducing them directly against the pinned Node 24
runtime through a real socket:
1. **Detection latency.** Node's `http.Server` only *checks* for a timeout violation when its
periodic `connectionsCheckingInterval` fires, and that option's own default is 30 seconds —
leaving it unset would silently let a violating connection sit for up to a further 30 seconds
past its configured timeout. `connectionsCheckingIntervalMs` is therefore also set explicitly
(5s).
2. **The observable event is `'clientError'`, not `'timeout'`.** Node's general server `'timeout'`
event belongs to `server.setTimeout()`'s idle-socket timer, which this server never arms; it
does **not** fire for `headersTimeout`/`requestTimeout`. Those surface as `'clientError'` with
`err.code === "ERR_HTTP_REQUEST_TIMEOUT"`. `apps/api/src/server.ts` therefore observes
`'clientError'`, and — because registering any `'clientError'` listener *suppresses* Node's own
default response entirely (`_http_server.js` only falls back when the emit returns `false`) —
that handler reproduces Node's default byte-for-byte rather than merely watching: `408 Request
Timeout` for a timeout, `431 Request Header Fields Too Large` for a header overflow, and `400
Bad Request` otherwise, each followed by destroying the socket, and each written only when the
socket is still writable and no response has already begun (so a handler that already responded
before an unread body timed out never receives a second, corrupting status line).
Exactly one fixed, sanitized operational line is logged per transport-level client failure — one
message for a timeout, one for any other malformed/oversized request. Neither ever contains the
request path, query, body, cookies, authorization value, client-address header, forwarding chain,
raw peer address, stack, or the caught error's own message (a parse failure's message is derived
from attacker-controlled input). This is proven at the real transport boundary by raw-socket
scenarios in `apps/api/tests/api-http.integration.ts` — a real slow-header connection, a real
slow-body connection, a malformed request line, and an oversized header block — each asserting the
exact response bytes, exactly one status line, socket closure, no handler invocation, and exactly
one sanitized log. See
[ADR 0017](../architecture/decisions/0017-public-request-perimeter-hardening.md) for the full
account.
## Module discovery, permission composition, and module runtime startup ## Module discovery, permission composition, and module runtime startup
@@ -259,6 +422,11 @@ are mounted under `/api/v1/modules/<module-id>`, in the module runtime's publish
one. `OPTIONS` remains entirely platform-owned: a module cannot register it, and a CORS preflight one. `OPTIONS` remains entirely platform-owned: a module cannot register it, and a CORS preflight
never reaches authorization or a handler. `GET` inherits Hono's own implicit `HEAD` handling never reaches authorization or a handler. `GET` inherits Hono's own implicit `HEAD` handling
(same permission check, response body stripped); a module cannot separately author `HEAD`. (same permission check, response body stripped); a module cannot separately author `HEAD`.
- **Body limit.** A route's own `maxBodyBytes` (see [Module development](module-development/README.md#http-routes)
and [Request body limits](#request-body-limits)) is enforced immediately after the trusted-origin
check and before permission evaluation — `0` means no body is accepted at all. An oversized
request receives the platform's stable `413 {"code":"REQUEST_BODY_TOO_LARGE"}` and never reaches
authorization, the handler, or the module database.
- **Handler input.** A handler receives the raw Fetch `Request` (so `Request.url`, headers, and - **Handler input.** A handler receives the raw Fetch `Request` (so `Request.url`, headers, and
body all behave normally, and the full query string remains available) plus a frozen context of body all behave normally, and the full query string remains available) plus a frozen context of
exactly `moduleId`, the authorized `EkklesiaPrincipal`, frozen, Hono-decoded path parameters (an exactly `moduleId`, the authorized `EkklesiaPrincipal`, frozen, Hono-decoded path parameters (an
@@ -338,6 +506,61 @@ authorization, and module domains all read from this one pool through independen
views — see [Authorization](authorization.md#shared-database-composition) for why there is exactly views — see [Authorization](authorization.md#shared-database-composition) for why there is exactly
one pool, not two. one pool, not two.
## Database transport (TLS)
`core/src/database/transport.ts` is the one typed policy deciding how *every* Ekklesia executable
reaches PostgreSQL. It has exactly two modes:
| Mode | Meaning | Default in |
| --- | --- | --- |
| `verify-full` | TLS required, certificate chain verified, **and** the server's identity verified against the configured host. | production |
| `disable` | No TLS. Refused outright in production. | development/test |
There is deliberately no encryption-only mode (`require`), no chain-only mode (`verify-ca`), and no
`rejectUnauthorized: false` escape hatch: encryption without identity verification is exactly what
an interception attack defeats. Every loose or aliased spelling — `VERIFY-FULL`, `" verify-full"`,
`require`, `prefer`, `no-verify`, `true`, `1`, empty — is rejected with `DATABASE_TLS_MODE_INVALID`
in *both* environments, so a typo can never quietly become a weaker mode.
**Production has no plaintext exception — not even for a loopback database.** `disable` in
production fails with `DATABASE_TLS_REQUIRED_IN_PRODUCTION` whether `DATABASE_URL` points at a
remote host, `127.0.0.1`, `localhost`, or `[::1]`. A connection string cannot prove the database is
actually process-local, so no "same host, therefore safe" carve-out exists.
Verification is certificate *and* hostname, not encryption alone. The policy sets
`rejectUnauthorized: true` and an explicit `checkServerIdentity` pinned to the host parsed from
`DATABASE_URL`. That second part closes a real gap rather than restating a default: the pinned `pg`
driver sets TLS `servername` only for non-IP hosts, and Node performs **no** identity check when it
is unset — so `rejectUnauthorized: true` alone accepts a certificate from any host the trusted CA
ever signed when you connect by IP address. Both halves are proven against a real TLS-enabled
PostgreSQL in `core/tests/database-tls.integration.ts`.
**`DATABASE_URL` may not carry its own TLS parameters.** `sslmode`, `ssl`, `sslcert`, `sslkey`,
`sslrootcert`, `sslpassword`, `sslnegotiation`, and `uselibpqcompat` are all rejected
(case-insensitively) with `DATABASE_TLS_CONNECTION_STRING_CONFLICT`. This is not stylistic: the
pinned driver merges parsed connection-string values *over* explicitly passed options, so a
`?sslmode=disable` in the URL would silently replace the policy's verified `ssl` object. TLS is
decided in exactly one place, and it is never the URL.
For a database behind an internal CA, supply the CA's PEM text in `EKKLESIA_DATABASE_CA`; it is
added to the trusted set for verification and never appears in any error, log, or `pnpm doctor`
output. Loading it from a file path or a secret store is out of scope here, as are certificate
rotation and client-certificate authentication.
The same policy is resolved identically by every executable that opens a connection — `apps/api`,
`pnpm migrations:plan`, `pnpm migrations:apply`, the platform-owner bootstrap CLI, and
`pnpm doctor`'s own connection — through one shared environment reader
(`core/src/cli/database-transport-environment.ts`), so `process.env` stays out of Core's reusable
constructors. `apps/api` validates it in `createApiRuntimeConfig`, *before* any pool, Better Auth
instance, module runtime, or listener exists. `pnpm doctor` reports it as `database.transport` in
its PostgreSQL section: a verified production policy passes, and an unsafe or invalid one is a
blocking `NOT READY` failure — never a warning — naming only the effective mode, never the URL,
host, port, credentials, or CA material.
TLS protects the *connection*, not the exposure: it does not make an internet-reachable database
safe, and the network boundary remains the deployment's own responsibility. See
[ADR 0018](../architecture/decisions/0018-identity-session-resolution-and-database-transport.md).
## Graceful shutdown ## Graceful shutdown
`startApi()` (the composition function `apps/api/src/server.ts` exports, used by both the `startApi()` (the composition function `apps/api/src/server.ts` exports, used by both the
@@ -362,15 +585,18 @@ failure propagates: module-services/identity/authorization construction failing
was started yet — the frozen `ModuleRuntimeServices` object is built in the same step, from the was started yet — the frozen `ModuleRuntimeServices` object is built in the same step, from the
same pool); the server failing to bind its listen address; or an app-construction failure, both of same pool); the server failing to bind its listen address; or an app-construction failure, both of
which also stop the module runtime first, since it may have already started successfully. A module which also stop the module runtime first, since it may have already started successfully. A module
load/register/start failure uses Core's own existing rollback (already-started modules stop, in load/register/start failure uses Core's own rollback — the module whose `start` hook failed is
reverse order, before the failure propagates) and then closes the pool — there is no separate stopped first, through its own `stop` hook, then already-started modules in reverse order, each
under the same bounded per-hook deadline — before the failure propagates, and then closes the pool — there is no separate
runtime handle to stop in that case, since `startDiscoveredModuleRuntime` never returned one. runtime handle to stop in that case, since `startDiscoveredModuleRuntime` never returned one.
## A real signup → sign-in → principal → sign-out transcript ## A real signup → sign-in → principal → sign-out transcript
Captured from this PR's own integration test against a real, migrated, disposable PostgreSQL Captured from this PR's own integration test against a real, migrated, disposable PostgreSQL
database. Session tokens, cookie values, and generated ids below are sanitized/truncated examples, database, configured as **development** with public sign-up explicitly enabled — public sign-up is
not real credentials. disabled by default and cannot be enabled in production at all (see
[Public registration policy](#public-registration-policy)). Session tokens, cookie values, and
generated ids below are sanitized/truncated examples, not real credentials.
```text ```text
POST /api/auth/sign-up/email POST /api/auth/sign-up/email
+207 -23
View File
@@ -20,8 +20,10 @@ registered permission, Core-owned or module-owned; a module can register its own
have them mounted, enforced, and reachable in production (see have them mounted, enforced, and reachable in production (see
[API](api.md#module-http-routes)); and every enforced route fails closed on infrastructure trouble [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 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 tenants; there is no user-deletion endpoint, owner-management HTTP endpoint, or owner-recovery
is not exposed at all); there are no service accounts or API tokens, so only cookie-authenticated workflow (revoking or re-granting platform-owner status is not exposed at all, and deleting the last
owner does not reactivate bootstrap); 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 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 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. in this repository — the generic grants API structurally cannot create or revoke them either.
@@ -33,7 +35,41 @@ Established as a prior checkpoint on this branch (`ekklesia_core_platform_owner`
`AuthorizationStore`; `@ekklesia/contracts`'s `PermissionId`/`GrantId`/`AuthorizationScope` `AuthorizationStore`; `@ekklesia/contracts`'s `PermissionId`/`GrantId`/`AuthorizationScope`
branded types). This page does not restate that design — read `core/src/authorization/schema.ts`, 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 `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, the bootstrap operation, and HTTP enforcement — plus the Phase 5D pass 3
integrity rules recorded in
[ADR 0019](../architecture/decisions/0019-durable-authorization-history-and-grant-management.md).
### What user deletion removes, and what it does not
Deleting an identity user still cascades to that user's **live** authorization rows: their grants
(`ekklesia_core_grant`) and their platform-owner row (`ekklesia_core_platform_owner`) are removed,
so nothing continues authorizing a principal that no longer exists.
It does **not** remove security records. `ekklesia_core_authorization_audit.subject_user_id` is a
retained, non-null historical snapshot with no foreign key to the user table, and
`ekklesia_core_platform_owner_bootstrap` has no user foreign key at all. Both survive user
deletion. Previously the audit subject cascaded, so deleting a user destroyed exactly the record of
what they had been granted, by whom, and when it was revoked.
Only opaque identifiers are retained — a subject id and an actor id. No name, email, or other
profile data is ever copied into an authorization table.
**Append-only is an application convention, not a database guarantee.** Core's authorization store
exposes no update or delete path for audit rows. Nothing in PostgreSQL enforces that: no role,
trigger, or rule prevents a database administrator, a future migration, or anything else holding
the same credentials from modifying them. They are not cryptographically immutable.
### Deploying these changes
All of the above depends on Core migration `0002_authorization_integrity`, which creates
`ekklesia_core_platform_owner_bootstrap`, drops the cascading audit-subject foreign key, and
backfills consumed state. **Migrations remain explicit operator work and never run at application
startup** — apply `0002` with `pnpm migrations:apply` *before* starting the new code, exactly as
with every other migration. It is classified `{"mode":"none"}` in
`core/drizzle/meta/_ekklesia.json`: it creates a table, removes one referential constraint
specifically to *retain* data, and inserts one backfill row; it drops no table, column, or row.
Migrations `0000_identity_core_tables` and `0001_authorization_core_tables` are byte-identical and
retain their existing checksums.
## Permission registry ## Permission registry
@@ -236,13 +272,37 @@ twice.
`core/src/authorization/bootstrap.ts`'s `bootstrapPlatformOwner(db, {userId})` is a dedicated, `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 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 call the existing grant function."
`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 "Already consumed" means **the singleton row exists** in `ekklesia_core_platform_owner_bootstrap`
entry are all one PostgreSQL transaction. The very first statement inside that transaction is a — a dedicated Core-owned table with `id` pinned by a check constraint to the literal `'singleton'`
and **no foreign key to any user**. Core never deletes from it.
This is deliberately no longer "an owner row exists". That marker cascaded on user deletion, so
deleting the bootstrapped owner's user row removed the owner row *and* its audit evidence, after
which the one operation that establishes a platform's first administrator could be run again. The
operational consequence of the fix is explicit and intended: **deleting the last platform owner
does not reactivate bootstrap**, and neither does revoking one. Re-establishing an owner after that
requires a separately designed owner-management flow, which does not exist yet. There is no reset
command, deletion path, HTTP endpoint, environment override, or force flag.
Upgrading an existing installation is handled by Core migration `0002_authorization_integrity`,
which inserts the singleton if any platform-owner row already exists — active or revoked — so an
existing deployment cannot gain a fresh first use. It records no `consumed_for_user_id`, because a
backfill cannot know which of several historical owner rows the original single use targeted, and
guessing would fabricate history. An installation that never had an owner is left untouched and
keeps its one first use.
One upgrade limitation is irrecoverable: if an operator manually deleted the bootstrapped owner's
user row *before* this migration, the old cascade already erased both the owner row and its audit
evidence, and the migration has nothing left to detect — such an installation backfills as
never-bootstrapped. At the current checkpoint no supported Better Auth or API user-deletion path
exists that could produce this state; it requires direct database access.
The sentinel claim, the target user's existence check, the owner-grant insert, and its audit entry
are all one PostgreSQL transaction. Consumption is therefore tied to *success*: a missing target
user, a constraint violation, or any other failure rolls the sentinel back with everything else,
leaving the one legitimate first use available. The very first statement inside that transaction is a
transaction-scoped advisory lock: transaction-scoped advisory lock:
```sql ```sql
@@ -281,7 +341,7 @@ path, or stack — only a stable `code` and a fixed message:
| Code | Meaning | | Code | Meaning |
| --- | --- | | --- | --- |
| `PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED` | A platform-owner row already exists (any user, revoked or not). | | `PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED` | The durable bootstrap-consumed singleton exists; bootstrap has already been used and cannot be used again. |
| `PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND` | `--user-id` does not match an existing identity user. | | `PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND` | `--user-id` does not match an existing identity user. |
| `PLATFORM_OWNER_BOOTSTRAP_FAILED` | Any other failure (database/infrastructure). | | `PLATFORM_OWNER_BOOTSTRAP_FAILED` | Any other failure (database/infrastructure). |
@@ -337,28 +397,84 @@ generic HTTP API structurally cannot create or revoke a relationship-derived gra
| Route | Origin required | Notes | | Route | Origin required | Notes |
| --- | --- | --- | | --- | --- | --- |
| `GET /api/v1/grants?userId=<id>` | No | Lists the subject's explicit grants only (`sourceKind: "explicit"` filter applied server-side). | | `GET /api/v1/grants?userId=<id>&limit=<1..100>&cursor=<opaque>` | No | One bounded page of the subject's explicit grants only (`sourceKind: "explicit"` filter applied server-side on every page). |
| `POST /api/v1/grants` | Yes | Creates a new explicit grant. | | `POST /api/v1/grants` | Yes | Creates a new **unscoped** explicit grant. |
| `POST /api/v1/grants/:grantId/revoke` | Yes | Revokes an explicit grant without deleting it. | | `POST /api/v1/grants/:grantId/revoke` | Yes | Revokes an explicit grant, and every unrevoked equivalent of it, without deleting anything. |
`:grantId` must be the canonical lowercase hyphenated UUID PostgreSQL emits (`8-4-4-4-12` lowercase
hexadecimal). Uppercase, whitespace-padded, shortened, overlong, and non-UUID values are rejected
with `400 {"code":"INVALID_REQUEST"}` after authorization but strictly before any store or database
call — previously such a value reached PostgreSQL's own UUID comparison and returned an
indistinguishable `503`. A *canonical* id that does not exist still returns the sanitized `404`.
### `POST /api/v1/grants` request body ### `POST /api/v1/grants` request body
Only four keys are accepted; any other key present (`actor`, `owner`, `isOwner`, `sourceKind`, Only three keys are accepted; any other key present (`scope`, `actor`, `owner`, `isOwner`,
`sourceRef`, `id`, `createdAt`, `revokedAt`, `createdByActorId`, `effectivePermissions`, or `sourceKind`, `sourceRef`, `id`, `createdAt`, `revokedAt`, `createdByActorId`,
anything else) makes the **entire body** invalid rather than being silently dropped, so a caller `effectivePermissions`, or anything else) makes the **entire body** invalid rather than being
cannot smuggle a server-derived field through a request that otherwise looks well-formed: silently dropped, so a caller cannot smuggle a server-derived field through a request that
otherwise looks well-formed:
| Field | Required | Notes | | Field | Required | Notes |
| --- | --- | --- | | --- | --- | --- |
| `subjectUserId` | Yes | Must parse as a valid `EkklesiaUserId`. | | `subjectUserId` | Yes | Must parse as a valid `EkklesiaUserId`. |
| `permission` | Yes | Must parse as a valid `PermissionId` **and** be registered in 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`. | | `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 | Exactly `YYYY-MM-DDTHH:mm:ss.sssZ`, byte-for-byte equal to the parsed date's own `toISOString()`, and strictly in the future. |
| `expiresAt` | No | An ISO date string if present. |
The server always derives, and the request body can never supply: the grant id, `sourceKind` The server always derives, and the request body can never supply: the grant id, `sourceKind`
(always `"explicit"`), the actor (always the authenticated caller, via (always `"explicit"`), the actor (always the authenticated caller, via
`createUserActor(principal.userId)`), and all audit/timestamp fields. `createUserActor(principal.userId)`), and all audit/timestamp fields.
**The generic HTTP surface creates unscoped explicit grants only.** A `scope` field is rejected in
every form — including `null`, empty, malformed, and otherwise valid scope strings — with
`400 {"code":"INVALID_REQUEST"}` before any store mutation. No HTTP route middleware supplies a
scope to the evaluator today, so a scope accepted here would produce a grant that persists, appears
in listings, and can authorize nothing. Core's `AuthorizationScope`, the evaluator's exact-scope
semantics, and the store's scope column are unchanged and remain available to trusted internal
callers; listing still shows a scope on historical or internally created grants. **Scopes cannot
authorize a module HTTP route today**, and no route-parameter-to-scope resolver was added.
`expiresAt` deliberately accepts one representation and nothing else. Rejected: offsets
(`2027-01-01T00:00:00.000+02:00`), missing milliseconds (`2027-01-01T00:00:00Z`), excess fractional
precision, lowercase separators, whitespace padding, date-only (`2027-01-01`), locale forms
(`March 5, 2027`), extra trailing text, normalized impossible dates (`2026-02-31T00:00:00.000Z`,
which bare `new Date()` silently rewrote to March 3rd), and any instant at or before the acceptance
clock. Core's store independently rejects an invalid or non-future `Date` before any SQL, so a
trusted internal caller cannot create a dead-on-arrival grant either. A rejected expiration creates
no grant and no audit row. There is no maximum lifetime, renewal workflow, scheduler, or background
expiration job.
### Duplicate creation and all-equivalent revocation
Two grant issuances are the **same authorization source** when all five of these are equal, with
`NULL` equal only to `NULL`: `user_id`, `permission_id`, `scope`, `source_kind`, `source_ref`.
`expires_at`, creation metadata, actor metadata, and the grant id do not distinguish them.
Creating a grant while an equivalent one is *currently effective* (`revoked_at IS NULL` and
`expires_at IS NULL OR expires_at > now()`) fails with
`409 {"code":"AUTHORIZATION_GRANT_ALREADY_EXISTS"}` and `Cache-Control: no-store`. The response
names no existing grant id, actor, scope, source reference, expiry, database detail, SQL, or stack —
only the code. A rejected duplicate creates no grant and no audit row. A revoked or already-expired
equivalent does not block a fresh issuance. This is a conflict rather than an idempotent return of
the existing grant: the caller asked to record a new authorization with their own actor and expiry,
and answering "done" while recording neither would hide that nothing happened.
Enforcement is database-backed, not process-local: every create and revoke touching one equivalence
tuple takes a transaction-scoped `pg_advisory_xact_lock` keyed by a hash of that tuple, so multiple
API processes serialize identically. Its single-key lock space is disjoint from the two-key space
used by the migration runner and platform-owner bootstrap.
**One revocation revokes every unrevoked equivalent**, including expired-but-unrevoked legacy rows,
in one transaction, writing one `grant.revoke` audit row per grant actually revoked, each carrying
that row's own grant id. Grants with a different subject, permission, scope, source kind, or source
reference are untouched. This replaces behavior where revoking one of two equivalent grants returned
`success` while the other kept authorizing the same user. The public success response remains
`200 {"success": true}`.
Historical duplicates are **not** silently revoked by any migration. The store now prevents creating
another effective equivalent, and an operator's explicit revocation resolves the whole equivalence
class.
### Response shapes ### Response shapes
A grant, wherever returned: A grant, wherever returned:
@@ -376,9 +492,48 @@ A grant, wherever returned:
} }
``` ```
`GET /api/v1/grants` → `200 {"grants": [...]}`. `POST /api/v1/grants` → `201 {"grant": {...}}`. `POST /api/v1/grants` → `201 {"grant": {...}}`.
`POST /api/v1/grants/:grantId/revoke` → `200 {"success": true}`. `POST /api/v1/grants/:grantId/revoke` → `200 {"success": true}`.
`GET /api/v1/grants` → `200 {"grants": [...], "nextCursor": <string|null>}`.
### Listing is bounded keyset pagination
```text
GET /api/v1/grants?userId=<id>&limit=<1..100>&cursor=<opaque>
```
| Parameter | Required | Notes |
| --- | --- | --- |
| `userId` | Yes | The subject whose explicit grants to list. |
| `limit` | No | Canonical base-10 integer, 1 through 100. Defaults to 50. |
| `cursor` | No | An opaque position string returned as a previous page's `nextCursor`. |
Unknown query keys, duplicated `userId`/`limit`/`cursor` keys, empty values, non-canonical numbers
(`0`, `101`, `-1`, `+5`, `01`, `1.5`, `1e2`, whitespace-padded), and malformed cursors are all
rejected with `400 {"code":"INVALID_REQUEST"}` before any store or database work. Permission
enforcement completes first, so query validation never discloses a validation distinction — or the
existence of any subject or grant — to an unauthorized caller.
Rows are ordered `created_at DESC, id DESC`, with a strict keyset boundary on **both** values, so
grants sharing a `created_at` can neither be skipped nor repeated across pages. At most `limit + 1`
rows are read internally and at most `limit` returned. The final page returns `"nextCursor": null`.
Relationship grants never appear on any page. There is no total count, offset pagination, search, or
sorting option.
The cursor is a small versioned payload containing only the subject user id, boundary creation
timestamp, and boundary grant id, encoded as canonical unpadded base64url. Decoding is strict —
exact version, exact keys, a subject matching the *requested* one, a canonical `toISOString()`
timestamp, a canonical `GrantId`, and a re-encoding that reproduces the supplied string
byte-for-byte — and a rejected cursor reveals nothing it decoded.
**It is a pagination position, not an authorization token.** It is deliberately unsigned: the
permission check and the server-side subject filter remain authoritative on every page, so a forged
cursor can at most name a different position within grants the caller may already list. It carries
no permission, scope, source reference, actor data, credential, or secret, and it is not a
consistency snapshot — rows created or revoked between pages are seen or not seen according to the
ordering.
### Status-code mapping ### Status-code mapping
| Status | When | | Status | When |
@@ -387,7 +542,8 @@ A grant, wherever returned:
| `400 {"code":"INVALID_REQUEST"}` | Malformed query/body/param (including a disallowed extra field). | | `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":"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. | | `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. | | `404 {"code":"AUTHORIZATION_GRANT_NOT_FOUND"}` | The canonical grant id does not exist as an unrevoked *explicit* grant — this is also what revoking a real relationship-derived grant's id looks like, indistinguishably. |
| `409 {"code":"AUTHORIZATION_GRANT_ALREADY_EXISTS"}` | An equivalent grant is already currently effective for the subject. |
| `503` | Principal resolution, evaluation, or store failure (`PRINCIPAL_RESOLUTION_FAILED`, `AUTHORIZATION_EVALUATION_FAILED`, or an `AuthorizationStoreError` code). | | `503` | Principal resolution, evaluation, or store failure (`PRINCIPAL_RESOLUTION_FAILED`, `AUTHORIZATION_EVALUATION_FAILED`, or an `AuthorizationStoreError` code). |
Every response sets `Cache-Control: no-store`. Every response sets `Cache-Control: no-store`.
@@ -416,6 +572,14 @@ unaffected and remain available for standalone/testable use — the CLI bootstra
`createAuthorizationDrizzleDatabase` directly for exactly that reason, since it only ever needs `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. the authorization schema and has no reason to share a pool with anything else.
Both the shared pool and the per-domain factories accept the same optional `transport` policy —
Ekklesia's single PostgreSQL TLS decision, described in
[API § Database transport (TLS)](api.md#database-transport-tls). Every executable that opens a
connection (the API, both migration commands, the platform-owner bootstrap CLI, and `pnpm doctor`)
resolves it the same way, so production connections are TLS-verified regardless of which factory
built them. Omitting it is a development/test affordance only: no `ssl` option is set and the
connection is plaintext, which a production configuration refuses outright.
`apps/api/tests/lifecycle.test.ts` proves this composition explicitly: exactly one `apps/api/tests/lifecycle.test.ts` proves this composition explicitly: exactly one
`new Pool(...)` call for a full `startApi()` lifecycle (covering identity, authorization, and `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 module database usage), and exactly one `pool.end()` call regardless of how many concurrent
@@ -445,7 +609,23 @@ module database usage), and exactly one `pool.end()` call regardless of how many
being imported. being imported.
- `core/tests/authorization-bootstrap.integration.ts` (live PostgreSQL) — successful bootstrap and - `core/tests/authorization-bootstrap.integration.ts` (live PostgreSQL) — successful bootstrap and
its audit row, nonexistent-user rollback, second-bootstrap rejection, a revoked historical owner its audit row, nonexistent-user rollback, second-bootstrap rejection, a revoked historical owner
still blocking reuse, and the concurrency scenario described above. still blocking reuse, and the concurrency scenario described above. Pass-3 scenarios add: exactly
one consumed row plus one owner row plus one audit row in one transaction; deleting the owner's
user row keeping the sentinel and the audit history while removing the live owner row and not
reactivating bootstrap; a grant subject's create/revoke audit rows surviving that subject's
deletion while their live grant cascades; a forced failure after the sentinel was claimed rolling
everything back so the one first use is not burned; and migration `0002` backfilling consumed
state for an active *and* a revoked existing owner while leaving a never-owned installation with
exactly one first use.
- `core/tests/authorization-store.integration.ts` (live PostgreSQL) — pass-3 grant-integrity
scenarios: sequential and six-way concurrent equivalent creation yielding exactly one grant and
one create audit row; a differing expiry not bypassing the conflict; different subject/permission/
scope/source-kind/source-ref being non-equivalent; fresh issuance after a revoked or expired
equivalent; one revocation clearing every unrevoked equivalent (including an expired-but-unrevoked
one) with per-row audit entries while leaving differently scoped or sourced grants untouched; a
concurrent create/revoke race serializing without an unexplained duplicate; a forced transaction
failure rolling back every grant and audit change; and bounded keyset pagination traversing seven
rows that all share one `created_at` with no duplicates or omissions.
- `apps/api/tests/authorization-middleware.test.ts` — allow/deny/unregistered/infrastructure- - `apps/api/tests/authorization-middleware.test.ts` — allow/deny/unregistered/infrastructure-
failure paths for `requirePermission`, and `requireTrustedOrigin`'s allow/reject behavior; failure paths for `requirePermission`, and `requireTrustedOrigin`'s allow/reject behavior;
a dedicated block proves `requirePermission` enforces module-owned `public`/`authenticated`/ a dedicated block proves `requirePermission` enforces module-owned `public`/`authenticated`/
@@ -458,7 +638,11 @@ module database usage), and exactly one `pool.end()` call regardless of how many
unexpected-exception fallback to a fixed generic detail, schema/migration checks continuing unexpected-exception fallback to a fixed generic detail, schema/migration checks continuing
independently after a composition failure, and the exact documented terminal report shape. independently after a composition failure, and the exact documented terminal report shape.
- `apps/api/tests/grants-routes.test.ts` — all three routes' allow/deny paths, disallowed-field - `apps/api/tests/grants-routes.test.ts` — all three routes' allow/deny paths, disallowed-field
and malformed-field rejection, a well-formed but unregistered target permission rejected with and malformed-field rejection (including every form of a `scope` field), canonical-`GrantId` and
canonical-future-`expiresAt` rejection with the store never called, the sanitized `409` conflict,
the full pagination contract (default/boundary limits, non-canonical limit rejection, unknown and
duplicated query keys, multi-page traversal, and every malformed or foreign cursor),
a well-formed but unregistered target permission rejected with
`403` and `store.createGrant` never called, exact server-derived-argument assertions, `403` and `store.createGrant` never called, exact server-derived-argument assertions,
sourceKind-filtered revoke returning 404 for a non-explicit grant, origin enforcement, and sourceKind-filtered revoke returning 404 for a non-explicit grant, origin enforcement, and
store-error mapping. store-error mapping.
+75 -8
View File
@@ -68,7 +68,7 @@ under the reserved source identifier `"core"`, and
aggregate, **Core first**. An installed module manifest that ever declares `id: "core"` is rejected aggregate, **Core first**. An installed module manifest that ever declares `id: "core"` is rejected
at discovery time so Core and module migration identities can never collide. at discovery time so Core and module migration identities can never collide.
The transactional runner, advisory lock, breakpoint splitter, and the fixed The transactional runner, advisory lock, whole-batch SQL execution, and the fixed
`public.ekklesia_core_migrations` history table (ADR 0009) are unchanged: Core's migration honestly `public.ekklesia_core_migrations` history table (ADR 0009) are unchanged: Core's migration honestly
populates the existing `module_id` column with the literal value `"core"`, exactly like a module populates the existing `module_id` column with the literal value `"core"`, exactly like a module
populates it with its own module ID. There is exactly one migration system and one executor. populates it with its own module ID. There is exactly one migration system and one executor.
@@ -82,6 +82,24 @@ pnpm migrations:apply # locked, transactional application of the pending bat
pnpm doctor # includes Core's migration in its discovery/plan checks pnpm doctor # includes Core's migration in its discovery/plan checks
``` ```
Both `migrations:plan` and `migrations:apply` connect with fixed, finite defaults — a 10-second
connection deadline, a 10-second client query timeout, and a 10-second PostgreSQL
`statement_timeout` — applied at the one shared construction boundary so no command can omit them.
A `DATABASE_URL` that tries to set `statement_timeout`, `query_timeout`, `connectionTimeoutMillis`,
`connect_timeout`, or an `options` value naming `statement_timeout` is refused with a sanitized
configuration error. Only `statement_timeout` actually overrides the selected policy under the
pinned driver (measured: `?statement_timeout=0` made the server report `0`); the others are rejected
as unsupported configuration and defense in depth — `query_timeout` breaks the client rather than
loosening it, and `connectionTimeoutMillis` pollutes a driver field the pure-JS client never
consults. Unrelated parameters are untouched. This bounds each individual operation; it does **not**
promise that a migration completes within ten seconds across retries or cleanup. Migrations remain explicit operator work and never run at application
startup.
Each pending migration's SQL is sent to PostgreSQL as **one complete batch**, inside the existing
transaction, so the server does all SQL parsing — Drizzle's `--> statement-breakpoint` lines are
ordinary `--` comments to it. No SQL parser exists in this repository, and the marker's bytes keep
their ordinary meaning inside a string literal, dollar-quoted body, quoted identifier, or comment.
No new commands are required to include identity: it flows through the existing migration No new commands are required to include identity: it flows through the existing migration
commands automatically. A fresh database applies the Core identity migration through commands automatically. A fresh database applies the Core identity migration through
`pnpm migrations:apply` like any other pending migration; a second application is a genuine no-op. `pnpm migrations:apply` like any other pending migration; a second application is a genuine no-op.
@@ -126,12 +144,28 @@ intentionally contains no real values — configure them per environment, never
| Value | Requirement | | Value | Requirement |
| --- | --- | | --- | --- |
| Secret | At least 32 characters, high entropy (for example `openssl rand -base64 32`). No built-in default is ever provided; a missing or too-short secret is rejected explicitly. | | Secret | At least 32 characters, high entropy (for example `openssl rand -base64 32`). No built-in default is ever provided; a missing or too-short secret is rejected explicitly. |
| Base URL | An absolute `http`/`https` URL. In production, `https` is required and loopback hosts are rejected. | | Base URL | Exactly one canonical `http`/`https` **origin** — scheme, host, and non-default port only. In production, `https` is required and loopback/unspecified hosts are rejected. |
| Trusted origins | One or more absolute `http`/`https` origins with no path, query, or fragment. Wildcards are rejected in this slice. In production, `https` is required and loopback hosts are rejected. | | Trusted origins | One or more canonical `http`/`https` origins. Wildcards are rejected in this slice. In production, `https` is required and loopback/unspecified hosts are rejected. |
Local development origins (for example `http://localhost:3000`) are only accepted when the caller **"Canonical origin" is literal**: the value must equal the WHATWG URL parser's own origin
explicitly passes `environment: "development"` — never silently. Every configuration failure serialization of itself. A path (`https://id.example.test/auth`), a bare trailing slash
raises a sanitized `IdentityConfigError` carrying only a stable code, never the offending value. (`https://id.example.test/`), a query, a fragment, embedded credentials, an explicitly written
default port (`https://id.example.test:443`), or non-canonical case is rejected with
`BASE_URL_INVALID`/`TRUSTED_ORIGIN_INVALID` rather than normalized behind your back — so the stored
value always matches what a browser actually sends in an `Origin` header.
Production rejects loopback and unspecified hosts by classifying the address, not by comparing it
against a short list: `localhost` and the whole reserved `.localhost` namespace (RFC 6761), all of
`127.0.0.0/8` (not just `127.0.0.1`), `0.0.0.0`, `::1` in both compressed and expanded spellings,
`::`, and IPv4-mapped forms such as `::ffff:127.0.0.1`. Local development origins (for example
`http://localhost:3000`) are only accepted when the caller explicitly passes
`environment: "development"` — never silently. Every configuration failure raises a sanitized
`IdentityConfigError` carrying only a stable code, never the offending value.
This is a guarantee about the *shape* of the configured value and nothing more. It does not resolve
DNS and does not prove reachability: a public hostname that resolves to a loopback or private
address passes this validation, and a firewall, network policy, or reverse proxy — not URL
parsing — is what actually determines who can reach a deployment.
Secret rotation (Better Auth's plural `secrets` list) and cross-subdomain cookies are deferred; see Secret rotation (Better Auth's plural `secrets` list) and cross-subdomain cookies are deferred; see
ADR 0010. ADR 0010.
@@ -144,7 +178,8 @@ ADR 0010.
`handleAuthRequest(request): Promise<Response>` (delegates directly to Better Auth's handler; its `handleAuthRequest(request): Promise<Response>` (delegates directly to Better Auth's handler; its
normal responses, including its own error responses, pass through unmodified) and normal responses, including its own error responses, pass through unmodified) and
`resolvePrincipal(headers): Promise<EkklesiaPrincipal>` (never silently downgrades a database `resolvePrincipal(headers): Promise<EkklesiaPrincipal>` (never silently downgrades a database
outage to an anonymous result — see ADR 0011 for exactly how). Neither method, nor anything else outage to an anonymous result — see ADR 0011 and [session resolution](#how-a-principal-is-resolved)
below). Neither method, nor anything else
exported from `@ekklesia/core/identity`, exposes `auth.api`, Better Auth's session/user types, the exported from `@ekklesia/core/identity`, exposes `auth.api`, Better Auth's session/user types, the
Drizzle adapter, the raw database, a session token, or Better Auth configuration. `apps/api` is the Drizzle adapter, the raw database, a session token, or Better Auth configuration. `apps/api` is the
only current consumer of this boundary; see [API](api.md). only current consumer of this boundary; see [API](api.md).
@@ -164,7 +199,39 @@ pinned to `8`/`128` instead of left on Better Auth's fallback default.
`operationTimeoutMs`, applied as both `pg`'s `query_timeout` and `statement_timeout` — bounding a `operationTimeoutMs`, applied as both `pg`'s `query_timeout` and `statement_timeout` — bounding a
hung or slow query, which `connectionTimeoutMs` alone cannot do since it only bounds establishing hung or slow query, which `connectionTimeoutMs` alone cannot do since it only bounds establishing
the connection. `apps/api` sets both timeouts explicitly (see [API](api.md)); an invalid value is the connection. `apps/api` sets both timeouts explicitly (see [API](api.md)); an invalid value is
rejected the same sanitized way an invalid connection string already is. rejected the same sanitized way an invalid connection string already is. It also accepts an optional
`transport` policy — the PostgreSQL TLS decision described in
[API § Database transport](api.md#database-transport-tls); every production executable passes one.
## How a principal is resolved
`resolvePrincipal(headers)` returns the anonymous principal for a missing, expired, revoked, or
malformed session, and throws a sanitized `PRINCIPAL_RESOLUTION_FAILED` for an infrastructure
failure. Telling those apart takes explicit work, because Better Auth's `getSession` swallows
internal failures and returns `null` for them exactly as it does for a genuinely absent session.
1. **No cookies, or only unrelated cookies → no identity database work at all.** `Cookie: x=y` is
not evidence that Better Auth ever attempted to resolve an Ekklesia session, so its `null` is
unambiguous and the anonymous principal is returned immediately. An unauthenticated caller
cannot drive identity database load with a cookie header they chose themselves.
2. **The configured session cookie is present, and Better Auth returned `null` → the ambiguous
case, and only then is the session store checked.** Detection is an exact, whole-name cookie
comparison (RFC 6265 parsing; names are case-sensitive and never percent-decoded), so
`better-auth.session_token_x` and `x_better-auth.session_token` are correctly *not* the session
cookie. The name itself is derived from the same options object passed to Better Auth, via its
own exported `getCookies` helper — including the `__Secure-` prefix an `https` base URL implies
— so there is no second copy of it to drift.
3. **The check reads the real session relation**, not `select 1`: it plans a read of the exact
columns the Better Auth adapter depends on under a `where false` predicate. PostgreSQL resolves
the relation, every named column, and the caller's `SELECT` privilege while matching zero rows,
so no session row, token, or user id can be read, logged, or enumerated by the check. It runs on
the injected pool — never a second connection — under a bounded 3-second timeout.
A missing session table, a dropped or drifted column, a revoked `SELECT` privilege, an unreachable
database, and a query timeout therefore all produce a sanitized
`503 {"code":"PRINCIPAL_RESOLUTION_FAILED"}` — never a silent anonymous result. The response body,
logs, and error carry no relation name, column name, SQL, driver message, host, or stack. See
[ADR 0018](../architecture/decisions/0018-identity-session-resolution-and-database-transport.md).
## What is not operational yet ## What is not operational yet
+7 -6
View File
@@ -108,9 +108,10 @@ Data definitions
✓ Schemas validated ✓ Schemas validated
0 modules · 0 tables 0 modules · 0 tables
✓ Migrations validated ✓ Migrations validated
2 migrations · checksums valid 3 migrations · checksums valid
core:0000_identity_core_tables · f8f2086ef06f core:0000_identity_core_tables · f8f2086ef06f
core:0001_authorization_core_tables · 830e1fbc26d0 core:0001_authorization_core_tables · 830e1fbc26d0
core:0002_authorization_integrity · 634294731180
PostgreSQL PostgreSQL
○ Connection check skipped ○ Connection check skipped
@@ -133,11 +134,11 @@ Result: READY WITH WARNINGS
Doctor is a local, human-readable summary. `pnpm verify`, `pnpm ci:verify`, infrastructure smoke, Doctor is a local, human-readable summary. `pnpm verify`, `pnpm ci:verify`, infrastructure smoke,
and migration smoke remain the authoritative automated evidence because they exercise formatting, and migration smoke remain the authoritative automated evidence because they exercise formatting,
boundaries, types, tests, builds, containers, and live mutation/rollback behavior that Doctor does boundaries, types, tests, builds, containers, and live mutation/rollback behavior that Doctor does
not repeat. Core authorization and an explicit platform-owner bootstrap command are the next not repeat. Core authorization and the explicit platform-owner bootstrap command have since shipped,
architectural phase; Doctor does not diagnose authorization, the `apps/api` HTTP transport, runtime and Doctor reports the composed permission registry; it still does not diagnose grant evaluation, the
module health, or production storage yet, and it never constructs an operational Better Auth `apps/api` HTTP transport, runtime module health, or production storage, and it never constructs an
instance, starts the API, or claims sign-in works. See [API](api.md) for `pnpm api:start` and the operational Better Auth instance, starts the API, or claims sign-in works. See [API](api.md) for
identity HTTP transport this repository now has. `pnpm api:start` and the identity HTTP transport this repository now has.
Migration planning and application require `DATABASE_URL`. Keep it in the uncommitted `.env` Migration planning and application require `DATABASE_URL`. Keep it in the uncommitted `.env`
created from `.env.example`: created from `.env.example`:
+235
View File
@@ -0,0 +1,235 @@
# Security model and deployment trust boundaries
The one canonical statement of what Ekklesia's boundaries actually protect against today, and what
they do not. Read it before installing a module, exposing the API, or reasoning about any other
document's guarantees.
Related: [ADR 0006](../architecture/decisions/0006-versioned-capabilities-and-compiled-module-runtime.md)
(compiled module runtime), [ADR 0016](../architecture/decisions/0016-module-database-access.md)
(module database access), [ADR 0020](../architecture/decisions/0020-module-runtime-and-migration-execution-hardening.md)
(module routing/lifecycle and migration execution), and
[Module development](../module-development/README.md).
## Installed modules are trusted compiled backend code
**Installing a module is equivalent to deploying trusted server code.** It is not equivalent to
installing a browser extension, a plugin from a marketplace, or anything else with a security
boundary around it. There is no such boundary here.
A module's `dist/server` entrypoint is imported into, and runs arbitrary JavaScript inside, the
`apps/api` process. Within the operating-system account that process runs as, a module can:
- read and write any file the account can reach, including `.env`, TLS material, and the source tree;
- read `process.env`, `process.argv`, and any other process state;
- open outbound network connections and bind listeners;
- import any installed dependency, including ones Ekklesia never intended it to use;
- monkey-patch Core's own exports, Node built-ins, `globalThis`, or another module's objects;
- keep running work after its lifecycle hooks return.
It also **shares the API's single PostgreSQL role and connection pool**. `ModuleDatabase.execute`
accepts arbitrary SQL, so a module has exactly the database authority the API has — every table in
the database, including `ekklesia_core_user`, `ekklesia_core_grant`,
`ekklesia_core_platform_owner`, `ekklesia_core_platform_owner_bootstrap`,
`ekklesia_core_authorization_audit`, and every other module's tables.
## What the module-facing mechanisms actually are
These are **correctness and coordination boundaries**. Every one of them assumes the module is
cooperative. None of them constrains a module that is not:
| Mechanism | What it actually provides |
| --- | --- |
| `ekklesia_*` table prefixes and schema discovery | Ownership clarity and collision avoidance between cooperating modules |
| TypeScript types, branded ids, contracts | Compile-time correctness; erased at runtime |
| `ekklesia.module.json` manifest and `manifestVersion` | Declared compatibility and dependency ordering |
| Permission declarations and the composed registry | Which permissions *this* module's own routes may name, checked when Core mounts them |
| Route validation, overlap rejection, body limits | Deterministic, unambiguous HTTP routing and request bounds |
| `Object.freeze` on contexts and services | Prevents accidental mutation; a module can still reach the underlying objects |
| The `ModuleDatabase` facade | A typed, ergonomic surface — not a privilege reduction, since `execute` is arbitrary SQL |
| Capability registry | Explicit inter-module wiring, not access control |
| Bounded stop/rollback deadlines (ADR 0020) | The platform stops *waiting*; module code is not cancelled or terminated |
**There is no process, container, VM, seccomp/syscall, filesystem, network, PostgreSQL role,
schema-permission, table-ACL, row-level-security, tenant, or resource sandbox between a module and
Core.** A module is not isolated from Core, from other modules, or from the host.
## What this platform must not claim
Ekklesia today has **none** of the following, and no document, README, or marketing surface may
imply otherwise:
- module signing, provenance attestation, or verified publishers;
- a marketplace, registry, or review process for third-party modules;
- sandboxed or hot-installed modules;
- per-module database roles, schemas, or credentials;
- tenant isolation;
- runtime resource limits (CPU, memory, file descriptors, connections) per module.
## Operator rule
**Only install modules whose source, provenance, and build output you have reviewed and whose
authors you trust with your production server and database.** Treat a module the way you would
treat a direct commit to `apps/api`: read the code, verify what artifact was built from it, pin it,
and re-review on upgrade. Do not install a module because a manifest looks well-formed — every
validation Ekklesia performs on a manifest is satisfied trivially by hostile code.
Installing, removing, or editing a module requires an API restart; there is no hot install path,
and none should be added while this model holds.
## What would have to change before accepting untrusted modules
Accepting third-party modules that have not been reviewed is an architectural change, not a
configuration change. At minimum it would require:
- process or container isolation per module, with the API as a supervisor;
- capability-based RPC across that boundary instead of in-process object passing;
- a separate PostgreSQL role and schema per module, with no shared superuser-equivalent pool;
- restricted filesystem and network access per module;
- CPU/memory/connection limits and enforced timeouts that can actually terminate work;
- signed, verifiable module provenance and a defined review/revocation process.
Until that exists, the trusted-code model above is the security model.
## Current deployment state
As of this branch, **Ekklesia is not deployed**. There is no production API instance, no reverse
proxy, no public listener, and no deployment automation of any kind in this repository — no deploy
script, no CI deployment step, no container publish, no orchestration manifest. No `.env` has ever
been committed; only `.env.example`.
Two production defaults are relevant but are **not** by themselves a deployment guarantee:
- `apps/api` defaults its listen address to loopback (`127.0.0.1`), so it is not reachable off-host
unless an operator explicitly changes it;
- production configuration refuses to enable public signup at all
(`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION`).
A loopback default is not a firewall, and a config default is not a verified boundary.
### Launch checklist — each item must be proven against the real deployment before public exposure
Until every one of these is verified on the actual hosts, the deployment is not launch-ready. None
of them can be satisfied by reading this repository.
This checklist gates a **public launch**. It does not gate development: Surfaces, modules, and Core
work all proceed against the local development infrastructure while every item below is still
outstanding.
1. The reverse-proxy path serves the API successfully.
2. The origin port is unreachable from the public Internet.
3. The origin port is also unreachable from any untrusted network/VLAN — only the intended private
proxy path connects. Security must not depend on an unpublished DNS name or origin IP.
4. The proxy **overwrites** (never appends to, never passes through) the client-IP header Ekklesia
is configured to trust.
5. Rotating a caller-supplied `Forwarded`, `X-Forwarded-For`, `CF-Connecting-IP`, `X-Real-IP`, or
the platform's own internal sanitized header cannot rotate the authentication rate-limit bucket.
6. The actual immediate proxy peer is in Ekklesia's explicit trusted-proxy set, and an untrusted
direct peer's forwarded header is ignored.
7. Proxy logs do not record cookies, authorization values, request bodies, or raw forwarding chains.
8. PostgreSQL is reachable only from the API host, over TLS, with the certificate and hostname
verification Ekklesia already requires.
### Rate limiting is per-process today
Better Auth's authentication rate limiter is **in-memory and per API process**. That is acceptable
only while **both** of the following hold, which is the current state:
- production public signup is disabled (and cannot be enabled — see above); and
- exactly one API instance serves traffic.
If either changes — public signup is enabled, or a second instance is added — a durable shared or
edge rate limiter becomes mandatory, and it must be proven that its counters are shared across
instances, survive process restart, and cannot be re-bucketed by a spoofed client-IP header.
Ekklesia does not ship one. Do not describe the current limiter as distributed throttling.
## Continuous integration boundary
CI runs on self-hosted Gitea Actions runners. The inventoried server is **Gitea 1.24.2**, and that
version determines which parts of the workflow are real guarantees and which are not.
### The current operating rule: trusted code only
**This CI installation is a trusted-code-only boundary.** Only code controlled and reviewed by
trusted repository collaborators may execute on the self-hosted runner. An unknown or untrusted pull
request must **not** execute on it automatically — a maintainer reviews the diff, including any
change to the workflow itself, before it runs.
This is an operating rule, not an enforced technical control, and it is the reason the rest of this
section is safe today. Everything below describes what the repository can and cannot prove about
that runner; none of it is a substitute for the rule.
### What is true of the repository today
These are properties of files in this repository, enforced by the action or the package manager,
independent of server version:
- every action reference is pinned by a full immutable commit SHA;
- dependencies install with `pnpm install --frozen-lockfile`;
- there is no `pull_request_target` workflow;
- no repository, organization, or instance secret is referenced anywhere in the workflow;
- every checkout step sets `persist-credentials: false`;
- the workflow declares `permissions: contents: read`.
### What `persist-credentials: false` actually proves
It proves exactly one thing: `actions/checkout` does not write its credential into `.git/config`,
so a later step in the same job cannot read or reuse it from there.
It does **not** remove the short-lived `GITEA_TOKEN` from the job environment, and it does **not**
reduce that token's server-side authority. A step that wants the token can still read it.
### What Gitea 1.24 ignores — so the branch cannot claim it
Per the official comparison for this version
(<https://docs.gitea.com/1.24/usage/actions/comparison>), Gitea 1.24's unsupported workflow syntax
includes `permissions` and `jobs.<job_id>.permissions`, `concurrency`, and
`jobs.<job_id>.timeout-minutes`. On this server those declarations are inert. Therefore:
- **The workflow's `permissions: contents: read` does not restrict the job token.** This branch
cannot claim the `GITEA_TOKEN` is read-only, because nothing in workflow YAML makes it so on
1.24. Job-token permission controls, repository/owner defaults, and server-side
maximum-permission clamping arrive in the 1.26 feature line
(<https://docs.gitea.com/1.26/usage/actions/token-permissions>).
- **`concurrency` and `timeout-minutes` are not bounded-execution guarantees here.** Cancellation,
timeout, and teardown must be enforced by a trusted provisioner and reaper outside the workflow.
The declarations are kept deliberately: they are already correct for a supported Gitea release and
for GitHub Actions, and keeping them means the upgrade does not require re-deriving the intent. They
are documented as ignored, not removed.
### Runner topology is not verified
**The runner topology is not verified in this repository and is not documented here as though it
were.** A pull request can edit `runs-on` and select any runner label its repository can reach, so a
workflow file cannot be the boundary. Whether untrusted pull-request jobs execute on a disposable
guest, and whether any runner reachable from this repository holds deployment authority or a host
Docker socket, must be established against the actual Gitea instance and runner hosts. Until that
evidence exists, treat pull-request CI as executing with whatever authority the runner host actually
grants.
### Requirements before executing untrusted contributions
These five items are the prerequisites for **relaxing the trusted-code-only rule above** — that is,
for letting code this project has not reviewed execute on this Gitea installation. They are backlog
requirements for that specific future capability. **They do not block Phase 6 Surface work**, and
they are not conditions on merging trusted, reviewed work.
None of them can be satisfied by editing this repository; each requires access to the Gitea server
and runner hosts, and each must be proven there.
1. Upgrade Gitea to a reviewed supported release with job-token permission controls — at least the
1.26 feature line. Select the actual release during infrastructure work, after backup and
compatibility review; do not pin a version from this document.
2. Configure repository and owner defaults, and the server-side maximum permission, so workflow
code cannot request more than read-only repository access.
3. Prove against the real server that the job's `GITEA_TOKEN` cannot push code, create or update
releases, packages, issues, or pull requests, change Actions configuration, or reach another
private repository.
4. Enforce job timeout, cancellation, concurrency, and teardown in the trusted
provisioner/reaper rather than relying on the ignored workflow keys above.
5. Complete a disposable-guest runner isolation proof: one throwaway guest per job, torn down by a
trusted reaper outside workflow YAML, holding no deployment credential, registration token, or
reusable key.
Moving future public contribution workflows to a service that provides isolated hosted runners — for
example GitHub — is an accepted alternative to satisfying items 1–5 on this installation.
+66 -5
View File
@@ -14,15 +14,22 @@ module migration discovery, identity schema drift detection, formatting, linting
Migration-runner unit tests use a narrow database/session port to prove deterministic planning, Migration-runner unit tests use a narrow database/session port to prove deterministic planning,
approval, transaction orchestration, cleanup, and sanitized errors. They do not claim to prove approval, transaction orchestration, cleanup, and sanitized errors. They do not claim to prove
PostgreSQL behavior. Identity unit tests cover branded opaque-ID contracts, principal shapes, PostgreSQL behavior. Identity unit tests cover branded opaque-ID contracts, principal shapes,
configuration validation (secret, base URL, and trusted-origin rules), bounded/validated configuration validation (secret rules, canonical-origin-only base URL and trusted origins, and the
full loopback/unspecified classifier production rejects — `.localhost`, all of `127.0.0.0/8`,
`0.0.0.0`, `::1` compressed and expanded, `::`, and IPv4-mapped forms), bounded/validated
connection and operation (`query_timeout`/`statement_timeout`) database timeouts, the Core identity connection and operation (`query_timeout`/`statement_timeout`) database timeouts, the Core identity
runtime (explicit construction, exact schema mapping, only email/password and rate limiting runtime (explicit construction, exact schema mapping, only email/password and rate limiting
enabled, an explicit `advanced.disableCSRFCheck`/`disableOriginCheck: false` pair, an explicit enabled, an explicit `advanced.disableCSRFCheck`/`disableOriginCheck: false` pair, an explicit
error-only sanitized logger that strips Better Auth's own message and arguments regardless of what error-only sanitized logger that strips Better Auth's own message and arguments regardless of what
they contain, pinned 8–128 password-length bounds, sanitized failures, and that a database outage they contain, pinned 8–128 password-length bounds, sanitized failures, exact whole-name
during principal resolution is never silently reported as anonymous), and Core migration discovery session-cookie detection that never fires on a prefixed, suffixed, or differently-cased lookalike,
(deterministic ordering, the reserved `core` identity, and collision rejection) — all without a and that a broken session store during principal resolution is never silently reported as anonymous
live database. `apps/api`'s own unit tests cover its pure Hono app factory (route shapes, module while an unrelated cookie performs no identity database work at all), the PostgreSQL transport
policy (`core/tests/database-transport.test.ts`: mode validation including every loose or aliased
spelling, production's plaintext refusal with no loopback exception, connection-string
TLS-parameter rejection, and that no configuration path yields `rejectUnauthorized: false` or leaks
CA material into an error), and Core migration discovery (deterministic ordering, the reserved
`core` identity, and collision rejection) — all without a live database. `apps/api`'s own unit tests cover its pure Hono app factory (route shapes, module
route mounting/enforcement/CORS/response sanitization, CORS allow/deny/preflight, deterministic 404 route mounting/enforcement/CORS/response sanitization, CORS allow/deny/preflight, deterministic 404
and sanitized error responses), its runtime configuration, its idempotent shutdown lifecycle and sanitized error responses), its runtime configuration, its idempotent shutdown lifecycle
(concurrent callers share one server-close-then-module-runtime-stop-then-pool-close promise; a (concurrent callers share one server-close-then-module-runtime-stop-then-pool-close promise; a
@@ -83,6 +90,15 @@ outage during principal resolution producing a sanitized `503` rather than a fal
starting the API against an unmigrated database creating no tables at all, and a clean listener/pool starting the API against an unmigrated database creating no tables at all, and a clean listener/pool
shutdown. shutdown.
A dedicated group within that suite proves session-store readiness against the real database rather
than a mock, since that is the only place a broken store looks exactly like an anonymous request: a
request carrying no cookies and one carrying only an unrelated cookie both resolve as anonymous
while issuing **no** identity database work at all; a request presenting the configured session
cookie against a dropped session table, a dropped column, and a revoked `SELECT` privilege each
produce a sanitized `503 PRINCIPAL_RESOLUTION_FAILED` with no relation name, column name, SQL, or
driver text in the body or the logs; and a genuinely expired or unknown session against a healthy
store still resolves as anonymous.
The same suite also proves Better Auth's own origin/CSRF protection is production-equivalent under The same suite also proves Better Auth's own origin/CSRF protection is production-equivalent under
the test runner, not just in a plain Node script: a no-cookie first authentication attempt succeeds the test runner, not just in a plain Node script: a no-cookie first authentication attempt succeeds
from a trusted browser origin and is rejected (`403`, no permissive CORS header, no leaked detail) from a trusted browser origin and is rejected (`403`, no permissive CORS header, no leaked detail)
@@ -97,6 +113,14 @@ database failure and asserts, against actual `console.error`/`warn`/`log` output
Auth's own internal error logging — configured explicitly in `core/src/identity/runtime.ts` — never Auth's own internal error logging — configured explicitly in `core/src/identity/runtime.ts` — never
emits a raw exception, SQL, connection detail, or the rejected request's origin. emits a raw exception, SQL, connection detail, or the rejected request's origin.
A dedicated group in the API suite also drives Pass 3's explicit-grant invariants through a real
authorized grants-management session: a malformed non-UUID revoke returning a sanitized `400` with
no database work, a canonical unknown UUID returning `404`, a scoped create body returning `400`
with nothing written, normalized-impossible/non-canonical/past expirations returning `400`, an
equivalent creation returning a sanitized `409` that names no existing grant, a bounded paginated
list traversed across three pages with relationship grants absent from every one, and one HTTP
revocation clearing every unrevoked legacy equivalent with a per-row audit entry.
A third dedicated suite (`apps/api/tests/module-database.integration.ts`, included in the same A third dedicated suite (`apps/api/tests/module-database.integration.ts`, included in the same
`pnpm test:migrations:integration` run) proves `context.services.database` against a real, `pnpm test:migrations:integration` run) proves `context.services.database` against a real,
installed test module (`core/tests/fixtures/module-database-integration/db-facade/` — never the installed test module (`core/tests/fixtures/module-database-integration/db-facade/` — never the
@@ -114,6 +138,43 @@ completes and is independently verifiable, through the harness's own administrat
never the API's own pool — before that pool closes. See never the API's own pool — before that pool closes. See
[ADR 0016](../architecture/decisions/0016-module-database-access.md). [ADR 0016](../architecture/decisions/0016-module-database-access.md).
A fourth dedicated suite (`core/tests/database-tls.integration.ts`, included in the same
`pnpm test:migrations:integration` run) proves the production PostgreSQL transport policy against a
real TLS-enabled PostgreSQL rather than against the shape of an options object: a trusted
certificate whose identity matches the host connects and is confirmed encrypted through
`pg_stat_ssl`; a certificate that does not chain to the configured CA is refused
(`UNABLE_TO_VERIFY_LEAF_SIGNATURE`); the same server reached through an address its certificate does
not cover is refused (`ERR_TLS_CERT_ALTNAME_INVALID`); the identical connection *without* the
policy's pinned `checkServerIdentity` succeeds, which is the concrete gap the policy closes; and
`verify-full` cannot be satisfied by a plaintext-only endpoint. Every CA, certificate, and private
key is generated at test runtime into a temporary directory and deleted afterwards — **no private
key material is ever committed to this repository** — and the disposable container is removed in
`afterAll` even when an assertion fails. See
[ADR 0018](../architecture/decisions/0018-identity-session-resolution-and-database-transport.md).
### Which kind of timing proof lives where
Pass 4 introduced several deadlines, and their tests deliberately differ in kind:
- **Unit deadline tests** (`core/tests/module-runtime.test.ts`) drive module stop and rollback
hooks through a short, test-only `hookTimeoutMs` override with manually controlled
never-settling promises — no real waiting, no sleeps, and an explicit `unhandledRejection` check
proving a hook that rejects after its deadline stays handled.
- **Real raw-HTTP force-close** (`apps/api/tests/api-http.integration.ts`) binds the real listener,
opens a real socket, writes a deliberately incomplete request, and proves shutdown force-closes
it at a short `drainTimeoutMsOverride` and still reaches pool closure.
- **Real TCP connection timeout** (`core/tests/postgres-migration.integration.ts`) binds a loopback
blackhole listener that accepts the socket and never completes PostgreSQL startup, proving the
configured connection deadline; the listener and every accepted socket close in `finally`.
- **Real PostgreSQL operation timeout and SQL batching** (same file) use a bounded server-side
delay only because the deadline itself is what is under test, and disposable databases to prove
that marker bytes inside literals, dollar-quoted bodies, comments, and quoted identifiers execute
intact, and that a failure rolls the whole batch back.
Every one of these uses a short **test-only override**; the production defaults (10 s per module
hook, 30 s HTTP drain, 10 s migration connection/query/statement) are asserted separately as
constants and are never waited out.
CI runs the same command in the dedicated `migration-smoke` job. After this change is merged, add CI runs the same command in the dedicated `migration-smoke` job. After this change is merged, add
the following pull-request status context to `main` branch protection: the following pull-request status context to `main` branch protection:
+100 -5
View File
@@ -3,6 +3,12 @@
An Ekklesia module is a backend capability package. It owns business behavior and data declarations; An Ekklesia module is a backend capability package. It owns business behavior and data declarations;
it never owns components, styles, layouts, renderers, navigation, or other UI. it never owns components, styles, layouts, renderers, navigation, or other UI.
> **Before you publish or install a module:** modules are trusted compiled backend code. Your
> module runs arbitrary JavaScript inside the API process and shares its PostgreSQL role, and
> nothing in this guide — manifests, permission declarations, typed facades, route validation,
> frozen contexts — isolates it. An operator installing your module is deploying trusted server
> code. See [Security model and deployment trust boundaries](../development/security.md).
## Required package shape ## Required package shape
Each immediate child of `modules/` must be an ESM workspace package with a unique package name, a Each immediate child of `modules/` must be an ESM workspace package with a unique package name, a
@@ -180,6 +186,21 @@ Entrypoint imports must have no top-level side effects. Modules are trusted back
evaluation cannot be rolled back. Keep registration declarative; open resources in `start` and evaluation cannot be rolled back. Keep registration declarative; open resources in `start` and
close them in `stop`. close them in `stop`.
### `stop()` may run after a partially completed `start()`
If your `start` hook throws, **your own `stop` hook runs first**, ahead of the previously started
modules being rolled back in reverse order. That is deliberate: a hook that opened a connection,
registered a listener, or recorded partial initialization before failing is the only code that knows
how to release it. Write `stop` so it is safe and idempotent about resources it may or may not have
acquired — guard on what you actually took, and never assume `start` completed.
Stop and rollback hooks have finite time: the platform waits at most **10 seconds** per module hook,
then records a timeout and continues with the next module in reverse order. Settle promptly. A hook
that exceeds its deadline is not killed — the platform simply stops waiting for it — but the shared
PostgreSQL pool closes after the bounded stop sequence, so **a timed-out hook cannot rely on
database availability afterwards**, and any late query will fail. The platform cannot undo side
effects your module performed outside its stop hook.
## HTTP routes ## HTTP routes
A module registers plain Fetch (`Request` in, `Response` out) route handlers during `register`, A module registers plain Fetch (`Request` in, `Response` out) route handlers during `register`,
@@ -199,6 +220,8 @@ export default defineModuleRuntime({
method: "GET", method: "GET",
path: "/events/:eventId", path: "/events/:eventId",
permission: "calendar.events.read", permission: "calendar.events.read",
// This route accepts no request body. See the body-size contract below.
maxBodyBytes: 0,
async handler(request, context) { async handler(request, context) {
return Response.json({ return Response.json({
eventId: context.params.eventId, eventId: context.params.eventId,
@@ -210,11 +233,82 @@ export default defineModuleRuntime({
}); });
``` ```
A route definition has exactly four fields — `method`, `path`, `permission`, `handler` — and nothing A route definition has exactly five fields — `method`, `path`, `permission`, `maxBodyBytes`,
else; an unknown field (a baseline, a mount prefix, CORS/origin policy, middleware, audit metadata, `handler` — and nothing else; an unknown field (a baseline, a mount prefix, CORS/origin policy,
authentication behavior, or another module's namespace) fails registration outright rather than middleware, audit metadata, authentication behavior, or another module's namespace) fails
being ignored. `method` is one of `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, always uppercase; there is registration outright rather than being ignored. `method` is one of `GET`, `POST`, `PUT`, `PATCH`,
no `OPTIONS` (the platform owns CORS preflight) and no wildcard/all-method registration. `DELETE`, always uppercase; there is no `OPTIONS` (the platform owns CORS preflight) and no
wildcard/all-method registration.
### Two routes may not both match one request path
Within one module and one HTTP method, two route patterns must never be able to match the same
request path. `/events/:id` and `/events/new` can — `/events/new` matches both — and that pair is
rejected during registration with `MODULE_ROUTE_OVERLAP`. So are `/events/:id/edit` against
`/events/new/:action`, and `/a/:left/c` against `/a/b/:right`. Two identical shapes, including paths
differing only in parameter name (`/events/:id` and `/events/:eventId`), keep the existing
`MODULE_ROUTE_DUPLICATE` result.
This is rejected rather than resolved by a "static beats parameter" rule because the routes may
carry *different permissions*, and letting declaration order decide which one guards `/events/new`
would make a permission policy depend on the order lines appear in your `register` hook. A rejected
registration publishes **none** of that module's routes or capabilities and runs no start hook, so
the failure is loud at startup rather than subtle at request time.
Still perfectly legal: two different static literals (`/events/new` and `/events/archive`),
different segment counts (`/events/:id` and `/events/:id/edit`), the same path under different
methods, and the same local path in two different modules — each module has its own
platform-owned mount prefix. Declaration order among disjoint routes is preserved exactly.
### Route parameters are decoded, untrusted input — validate them yourself
`context.params` holds the router's **decoded** path values. Decoding is not validation, and the
fact that your *declared* path passed the platform's path grammar says nothing about what a caller
may *send*. A single encoded segment routinely carries characters no declared path could:
| Request segment | `context.params` value |
| --- | --- |
| `%2F..%2Fadmin` | `/../admin` |
| `a%5Cb` | `a\b` |
| `a%00b` | `a`, NUL, `b` |
| `a%0D%0Ab` | `a`, CR, LF, `b` |
| `1%3B%20drop` | `1; drop` |
| `%C3%A9v%C3%A9nement` | `événement` |
**Validate every parameter you use against your own exact domain grammar** — a UUID, slug, date,
numeric identifier, or whatever your module owns — before any database, filesystem, command,
identifier, or other side-effecting use, and reject a missing, repeated, malformed, or semantically
invalid value with your own sanitized response:
```ts
const eventId = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u;
async handler(request, context) {
const id = context.params.eventId;
if (id === undefined || !eventId.test(id)) {
return Response.json({ code: "INVALID_REQUEST" }, { status: 400 });
}
// ...only now is `id` safe to use as a domain identifier.
}
```
Re-encoding the value, or a decode/re-encode round trip, is **not** a substitute for that check. The
platform deliberately supplies no generic parameter validator: your parameters are your domain
types, and one platform-wide pattern would either reject legitimate values or approve meaningless
ones. The platform continues to own authentication, authorization, `Origin`, CORS, body limits,
cache behavior, and uncaught-handler sanitization, and permission enforcement always completes
before your handler runs — an unauthorized caller can never reach your validation at all.
`maxBodyBytes` is a conscious, required request-body contract, not an optional or defaulted field:
`0` means the route accepts no body at all, and a non-empty declared (`Content-Length`) or streamed
body is rejected before the handler ever runs; any other value is the exact maximum number of bytes
accepted. It must be a non-negative integer no greater than the platform ceiling,
`MODULE_ROUTE_MAX_BODY_BYTES_CEILING` (`@ekklesia/contracts`, currently 1 MiB) — a negative,
non-integer, non-finite, or over-ceiling value fails registration with
`MODULE_ROUTE_MAX_BODY_BYTES_INVALID`. The platform enforces this limit itself, ahead of permission
evaluation and the handler, using the identical sanitized `413 {"code":"REQUEST_BODY_TOO_LARGE"}`
contract every other body-consuming route in the platform uses — see
[API](../development/api.md#request-body-limits).
`path` is local to the module, never the path a caller actually sends. The platform alone owns the `path` is local to the module, never the path a caller actually sends. The platform alone owns the
production mount prefix, `/api/v1/modules/<module-id>`: `"/"` is the module's own root, `"/events"` production mount prefix, `/api/v1/modules/<module-id>`: `"/"` is the module's own root, `"/events"`
@@ -370,6 +464,7 @@ export default defineModuleRuntime({
method: "GET", method: "GET",
path: "/events", path: "/events",
permission: "calendar.events.read", permission: "calendar.events.read",
maxBodyBytes: 0,
async handler(_request, context) { async handler(_request, context) {
const rows = await context.services.database const rows = await context.services.database
.select({ id: events.id, title: events.title }) .select({ id: events.id, title: events.title })
+15 -3
View File
@@ -6,10 +6,22 @@ import { parseEkklesiaUserId } from "./identity.js";
/** /**
* Opaque at the type level via Zod's nominal `.brand()`, exactly like `EkklesiaUserId` and * Opaque at the type level via Zod's nominal `.brand()`, exactly like `EkklesiaUserId` and
* `PermissionId`: a plain `string` is not assignable to `GrantId` without passing through * `PermissionId`: a plain `string` is not assignable to `GrantId` without passing through
* `parseGrantId`/`safeParseGrantId`. Ekklesia does not assume any particular format; Core's * `parseGrantId`/`safeParseGrantId`. Callers must still treat the *value* as opaque — nothing
* Postgres-backed store happens to generate a UUID, but callers must treat the value as opaque. * outside Core's store may construct one, derive meaning from it, or depend on its internals.
*
* The accepted shape is deliberately narrow: exactly the canonical lowercase hyphenated text form
* PostgreSQL emits for the UUID-backed `id` column (`8-4-4-4-12` lowercase hexadecimal). A looser
* "any short string" rule meant a malformed path parameter passed validation and only failed later,
* inside PostgreSQL's own UUID comparison — turning a request-validation problem into an
* indistinguishable infrastructure failure. There is deliberately **no** `.trim()`: whitespace
* padding is a malformed identifier, not something to silently repair, and normalizing it here
* would let two spellings of the same id reach the store. Uppercase is likewise rejected rather
* than folded, so exactly one spelling of any grant is ever valid.
*/ */
export const grantIdSchema = z.string().trim().min(1).max(128).brand<"GrantId">(); export const grantIdSchema = z
.string()
.regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u)
.brand<"GrantId">();
export type GrantId = z.infer<typeof grantIdSchema>; export type GrantId = z.infer<typeof grantIdSchema>;
+119 -13
View File
@@ -21,6 +21,23 @@ export function isModuleHttpMethod(value: unknown): value is ModuleHttpMethod {
return typeof value === "string" && moduleHttpMethods.has(value); return typeof value === "string" && moduleHttpMethods.has(value);
} }
/**
* The platform-owned hard ceiling on any single module route's declared request-body allowance, in
* bytes. No module route may declare a `maxBodyBytes` above this value — validated at registration
* (`safeParseModuleRouteDefinition`, below), not merely documented. This is a security contract,
* not a manifest capability: it does not gate on `manifestVersion` and is enforced identically for
* every installed module regardless of its declared compatibility range. Large media transfer
* belongs to a future dedicated storage design, never to widening this ceiling.
*/
export const MODULE_ROUTE_MAX_BODY_BYTES_CEILING = 1_048_576;
const moduleRouteMaxBodyBytesSchema = z
.number()
.int()
.finite()
.min(0)
.max(MODULE_ROUTE_MAX_BODY_BYTES_CEILING);
const MODULE_ROUTE_PATH_MAX_LENGTH = 256; const MODULE_ROUTE_PATH_MAX_LENGTH = 256;
const staticSegmentPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; const staticSegmentPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u;
const parameterSegmentPattern = /^:[a-z][a-zA-Z0-9]*$/u; const parameterSegmentPattern = /^:[a-z][a-zA-Z0-9]*$/u;
@@ -124,6 +141,49 @@ export function moduleRouteShapeKey(method: ModuleHttpMethod, path: ModuleRouteP
return `${method} /${shapeSegments.join("/")}`; return `${method} /${shapeSegments.join("/")}`;
} }
/** `"/"` is the module root and has no segments; every other canonical path is its `/`-separated
* segments. Takes an already-validated `ModuleRoutePath`. */
function moduleRoutePathSegments(path: ModuleRoutePath): readonly string[] {
return path === "/" ? [] : path.slice(1).split("/");
}
/**
* Whether two routes of the same module can both match at least one request path.
*
* Two routes overlap when they share an HTTP method and a segment count, and at every position
* either both segments are the same static literal or at least one is a parameter. Under this
* route grammar — static kebab-case literals or `:name` parameters, with no wildcard, regex,
* optional, or catch-all segment — that condition is exactly "some concrete path matches both".
*
* This exists because an overlap is a *permission* ambiguity, not a routing preference:
* `GET /events/:id` and `GET /events/new` can carry different permissions, and with an
* order-sensitive matcher whichever registered first decides which permission governs
* `/events/new`. Core rejects the overlap at registration instead of picking a winner, so no
* router's matching order can ever select the governing permission. Identical shapes (including
* paths differing only in parameter name) remain the pre-existing duplicate result; callers check
* that first.
*
* Deliberately framework-neutral: path semantics already live in this package, and nothing about
* this rule may depend on Hono, which stays confined to `apps/api`.
*/
export function moduleRoutesOverlap(
first: { readonly method: ModuleHttpMethod; readonly path: ModuleRoutePath },
second: { readonly method: ModuleHttpMethod; readonly path: ModuleRoutePath },
): boolean {
if (first.method !== second.method) {
return false;
}
const firstSegments = moduleRoutePathSegments(first.path);
const secondSegments = moduleRoutePathSegments(second.path);
if (firstSegments.length !== secondSegments.length) {
return false;
}
return firstSegments.every((segment, index) => {
const other = secondSegments[index] as string;
return segment.startsWith(":") || other.startsWith(":") || segment === other;
});
}
/** /**
* What a module's route handler receives about the caller, the match, and the platform services * What a module's route handler receives about the caller, the match, and the platform services
* available to it — never an authorization-store, grant-management, storage, jobs, configuration, * available to it — never an authorization-store, grant-management, storage, jobs, configuration,
@@ -132,6 +192,28 @@ export function moduleRouteShapeKey(method: ModuleHttpMethod, path: ModuleRouteP
* before ever invoking its handler; this contracts-layer module never invokes a handler itself. * before ever invoking its handler; this contracts-layer module never invokes a handler itself.
* `services` is the same frozen `ModuleRuntimeServices` object the owning module's `start`/`stop` * `services` is the same frozen `ModuleRuntimeServices` object the owning module's `start`/`stop`
* hooks receive for this API runtime — never a fresh one built per request. * hooks receive for this API runtime — never a fresh one built per request.
*
* ## `params` values are untrusted input
*
* `params` holds the HTTP router's **decoded** path values, frozen alongside this context. Decoding
* is not validation, and the declaration grammar above constrains only what a module may *declare*
* — it says nothing about what a request may *send*. A caller can percent-encode anything into a
* single segment, so a decoded value routinely contains characters no declared path could:
* verified against the pinned router, `%2F..%2Fadmin` arrives as `/../admin`, `a%5Cb` as `a\b`,
* `a%00b` with an embedded NUL, `a%0D%0Ab` with CR/LF, and `%C3%A9v%C3%A9nement` as `événement`.
*
* Every handler must therefore validate each parameter it uses against its own exact domain
* grammar — a UUID, slug, date, numeric id, or whatever that module owns — **before** any
* database, filesystem, command, identifier, or other side-effecting use, and reject a missing,
* repeated, malformed, or semantically invalid value with its own sanitized response. Re-encoding,
* decode/re-encode round-tripping, or inspecting the raw URL is not a substitute for that check.
* The platform deliberately supplies no generic parameter grammar: module parameters are
* module-owned domain types, and one platform-wide pattern would either reject legitimate values
* or approve meaningless ones.
*
* The platform continues to own authentication, authorization, `Origin`, CORS, body limits, cache
* behavior, and uncaught-handler sanitization; permission enforcement always completes before a
* handler — and therefore any parameter validation it performs — can run.
*/ */
export interface ModuleRouteHandlerContext { export interface ModuleRouteHandlerContext {
readonly moduleId: ModuleId; readonly moduleId: ModuleId;
@@ -151,15 +233,21 @@ export type ModuleRouteHandler = (
/** /**
* What a module passes to `http.route(...)` during `register`. `permission` is a plain string here * What a module passes to `http.route(...)` during `register`. `permission` is a plain string here
* (never the branded `PermissionId`) so a module can write a literal permission id without an * (never the branded `PermissionId`) so a module can write a literal permission id without an
* explicit parse call first; `http.route` validates and brands it. The type has exactly these four * explicit parse call first; `http.route` validates and brands it. `maxBodyBytes` is the route's
* fields — a module cannot specify a baseline, mount prefix, absolute path, CORS/origin policy, * conscious request-body contract — `0` means the route accepts no body at all (a non-empty
* authentication behavior, grant/platform-owner state, middleware, audit metadata, another module's * declared or streamed body is rejected), and any other value is the exact maximum number of bytes
* namespace, or an unprotected/no-permission route. * accepted, capped at `MODULE_ROUTE_MAX_BODY_BYTES_CEILING`. This is a security contract the
* platform enforces before a handler ever runs, not a manifest capability: it is not optional and
* has no implicit default, so a module author must make an explicit choice for every route. The
* type has exactly these five fields — a module cannot specify a baseline, mount prefix, absolute
* path, CORS/origin policy, authentication behavior, grant/platform-owner state, middleware, audit
* metadata, another module's namespace, or an unprotected/no-permission route.
*/ */
export interface ModuleRouteDefinitionInput { export interface ModuleRouteDefinitionInput {
readonly method: ModuleHttpMethod; readonly method: ModuleHttpMethod;
readonly path: string; readonly path: string;
readonly permission: string; readonly permission: string;
readonly maxBodyBytes: number;
readonly handler: ModuleRouteHandler; readonly handler: ModuleRouteHandler;
} }
@@ -169,6 +257,7 @@ export interface ModuleRouteDefinition {
readonly method: ModuleHttpMethod; readonly method: ModuleHttpMethod;
readonly path: ModuleRoutePath; readonly path: ModuleRoutePath;
readonly permission: PermissionId; readonly permission: PermissionId;
readonly maxBodyBytes: number;
readonly handler: ModuleRouteHandler; readonly handler: ModuleRouteHandler;
} }
@@ -187,18 +276,29 @@ export interface RegisteredModuleHttpRoute {
readonly method: ModuleHttpMethod; readonly method: ModuleHttpMethod;
readonly path: ModuleRoutePath; readonly path: ModuleRoutePath;
readonly permission: PermissionId; readonly permission: PermissionId;
readonly maxBodyBytes: number;
readonly handler: ModuleRouteHandler; readonly handler: ModuleRouteHandler;
} }
const moduleRouteDefinitionKeys = new Set(["method", "path", "permission", "handler"]); const moduleRouteDefinitionKeys = new Set([
"method",
"path",
"permission",
"maxBodyBytes",
"handler",
]);
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value); return typeof value === "object" && value !== null && !Array.isArray(value);
} }
function hasModuleRouteDefinitionShape( function hasModuleRouteDefinitionShape(value: unknown): value is {
value: unknown, method: unknown;
): value is { method: unknown; path: unknown; permission: unknown; handler: unknown } { path: unknown;
permission: unknown;
maxBodyBytes: unknown;
handler: unknown;
} {
if (!isRecord(value)) { if (!isRecord(value)) {
return false; return false;
} }
@@ -214,7 +314,8 @@ export type ModuleRouteDefinitionIssueCode =
| "MODULE_ROUTE_DEFINITION_INVALID" | "MODULE_ROUTE_DEFINITION_INVALID"
| "MODULE_ROUTE_METHOD_INVALID" | "MODULE_ROUTE_METHOD_INVALID"
| "MODULE_ROUTE_PATH_INVALID" | "MODULE_ROUTE_PATH_INVALID"
| "MODULE_ROUTE_PERMISSION_INVALID"; | "MODULE_ROUTE_PERMISSION_INVALID"
| "MODULE_ROUTE_MAX_BODY_BYTES_INVALID";
export type ModuleRouteDefinitionParseResult = export type ModuleRouteDefinitionParseResult =
| { readonly success: true; readonly data: ModuleRouteDefinition } | { readonly success: true; readonly data: ModuleRouteDefinition }
@@ -222,10 +323,10 @@ export type ModuleRouteDefinitionParseResult =
/** /**
* Validates every field-level rule a route definition must satisfy on its own — exact shape, * Validates every field-level rule a route definition must satisfy on its own — exact shape,
* canonical method, canonical path, well-formed permission id — but never whether the permission is * canonical method, canonical path, well-formed permission id, well-formed body-size contract — but
* actually declared by the registering module. That ownership check needs the module's own manifest * never whether the permission is actually declared by the registering module. That ownership check
* and belongs to the caller (Core's module runtime), not this framework-neutral contract. Never * needs the module's own manifest and belongs to the caller (Core's module runtime), not this
* echoes the rejected input in the returned code. * framework-neutral contract. Never echoes the rejected input in the returned code.
*/ */
export function safeParseModuleRouteDefinition(value: unknown): ModuleRouteDefinitionParseResult { export function safeParseModuleRouteDefinition(value: unknown): ModuleRouteDefinitionParseResult {
if (!hasModuleRouteDefinitionShape(value)) { if (!hasModuleRouteDefinitionShape(value)) {
@@ -242,12 +343,17 @@ export function safeParseModuleRouteDefinition(value: unknown): ModuleRouteDefin
if (!parsedPermission.success) { if (!parsedPermission.success) {
return { success: false, code: "MODULE_ROUTE_PERMISSION_INVALID" }; return { success: false, code: "MODULE_ROUTE_PERMISSION_INVALID" };
} }
const parsedMaxBodyBytes = moduleRouteMaxBodyBytesSchema.safeParse(value.maxBodyBytes);
if (!parsedMaxBodyBytes.success) {
return { success: false, code: "MODULE_ROUTE_MAX_BODY_BYTES_INVALID" };
}
return { return {
success: true, success: true,
data: Object.freeze({ data: Object.freeze({
method: value.method, method: value.method,
path: parsedPath.data, path: parsedPath.data,
permission: parsedPermission.data, permission: parsedPermission.data,
maxBodyBytes: parsedMaxBodyBytes.data,
handler: value.handler as ModuleRouteHandler, handler: value.handler as ModuleRouteHandler,
}), }),
}; };
+42 -4
View File
@@ -135,15 +135,53 @@ describe("PermissionDeclaration", () => {
}); });
describe("GrantId", () => { describe("GrantId", () => {
it("parses a non-empty opaque grant id and rejects empty/oversized input", () => { it("parses exactly the canonical lowercase hyphenated UUID PostgreSQL emits", () => {
const id: GrantId = parseGrantId("11111111-1111-1111-1111-111111111111"); const id: GrantId = parseGrantId("11111111-1111-1111-1111-111111111111");
expect(id).toBe("11111111-1111-1111-1111-111111111111"); expect(id).toBe("11111111-1111-1111-1111-111111111111");
expect(() => parseGrantId("")).toThrow(); expect(parseGrantId("0b36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f70")).toBe(
expect(() => parseGrantId("x".repeat(129))).toThrow(); "0b36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f70",
);
});
it("is opaque at the type level: a plain string is not assignable without parsing", () => {
// @ts-expect-error a bare string must not satisfy the branded GrantId type.
const notAllowed: GrantId = "11111111-1111-1111-1111-111111111111";
expect(notAllowed).toBe("11111111-1111-1111-1111-111111111111");
});
it("rejects every non-canonical spelling rather than repairing it", () => {
for (const value of [
"",
"abc",
"not-a-uuid",
// Uppercase is rejected, never folded, so exactly one spelling of any grant is valid.
"0B36B1B6-9A29-4C4E-9E2D-4C0E4C5E6F70",
// Whitespace padding is malformed input, not something to silently trim: trimming would let
// two spellings of the same id reach the store.
" 0b36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f70",
"0b36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f70 ",
"\t0b36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f70\n",
// Shortened, overlong, unhyphenated, wrong grouping, and non-hex.
"0b36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f7",
"0b36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f700",
"0b36b1b69a294c4e9e2d4c0e4c5e6f70",
"0b36b1b6-9a294-c4e-9e2d-4c0e4c5e6f70",
"0g36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f70",
"0b36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f70\n0b36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f71",
"x".repeat(129),
]) {
expect(safeParseGrantId(value).success, `expected ${JSON.stringify(value)} rejected`).toBe(
false,
);
expect(() => parseGrantId(value)).toThrow();
}
for (const value of [42, null, undefined, {}, []]) {
expect(safeParseGrantId(value).success).toBe(false);
}
}); });
it("exposes a safe-parse variant that never throws", () => { it("exposes a safe-parse variant that never throws", () => {
expect(safeParseGrantId("abc").success).toBe(true); expect(safeParseGrantId("11111111-1111-1111-1111-111111111111").success).toBe(true);
expect(safeParseGrantId("").success).toBe(false); expect(safeParseGrantId("").success).toBe(false);
}); });
}); });
@@ -2,9 +2,11 @@ import { describe, expect, it } from "vitest";
import { import {
isModuleHttpMethod, isModuleHttpMethod,
MODULE_HTTP_METHODS, MODULE_HTTP_METHODS,
MODULE_ROUTE_MAX_BODY_BYTES_CEILING,
type ModuleRouteDefinitionInput, type ModuleRouteDefinitionInput,
moduleRoutePathSchema, moduleRoutePathSchema,
moduleRouteShapeKey, moduleRouteShapeKey,
moduleRoutesOverlap,
parseModuleRouteDefinition, parseModuleRouteDefinition,
parseModuleRoutePath, parseModuleRoutePath,
safeParseModuleRouteDefinition, safeParseModuleRouteDefinition,
@@ -22,6 +24,7 @@ function validDefinition(
method: "GET", method: "GET",
path: "/events/:eventId", path: "/events/:eventId",
permission: "calendar.events.read", permission: "calendar.events.read",
maxBodyBytes: 0,
handler: noopHandler, handler: noopHandler,
...overrides, ...overrides,
}; };
@@ -153,6 +156,7 @@ describe("safeParseModuleRouteDefinition", () => {
method: "GET", method: "GET",
path: "/events/:eventId", path: "/events/:eventId",
permission: "calendar.events.read", permission: "calendar.events.read",
maxBodyBytes: 0,
handler: noopHandler, handler: noopHandler,
}); });
expect(Object.isFrozen(result.data)).toBe(true); expect(Object.isFrozen(result.data)).toBe(true);
@@ -216,6 +220,44 @@ describe("safeParseModuleRouteDefinition", () => {
}); });
}); });
it("accepts a maxBodyBytes of exactly zero (no body accepted) and exactly the platform ceiling", () => {
expect(safeParseModuleRouteDefinition(validDefinition({ maxBodyBytes: 0 })).success).toBe(true);
const atCeiling = safeParseModuleRouteDefinition(
validDefinition({ maxBodyBytes: MODULE_ROUTE_MAX_BODY_BYTES_CEILING }),
);
expect(atCeiling.success).toBe(true);
if (atCeiling.success) {
expect(atCeiling.data.maxBodyBytes).toBe(MODULE_ROUTE_MAX_BODY_BYTES_CEILING);
}
});
it("rejects a negative, non-integer, non-finite, or over-ceiling maxBodyBytes", () => {
for (const maxBodyBytes of [
-1,
1.5,
Number.NaN,
Number.POSITIVE_INFINITY,
Number.NEGATIVE_INFINITY,
MODULE_ROUTE_MAX_BODY_BYTES_CEILING + 1,
]) {
expect(safeParseModuleRouteDefinition(validDefinition({ maxBodyBytes }))).toEqual({
success: false,
code: "MODULE_ROUTE_MAX_BODY_BYTES_INVALID",
});
}
});
it("rejects a non-numeric or missing maxBodyBytes", () => {
expect(
safeParseModuleRouteDefinition({ ...validDefinition(), maxBodyBytes: "0" }),
).toMatchObject({ success: false, code: "MODULE_ROUTE_MAX_BODY_BYTES_INVALID" });
const { maxBodyBytes: _maxBodyBytes, ...withoutMaxBodyBytes } = validDefinition();
expect(safeParseModuleRouteDefinition(withoutMaxBodyBytes)).toEqual({
success: false,
code: "MODULE_ROUTE_DEFINITION_INVALID",
});
});
it("does not retain the caller's mutable definition object: mutating it afterward cannot change the parsed result", () => { it("does not retain the caller's mutable definition object: mutating it afterward cannot change the parsed result", () => {
const source = { ...validDefinition() }; const source = { ...validDefinition() };
const result = safeParseModuleRouteDefinition(source); const result = safeParseModuleRouteDefinition(source);
@@ -253,3 +295,42 @@ describe("parseModuleRouteDefinition", () => {
} }
}); });
}); });
describe("moduleRoutesOverlap", () => {
const route = (method: "GET" | "POST", path: string) => ({
method,
path: parseModuleRoutePath(path),
});
it.each([
["a parameter against a static literal", "/events/:id", "/events/new"],
["a later position", "/events/:id/edit", "/events/new/:action"],
["parameters on both sides at different positions", "/a/:left/c", "/a/b/:right"],
["identical parameter shapes with different names", "/events/:id", "/events/:eventId"],
["identical static paths", "/events/new", "/events/new"],
["root against root", "/", "/"],
])("reports an overlap for %s", (_label, first, second) => {
expect(moduleRoutesOverlap(route("GET", first), route("GET", second))).toBe(true);
// The relation is symmetric, so registration order cannot change the verdict.
expect(moduleRoutesOverlap(route("GET", second), route("GET", first))).toBe(true);
});
it.each([
["two different static literals", "/events/new", "/events/archive"],
["different segment counts", "/events/:id", "/events/:id/edit"],
["a static prefix that differs earlier", "/events/:id", "/people/:id"],
["root against a one-segment path", "/", "/events"],
])("reports no overlap for %s", (_label, first, second) => {
expect(moduleRoutesOverlap(route("GET", first), route("GET", second))).toBe(false);
expect(moduleRoutesOverlap(route("GET", second), route("GET", first))).toBe(false);
});
it("never reports an overlap across different methods", () => {
expect(moduleRoutesOverlap(route("GET", "/events/:id"), route("POST", "/events/new"))).toBe(
false,
);
expect(moduleRoutesOverlap(route("GET", "/events/new"), route("POST", "/events/new"))).toBe(
false,
);
});
});
@@ -48,6 +48,7 @@ describe("defineModuleRuntime", () => {
method: "GET", method: "GET",
path: "/records", path: "/records",
permission: "example.records.read", permission: "example.records.read",
maxBodyBytes: 0,
handler: () => new Response(null, { status: 204 }), handler: () => new Response(null, { status: 204 }),
}); });
}, },
+22 -243
View File
@@ -4,6 +4,13 @@ settings:
autoInstallPeers: true autoInstallPeers: true
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
overrides:
vite>postcss: 8.5.24
'@esbuild-kit/core-utils>esbuild': 0.25.12
chevrotain>lodash: 4.18.1
'@chevrotain/gast>lodash': 4.18.1
'@chevrotain/cst-dts-gen>lodash': 4.18.1
importers: importers:
.: .:
@@ -531,12 +538,6 @@ packages:
cpu: [ppc64] cpu: [ppc64]
os: [aix] os: [aix]
'@esbuild/[email protected]':
resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==}
engines: {node: '>=12'}
cpu: [arm64]
os: [android]
'@esbuild/[email protected]': '@esbuild/[email protected]':
resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -549,12 +550,6 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [android] os: [android]
'@esbuild/[email protected]':
resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==}
engines: {node: '>=12'}
cpu: [arm]
os: [android]
'@esbuild/[email protected]': '@esbuild/[email protected]':
resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -567,12 +562,6 @@ packages:
cpu: [arm] cpu: [arm]
os: [android] os: [android]
'@esbuild/[email protected]':
resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==}
engines: {node: '>=12'}
cpu: [x64]
os: [android]
'@esbuild/[email protected]': '@esbuild/[email protected]':
resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -585,12 +574,6 @@ packages:
cpu: [x64] cpu: [x64]
os: [android] os: [android]
'@esbuild/[email protected]':
resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==}
engines: {node: '>=12'}
cpu: [arm64]
os: [darwin]
'@esbuild/[email protected]': '@esbuild/[email protected]':
resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -603,12 +586,6 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@esbuild/[email protected]':
resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [darwin]
'@esbuild/[email protected]': '@esbuild/[email protected]':
resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -621,12 +598,6 @@ packages:
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@esbuild/[email protected]':
resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==}
engines: {node: '>=12'}
cpu: [arm64]
os: [freebsd]
'@esbuild/[email protected]': '@esbuild/[email protected]':
resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -639,12 +610,6 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [freebsd] os: [freebsd]
'@esbuild/[email protected]':
resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [freebsd]
'@esbuild/[email protected]': '@esbuild/[email protected]':
resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -657,12 +622,6 @@ packages:
cpu: [x64] cpu: [x64]
os: [freebsd] os: [freebsd]
'@esbuild/[email protected]':
resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==}
engines: {node: '>=12'}
cpu: [arm64]
os: [linux]
'@esbuild/[email protected]': '@esbuild/[email protected]':
resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -675,12 +634,6 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==}
engines: {node: '>=12'}
cpu: [arm]
os: [linux]
'@esbuild/[email protected]': '@esbuild/[email protected]':
resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -693,12 +646,6 @@ packages:
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==}
engines: {node: '>=12'}
cpu: [ia32]
os: [linux]
'@esbuild/[email protected]': '@esbuild/[email protected]':
resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -711,12 +658,6 @@ packages:
cpu: [ia32] cpu: [ia32]
os: [linux] os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==}
engines: {node: '>=12'}
cpu: [loong64]
os: [linux]
'@esbuild/[email protected]': '@esbuild/[email protected]':
resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -729,12 +670,6 @@ packages:
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==}
engines: {node: '>=12'}
cpu: [mips64el]
os: [linux]
'@esbuild/[email protected]': '@esbuild/[email protected]':
resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -747,12 +682,6 @@ packages:
cpu: [mips64el] cpu: [mips64el]
os: [linux] os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==}
engines: {node: '>=12'}
cpu: [ppc64]
os: [linux]
'@esbuild/[email protected]': '@esbuild/[email protected]':
resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -765,12 +694,6 @@ packages:
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==}
engines: {node: '>=12'}
cpu: [riscv64]
os: [linux]
'@esbuild/[email protected]': '@esbuild/[email protected]':
resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -783,12 +706,6 @@ packages:
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==}
engines: {node: '>=12'}
cpu: [s390x]
os: [linux]
'@esbuild/[email protected]': '@esbuild/[email protected]':
resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -801,12 +718,6 @@ packages:
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==}
engines: {node: '>=12'}
cpu: [x64]
os: [linux]
'@esbuild/[email protected]': '@esbuild/[email protected]':
resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -831,12 +742,6 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [netbsd] os: [netbsd]
'@esbuild/[email protected]':
resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==}
engines: {node: '>=12'}
cpu: [x64]
os: [netbsd]
'@esbuild/[email protected]': '@esbuild/[email protected]':
resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -861,12 +766,6 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [openbsd] os: [openbsd]
'@esbuild/[email protected]':
resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==}
engines: {node: '>=12'}
cpu: [x64]
os: [openbsd]
'@esbuild/[email protected]': '@esbuild/[email protected]':
resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -891,12 +790,6 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [openharmony] os: [openharmony]
'@esbuild/[email protected]':
resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [sunos]
'@esbuild/[email protected]': '@esbuild/[email protected]':
resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -909,12 +802,6 @@ packages:
cpu: [x64] cpu: [x64]
os: [sunos] os: [sunos]
'@esbuild/[email protected]':
resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==}
engines: {node: '>=12'}
cpu: [arm64]
os: [win32]
'@esbuild/[email protected]': '@esbuild/[email protected]':
resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -927,12 +814,6 @@ packages:
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@esbuild/[email protected]':
resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==}
engines: {node: '>=12'}
cpu: [ia32]
os: [win32]
'@esbuild/[email protected]': '@esbuild/[email protected]':
resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -945,12 +826,6 @@ packages:
cpu: [ia32] cpu: [ia32]
os: [win32] os: [win32]
'@esbuild/[email protected]':
resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [win32]
'@esbuild/[email protected]': '@esbuild/[email protected]':
resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1579,11 +1454,6 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==} resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==}
[email protected]:
resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==}
engines: {node: '>=12'}
hasBin: true
[email protected]: [email protected]:
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1757,8 +1627,8 @@ packages:
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
[email protected]7.21: [email protected]8.1:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
[email protected]: [email protected]:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@@ -1769,8 +1639,8 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
[email protected]5: [email protected]6:
resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true hasBin: true
@@ -1843,8 +1713,8 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
[email protected].17: [email protected].24:
resolution: {integrity: sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==} resolution: {integrity: sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
[email protected]: [email protected]:
@@ -2414,12 +2284,12 @@ snapshots:
dependencies: dependencies:
'@chevrotain/gast': 10.5.0 '@chevrotain/gast': 10.5.0
'@chevrotain/types': 10.5.0 '@chevrotain/types': 10.5.0
lodash: 4.17.21 lodash: 4.18.1
'@chevrotain/[email protected]': '@chevrotain/[email protected]':
dependencies: dependencies:
'@chevrotain/types': 10.5.0 '@chevrotain/types': 10.5.0
lodash: 4.17.21 lodash: 4.18.1
'@chevrotain/[email protected]': {} '@chevrotain/[email protected]': {}
@@ -2456,7 +2326,7 @@ snapshots:
'@esbuild-kit/[email protected]': '@esbuild-kit/[email protected]':
dependencies: dependencies:
esbuild: 0.18.20 esbuild: 0.25.12
source-map-support: 0.5.21 source-map-support: 0.5.21
'@esbuild-kit/[email protected]': '@esbuild-kit/[email protected]':
@@ -2470,144 +2340,96 @@ snapshots:
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
@@ -2620,9 +2442,6 @@ snapshots:
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
@@ -2635,9 +2454,6 @@ snapshots:
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
@@ -2650,36 +2466,24 @@ snapshots:
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]': '@esbuild/[email protected]':
optional: true optional: true
@@ -3061,7 +2865,7 @@ snapshots:
'@chevrotain/gast': 10.5.0 '@chevrotain/gast': 10.5.0
'@chevrotain/types': 10.5.0 '@chevrotain/types': 10.5.0
'@chevrotain/utils': 10.5.0 '@chevrotain/utils': 10.5.0
lodash: 4.17.21 lodash: 4.18.1
regexp-to-ast: 0.5.0 regexp-to-ast: 0.5.0
[email protected]: [email protected]:
@@ -3112,31 +2916,6 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]:
optionalDependencies:
'@esbuild/android-arm': 0.18.20
'@esbuild/android-arm64': 0.18.20
'@esbuild/android-x64': 0.18.20
'@esbuild/darwin-arm64': 0.18.20
'@esbuild/darwin-x64': 0.18.20
'@esbuild/freebsd-arm64': 0.18.20
'@esbuild/freebsd-x64': 0.18.20
'@esbuild/linux-arm': 0.18.20
'@esbuild/linux-arm64': 0.18.20
'@esbuild/linux-ia32': 0.18.20
'@esbuild/linux-loong64': 0.18.20
'@esbuild/linux-mips64el': 0.18.20
'@esbuild/linux-ppc64': 0.18.20
'@esbuild/linux-riscv64': 0.18.20
'@esbuild/linux-s390x': 0.18.20
'@esbuild/linux-x64': 0.18.20
'@esbuild/netbsd-x64': 0.18.20
'@esbuild/openbsd-x64': 0.18.20
'@esbuild/sunos-x64': 0.18.20
'@esbuild/win32-arm64': 0.18.20
'@esbuild/win32-ia32': 0.18.20
'@esbuild/win32-x64': 0.18.20
[email protected]: [email protected]:
optionalDependencies: optionalDependencies:
'@esbuild/aix-ppc64': 0.25.12 '@esbuild/aix-ppc64': 0.25.12
@@ -3297,7 +3076,7 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]7.21: {} [email protected]8.1: {}
[email protected]: [email protected]:
dependencies: dependencies:
@@ -3309,7 +3088,7 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]5: {} [email protected]6: {}
[email protected]: {} [email protected]: {}
@@ -3375,9 +3154,9 @@ snapshots:
exsolve: 1.1.0 exsolve: 1.1.0
pathe: 2.0.3 pathe: 2.0.3
[email protected].17: [email protected].24:
dependencies: dependencies:
nanoid: 3.3.15 nanoid: 3.3.16
picocolors: 1.1.1 picocolors: 1.1.1
source-map-js: 1.2.1 source-map-js: 1.2.1
@@ -3523,7 +3302,7 @@ snapshots:
dependencies: dependencies:
lightningcss: 1.32.0 lightningcss: 1.32.0
picomatch: 4.0.5 picomatch: 4.0.5
postcss: 8.5.17 postcss: 8.5.24
rolldown: 1.1.5 rolldown: 1.1.5
tinyglobby: 0.2.17 tinyglobby: 0.2.17
optionalDependencies: optionalDependencies:
+26
View File
@@ -9,3 +9,29 @@ packages:
allowBuilds: allowBuilds:
esbuild: true esbuild: true
minimumReleaseAgeExclude:
- [email protected]
# Narrowly scoped advisory remediation. Each entry raises exactly the one edge that resolves a
# vulnerable version, never the whole graph, and pins an exact release rather than a range.
overrides:
# GHSA-r28c-9q8g-f849 (high). PostCSS is reached only through Vite, which better-auth pulls in
# via Vitest. Vite 8.1.4's own dependency range still resolves the vulnerable 8.5.17.
vite>postcss: "8.5.24"
# GHSA-67mh-4wv8-2f99 (moderate). The vulnerable esbuild 0.18.20 is reached only through the
# deprecated @esbuild-kit/core-utils (drizzle-kit -> @esbuild-kit/esm-loader), whose pinned range
# predates the fix. Scoped to that one edge deliberately: drizzle-kit already resolves esbuild
# 0.25.12 directly and tsx/vite resolve 0.28.1, so a global override would drag those newer,
# unaffected versions backwards.
"@esbuild-kit/core-utils>esbuild": "0.25.12"
# GHSA-r5fr-rjxr-66jc (high), GHSA-f23m-r3pf-42rh and GHSA-xxjr-mmjv-4gpg (moderate). Lodash is
# reached only through the development-time Better Auth `auth` CLI's Prisma parser
# (@mrleebo/prisma-ast -> chevrotain). All three chevrotain packages pin lodash to exactly
# 4.17.21, so no in-range release is patched; the advisories require >= 4.17.24, which the 4.17.x
# line never published, making 4.18.1 the lowest fixed release. Verified compatible: it keeps the
# per-method CommonJS subpath files (`lodash/assign`, `lodash/isRegExp`, ...) chevrotain requires,
# with the same `main` and no restrictive `exports` field.
chevrotain>lodash: "4.18.1"
"@chevrotain/gast>lodash": "4.18.1"
"@chevrotain/cst-dts-gen>lodash": "4.18.1"
+11
View File
@@ -22,6 +22,17 @@ compose_job_network_initialize() {
COMPOSE_JOB_NETWORK_CURRENT_CONTAINER="$HOSTNAME" COMPOSE_JOB_NETWORK_CURRENT_CONTAINER="$HOSTNAME"
} }
# Emits the identifier of the container this job is executing in, or an empty string when the job
# runs directly on a host. This is the single topology signal: harnesses that provision their own
# containers instead of a Compose project (the TLS integration suite) consume it through the
# environment rather than re-deriving the topology, so there is exactly one detector.
compose_job_network_job_container() {
if [[ "$COMPOSE_JOB_NETWORK_RUNNING_IN_CONTAINER" != "true" ]]; then
return 0
fi
printf '%s' "$COMPOSE_JOB_NETWORK_CURRENT_CONTAINER"
}
compose_job_network_connect() { compose_job_network_connect() {
if [[ "$COMPOSE_JOB_NETWORK_RUNNING_IN_CONTAINER" != "true" ]]; then if [[ "$COMPOSE_JOB_NETWORK_RUNNING_IN_CONTAINER" != "true" ]]; then
return 1 return 1
+4
View File
@@ -96,5 +96,9 @@ else
fi fi
fi fi
# The TLS suite provisions its own disposable PostgreSQL rather than a Compose service, so it
# cannot call `compose_job_network_connect`. It receives the same topology decision this script
# already made, instead of detecting the topology a second time.
EKKLESIA_TEST_JOB_CONTAINER="$(compose_job_network_job_container)" \
EKKLESIA_MIGRATION_TEST_DATABASE_URL="postgresql://$POSTGRES_USER:$POSTGRES_PASSWORD@$database_host:$database_port/$POSTGRES_DB" \ EKKLESIA_MIGRATION_TEST_DATABASE_URL="postgresql://$POSTGRES_USER:$POSTGRES_PASSWORD@$database_host:$database_port/$POSTGRES_DB" \
pnpm exec vitest run --config vitest.migrations.integration.config.ts pnpm exec vitest run --config vitest.migrations.integration.config.ts
+6
View File
@@ -21,6 +21,12 @@ export default defineModuleRuntime({
method: "GET", method: "GET",
path: "/records", path: "/records",
permission: "example.records.read", permission: "example.records.read",
// This route accepts no request body at all — a GET route never should. Every route must
// make this choice consciously: 0 means "no body accepted" (a non-empty declared or streamed
// body is rejected before this handler ever runs); a route that does accept a body declares
// its own explicit byte maximum instead, up to the platform's ceiling
// (`MODULE_ROUTE_MAX_BODY_BYTES_CEILING`, `@ekklesia/contracts`).
maxBodyBytes: 0,
async handler(_request, context) { async handler(_request, context) {
const rows = await context.services.database const rows = await context.services.database
.select({ id: records.id, value: records.value }) .select({ id: records.id, value: records.value })
+3
View File
@@ -106,6 +106,9 @@ describe("module template", () => {
method: "GET", method: "GET",
path: "/records", path: "/records",
permission: "example.records.read", permission: "example.records.read",
// The template's example route demonstrates the explicit no-body contract: a GET route
// accepts no request body at all.
maxBodyBytes: 0,
}); });
}); });
+1
View File
@@ -6,6 +6,7 @@ export default defineConfig({
hookTimeout: 30_000, hookTimeout: 30_000,
include: [ include: [
"core/tests/postgres-migration.integration.ts", "core/tests/postgres-migration.integration.ts",
"core/tests/database-tls.integration.ts",
"core/tests/authorization-store.integration.ts", "core/tests/authorization-store.integration.ts",
"core/tests/authorization-bootstrap.integration.ts", "core/tests/authorization-bootstrap.integration.ts",
"apps/api/tests/api-http.integration.ts", "apps/api/tests/api-http.integration.ts",