diff --git a/.env.example b/.env.example index 4880e03..d86af41 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,11 @@ # 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_USER=ekklesia POSTGRES_PASSWORD=ekklesia-development-only diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3b3d39..03c8a9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,22 @@ 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..permissions`, `concurrency`, and +# `jobs..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: pull_request: push: @@ -20,6 +37,11 @@ jobs: steps: - name: Check out repository 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 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -44,6 +66,11 @@ jobs: steps: - name: Check out repository 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 run: bash scripts/smoke-infrastructure.sh @@ -55,6 +82,11 @@ jobs: steps: - name: Check out repository 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 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 diff --git a/README.md b/README.md index 9780bc2..81ba079 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,14 @@ surfaces. 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 -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, deterministic automatic discovery, compiled server loading, a staged capability registry, 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 (`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 -still cannot reach any of this. Public email/password registration is intentional; every account -receives identical lowest-privilege baseline status and signup order never grants elevation. See +still cannot reach any of this. Public email/password registration is the intended eventual +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 [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 @@ -91,13 +100,140 @@ startup. See [API](docs/development/api.md#module-database-access), [Module development](docs/module-development/README.md#database-access), and [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 Prerequisites: Node.js 24 LTS, pnpm 11.12, and Docker with Compose. ```bash pnpm install --frozen-lockfile -cp .env.example .env +cp .env.example .env # keep its NODE_ENV=development: see below pnpm infrastructure:up pnpm modules:validate pnpm migrations:validate @@ -109,9 +245,15 @@ pnpm verify ``` `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 -planning, and the current object-storage integration state. It never applies migrations, creates a -history table, starts module servers, or performs storage operations. Schema discovery does import +modules and dependency order, schema and migration inventories, the effective PostgreSQL transport +(TLS) policy, optional read-only PostgreSQL planning, and the current object-storage integration +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. With no installed modules and no `DATABASE_URL`, the current repository produces a report shaped @@ -136,11 +278,14 @@ Data definitions ✓ Schemas validated 0 modules · 0 tables ✓ Migrations validated - 2 migrations · checksums valid + 3 migrations · checksums valid core:0000_identity_core_tables · f8f2086ef06f core:0001_authorization_core_tables · 830e1fbc26d0 + core:0002_authorization_integrity · 634294731180 PostgreSQL + ✓ Database TLS verified + TLS required with certificate and hostname verification. ○ Connection check skipped DATABASE_URL is not configured. ○ Migration plan skipped diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 4a10815..bf24cf6 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -12,6 +12,7 @@ import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/co import { Hono } from "hono"; import type { Context, MiddlewareHandler } from "hono"; import { cors } from "hono/cors"; +import { AUTH_REQUEST_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js"; import { createGrantsRoutes } from "./grants-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/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 { - 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) { return sanitizedErrorResponse(c, error, 500); } diff --git a/apps/api/src/body-limits.ts b/apps/api/src/body-limits.ts new file mode 100644 index 0000000..12e4ea9 --- /dev/null +++ b/apps/api/src/body-limits.ts @@ -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 }); +} diff --git a/apps/api/src/client-address.ts b/apps/api/src/client-address.ts new file mode 100644 index 0000000..7ba8ed1 --- /dev/null +++ b/apps/api/src/client-address.ts @@ -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, + resolver: ClientAddressResolver, +): (request: Request, env: NodeServerBindings) => Promise { + return async (request: Request, env: NodeServerBindings): Promise => { + 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); + }; +} diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index 1843153..48cca77 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -1,9 +1,20 @@ +import { + assertProductionDatabaseTransport, + type DatabaseTransportPolicy, + type DatabaseTransportPolicyInput, + createDatabaseTransportPolicy, +} from "@ekklesia/core/database"; import { type CreateIdentityConfigOptions, createIdentityConfig, type IdentityConfig, type IdentityConfigInput, } from "@ekklesia/core/identity"; +import { + type ClientAddressConfig, + type ClientAddressConfigInput, + createClientAddressConfig, +} from "./client-address.js"; export type ApiConfigErrorCode = | "DATABASE_URL_INVALID" @@ -37,14 +48,23 @@ const defaultHost = "127.0.0.1"; const defaultPort = 3000; export interface ApiRuntimeConfig { + readonly clientAddress: ClientAddressConfig; readonly databaseUrl: string; + /** The one validated PostgreSQL transport policy this runtime's single pool uses. */ + readonly databaseTransport: DatabaseTransportPolicy; readonly host: string; readonly identity: IdentityConfig; readonly port: number; } 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; + /** 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 identity: IdentityConfigInput; readonly port?: unknown; @@ -112,6 +132,22 @@ export function createApiRuntimeConfig( input.identity, 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, + }); } diff --git a/apps/api/src/grants-routes.ts b/apps/api/src/grants-routes.ts index 2f3d0c0..3622374 100644 --- a/apps/api/src/grants-routes.ts +++ b/apps/api/src/grants-routes.ts @@ -1,16 +1,17 @@ -import type { AuthorizationScope, EkklesiaUserId, PermissionId } from "@ekklesia/contracts"; +import type { EkklesiaUserId, GrantId, PermissionId } from "@ekklesia/contracts"; import { createUserActor, isAuthenticatedPrincipal, - safeParseAuthorizationScope, safeParseEkklesiaUserId, safeParseGrantId, safeParsePermissionId, } from "@ekklesia/contracts"; import { + AUTHORIZATION_GRANT_LIST_MAX_LIMIT, type AuthorizationStore, AuthorizationStoreError, CORE_AUTHORIZATION_GRANTS_MANAGE, + type GrantPageBoundary, type GrantRecord, } from "@ekklesia/core/authorization"; import { Hono } from "hono"; @@ -21,17 +22,41 @@ import { requirePermission, requireTrustedOrigin, } from "./authorization-middleware.js"; +import { CORE_MUTATION_MAX_BODY_BYTES, platformBodyLimit } from "./body-limits.js"; export interface CreateGrantsRoutesOptions extends AuthorizationMiddlewareOptions { readonly store: AuthorizationStore; readonly trustedOrigins: ReadonlySet; + /** 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 { c.header("Cache-Control", "no-store"); if (error instanceof AuthorizationStoreError) { - const status = error.code === "AUTHORIZATION_GRANT_NOT_FOUND" ? 404 : 503; - return c.json({ code: error.code }, status); + if (error.code === "AUTHORIZATION_GRANT_NOT_FOUND") { + 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); } @@ -39,20 +64,55 @@ function sanitizedStoreErrorResponse(c: Context, error: unknown): Response { interface ParsedCreateGrantBody { readonly expiresAt?: Date; readonly permission: PermissionId; - readonly scope?: AuthorizationScope; 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`. - * 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. + * The one accepted expiration representation: `YYYY-MM-DDTHH:mm:ss.sssZ`, byte-for-byte equal to + * the parsed `Date`'s own `toISOString()`. + * + * `new Date(value)` alone was far too permissive. It silently *normalized* impossible dates + * (`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)) { return undefined; } @@ -70,31 +130,138 @@ function parseCreateGrantBody(raw: unknown): ParsedCreateGrantBody | 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; if (body.expiresAt !== undefined) { - if (typeof body.expiresAt !== "string") { + expiresAt = parseCanonicalFutureInstant(body.expiresAt, now); + if (expiresAt === undefined) { return undefined; } - const parsedDate = new Date(body.expiresAt); - if (Number.isNaN(parsedDate.getTime())) { - return undefined; - } - expiresAt = parsedDate; } return Object.freeze({ ...(expiresAt === undefined ? {} : { expiresAt }), 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; + 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, }); } @@ -124,74 +291,53 @@ function serializeGrant(grant: GrantRecord) { * `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 — * those grants remain owned by the module operation that established the relationship. + * + * Creation is unscoped-only; see `parseCreateGrantBody`. */ export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono { const routes = new Hono(); + const now = options.now ?? ((): Date => new Date()); const requireManagePermission = requirePermission(options, CORE_AUTHORIZATION_GRANTS_MANAGE); 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) => { c.header("Cache-Control", "no-store"); - const subjectUserId = safeParseEkklesiaUserId(c.req.query("userId")); - if (!subjectUserId.success) { + // Reached only after `requireManagePermission` allowed the caller, so query validation can + // 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); } 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", }); - return c.json({ grants: grants.map(serializeGrant) }, 200); - } catch (error) { - return sanitizedStoreErrorResponse(c, error); - } - }); - - routes.post("/api/v1/grants", requireOrigin, requireManagePermission, async (c) => { - c.header("Cache-Control", "no-store"); - const principal = getAuthorizedPrincipal(c); - if (!isAuthenticatedPrincipal(principal)) { - return c.json({ code: "AUTHORIZATION_DENIED" }, 403); - } - - let rawBody: unknown; - try { - rawBody = await c.req.json(); - } catch { - return c.json({ code: "INVALID_REQUEST" }, 400); - } - const parsed = parseCreateGrantBody(rawBody); - if (!parsed) { - return c.json({ code: "INVALID_REQUEST" }, 400); - } - - // A well-formed PermissionId is not necessarily a *registered* one. Only the trusted registry - // (never the request) decides what may be granted — the same registry `requireManagePermission` - // already consults for the caller's own `core.authorization.grants.manage` check — so a caller - // cannot pre-create a dormant grant for an invented identifier that later becomes effective the - // moment someone registers it. Sanitized identically to any other denial: this must never reveal - // whether the identifier was malformed (400, above) or simply unregistered (403, here). - if (!options.registry.has(parsed.permission)) { - return c.json({ code: "AUTHORIZATION_DENIED" }, 403); - } - - try { - const grant = await options.store.createGrant({ - actor: createUserActor(principal.userId), - ...(parsed.expiresAt === undefined ? {} : { expiresAt: parsed.expiresAt }), - permission: parsed.permission, - ...(parsed.scope === undefined ? {} : { scope: parsed.scope }), - sourceKind: "explicit", - userId: parsed.subjectUserId, - }); - return c.json({ grant: serializeGrant(grant) }, 201); + return c.json( + { + grants: page.grants.map(serializeGrant), + nextCursor: + page.nextBoundary === undefined + ? null + : encodeCursor(query.subjectUserId, page.nextBoundary), + }, + 200, + ); } catch (error) { return sanitizedStoreErrorResponse(c, error); } }); routes.post( - "/api/v1/grants/:grantId/revoke", + "/api/v1/grants", requireOrigin, + mutationBodyLimit, requireManagePermission, async (c) => { c.header("Cache-Control", "no-store"); @@ -200,6 +346,57 @@ export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono { return c.json({ code: "AUTHORIZATION_DENIED" }, 403); } + let rawBody: unknown; + try { + rawBody = await c.req.json(); + } catch { + return c.json({ code: "INVALID_REQUEST" }, 400); + } + const parsed = parseCreateGrantBody(rawBody, now()); + if (!parsed) { + return c.json({ code: "INVALID_REQUEST" }, 400); + } + + // A well-formed PermissionId is not necessarily a *registered* one. Only the trusted registry + // (never the request) decides what may be granted — the same registry `requireManagePermission` + // already consults for the caller's own `core.authorization.grants.manage` check — so a caller + // cannot pre-create a dormant grant for an invented identifier that later becomes effective the + // moment someone registers it. Sanitized identically to any other denial: this must never reveal + // whether the identifier was malformed (400, above) or simply unregistered (403, here). + if (!options.registry.has(parsed.permission)) { + return c.json({ code: "AUTHORIZATION_DENIED" }, 403); + } + + try { + const grant = await options.store.createGrant({ + actor: createUserActor(principal.userId), + ...(parsed.expiresAt === undefined ? {} : { expiresAt: parsed.expiresAt }), + permission: parsed.permission, + sourceKind: "explicit", + userId: parsed.subjectUserId, + }); + return c.json({ grant: serializeGrant(grant) }, 201); + } catch (error) { + return sanitizedStoreErrorResponse(c, error); + } + }, + ); + + routes.post( + "/api/v1/grants/:grantId/revoke", + requireOrigin, + mutationBodyLimit, + requireManagePermission, + async (c) => { + c.header("Cache-Control", "no-store"); + const principal = getAuthorizedPrincipal(c); + if (!isAuthenticatedPrincipal(principal)) { + 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")); if (!grantId.success) { return c.json({ code: "INVALID_REQUEST" }, 400); @@ -208,7 +405,7 @@ export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono { try { await options.store.revokeGrant({ actor: createUserActor(principal.userId), - grantId: grantId.data, + grantId: grantId.data satisfies GrantId, sourceKind: "explicit", }); return c.json({ success: true }, 200); diff --git a/apps/api/src/module-http-routes.ts b/apps/api/src/module-http-routes.ts index 52c373d..c8ce3f9 100644 --- a/apps/api/src/module-http-routes.ts +++ b/apps/api/src/module-http-routes.ts @@ -11,6 +11,7 @@ import { requirePermission, requireTrustedOrigin, } from "./authorization-middleware.js"; +import { platformBodyLimit } from "./body-limits.js"; const MODULE_ROUTE_PREFIX = "/api/v1/modules"; const STATE_CHANGING_METHODS: ReadonlySet = new Set(["DELETE", "PATCH", "POST", "PUT"]); @@ -187,6 +188,10 @@ export function mountModuleHttpRoutes( } 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 finalHandler = async (c: Context): Promise => { 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 // spreading a variable-length handlers array. 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 { - app.on(route.method, path, requireRoutePermission, finalHandler); + app.on(route.method, path, routeBodyLimit, requireRoutePermission, finalHandler); } } } diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 6981599..1a1d831 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -1,3 +1,4 @@ +import type { Socket } from "node:net"; import path from "node:path"; import { pathToFileURL } from "node:url"; import type { ModuleRuntimeServices } from "@ekklesia/contracts"; @@ -14,6 +15,12 @@ import { import { serve, type ServerType } from "@hono/node-server"; import type { Hono } from "hono"; 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 ApiDatabaseHandle, createApiDatabase } from "./database.js"; import { prepareStartupModuleInventory } from "./platform-permissions.js"; @@ -21,6 +28,86 @@ import { prepareStartupModuleInventory } from "./platform-permissions.js"; const connectionTimeoutMs = 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> = 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 { readonly app: Hono; readonly server: ServerType; @@ -39,6 +126,17 @@ export interface StartApiOptions { * runtime startup. Never set by the executable entry point. See * `prepareStartupModuleInventory`. */ 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; + /** 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 { @@ -61,14 +159,146 @@ async function safeStopModuleRuntime(moduleRuntime: StartedModuleRuntime): Promi } } -function bindServer(app: Hono, host: string, port: number): Promise { +/** + * 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; + return ( + typeof candidate.closeIdleConnections === "function" && + typeof candidate.closeAllConnections === "function" + ); +} + +async function drainHttpServer( + server: ServerType, + timeoutMs: number, +): Promise<"drained" | "forced"> { + let timer: ReturnType | undefined; + const closed = new Promise((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 { return new Promise((resolve, reject) => { let settled = false; - const server = serve({ fetch: app.fetch, hostname: host, port }, () => { - if (!settled) { - settled = true; - resolve(server); - } + 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) { + settled = true; + 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", () => { if (!settled) { @@ -125,6 +355,10 @@ export async function startApi( const databaseHandle = createApiDatabase(config.databaseUrl, { connectionTimeoutMs, 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; @@ -153,7 +387,11 @@ export async function startApi( // handle to stop here, only the pool this failure leaves open. let moduleRuntime: StartedModuleRuntime; try { - moduleRuntime = await startDiscoveredModuleRuntime(inventory.modules, services); + moduleRuntime = await startDiscoveredModuleRuntime(inventory.modules, services, { + ...(options.moduleHookTimeoutMsOverride === undefined + ? {} + : { hookTimeoutMs: options.moduleHookTimeoutMsOverride }), + }); } catch (error) { await safeCloseDatabase(databaseHandle); throw error; @@ -178,9 +416,15 @@ export async function startApi( throw error; } + const clientAddressResolver = createClientAddressResolver(config.clientAddress); + const timeouts: NodeServerTimeouts = { + ...PRODUCTION_NODE_SERVER_TIMEOUTS, + ...options.nodeServerTimeoutOverride, + }; + let server: ServerType; try { - server = await bindServer(app, config.host, config.port); + server = await bindServer(app, config.host, config.port, clientAddressResolver, timeouts); } catch (error) { await safeStopModuleRuntime(moduleRuntime); 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 // 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. + const drainTimeoutMs = options.drainTimeoutMsOverride ?? HTTP_DRAIN_TIMEOUT_MS; let shutdownPromise: Promise | undefined; const shutdown = (): Promise => { if (!shutdownPromise) { shutdownPromise = (async () => { - await new Promise((resolve) => { - server.close(() => resolve()); - }); + const drainOutcome = await drainHttpServer(server, drainTimeoutMs); let moduleStopError: unknown; try { await moduleRuntime.stop(); @@ -214,8 +457,14 @@ export async function startApi( moduleStopError = error; } await safeCloseDatabase(databaseHandle); - if (moduleStopError) { - throw moduleStopError; + // Every stage above ran regardless of what the earlier ones did, so a drain timeout or a + // 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); } +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(): { readonly input: Parameters[0]; readonly environment: "development" | "production"; @@ -259,14 +527,25 @@ function readConfigFromEnvironment(): { ?.split(",") .map((origin) => origin.trim()) .filter((origin) => origin.length > 0); + const clientAddress = readClientAddressInputFromEnvironment(); return { environment: process.env.NODE_ENV === "development" ? "development" : "production", 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, host: process.env.EKKLESIA_API_HOST, identity: { baseUrl: process.env.EKKLESIA_IDENTITY_BASE_URL, + publicSignupEnabled: process.env.EKKLESIA_PUBLIC_SIGNUP_ENABLED, secret: process.env.EKKLESIA_IDENTITY_SECRET, trustedOrigins, }, diff --git a/apps/api/tests/api-http.integration.ts b/apps/api/tests/api-http.integration.ts index c25af5a..7dfd7b4 100644 --- a/apps/api/tests/api-http.integration.ts +++ b/apps/api/tests/api-http.integration.ts @@ -1,5 +1,6 @@ import { randomBytes } from "node:crypto"; import { request as httpRequest } from "node:http"; +import net from "node:net"; import { fileURLToPath } from "node:url"; import path from "node:path"; import { parseEkklesiaUserId, parsePermissionId, SYSTEM_ACTOR } from "@ekklesia/contracts"; @@ -18,8 +19,9 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { Client, type QueryResultRow } from "pg"; import { afterAll, describe, expect, it, vi } from "vitest"; +import type { ClientAddressConfigInput } from "../src/client-address.js"; import { createApiRuntimeConfig } from "../src/config.js"; -import { startApi, type RunningApi } from "../src/server.js"; +import { startApi, type NodeServerTimeouts, type RunningApi } from "../src/server.js"; const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL"; const coreMigrationsDirectory = path.join( @@ -186,6 +188,7 @@ async function applyCoreMigrations(url: string): Promise { expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([ "core:0000_identity_core_tables", "core:0001_authorization_core_tables", + "core:0002_authorization_integrity", ]); expect(aggregate.migrations[0]?.checksum).toBe( "f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f", @@ -193,6 +196,10 @@ async function applyCoreMigrations(url: string): Promise { expect(aggregate.migrations[1]?.checksum).toBe( "830e1fbc26d05af08df8d8710c3d10e1f3016d32c41b8363cb661c49afcbe81e", ); + // Pass 3's new Core migration, pinned the same way: 0000/0001 above must stay byte-identical. + expect(aggregate.migrations[2]?.checksum).toBe( + "63429473118065dfe3877a1baec61b8f61663b88127d9cb17707c859f27f6036", + ); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { await rm(emptyModulesDirectory, { recursive: true, force: true }); @@ -229,6 +236,97 @@ function createEventRecorder(): { events: string[]; expression: string } { * module also registers a restricted `GET /events/:eventId` route and a restricted, state-changing * `POST /events` route against that same permission, so the full HTTP mounting/enforcement chain * can be exercised live. */ +/** A handler gate shared with a dynamically imported fixture module through `globalThis`, the same + * technique `createEventRecorder` uses. The handler resolves `entered` when it is genuinely inside + * user code and then awaits `released`, so a test can position shutdown precisely instead of + * inferring handler entry from a connection count. `release` is idempotent and safe in `finally`. */ +function createHandlerGate(): { + readonly entered: Promise; + readonly expression: string; + release: () => void; +} { + const key = `ekklesia.api.integration.gate.${eventSymbols.length}.${Date.now()}.${Math.random()}`; + const symbol = Symbol.for(key); + let signalEntered: () => void = () => undefined; + let signalReleased: () => void = () => undefined; + const entered = new Promise((resolve) => { + signalEntered = resolve; + }); + const released = new Promise((resolve) => { + signalReleased = resolve; + }); + (globalThis as unknown as Record)[symbol] = { + enter: () => signalEntered(), + released, + }; + eventSymbols.push(symbol); + return { + entered, + expression: `globalThis[Symbol.for(${JSON.stringify(key)})]`, + release: () => signalReleased(), + }; +} + +/** A fixture module with one public route whose handler blocks on the supplied gate, plus a stop + * hook that records when it ran — everything the graceful-drain ordering assertion needs. */ +async function createGatedRouteModule( + modulesDirectory: string, + id: string, + recorder: { expression: string }, + gate: { expression: string }, +): Promise { + const permissionId = `${id}.gated.read`; + const moduleDirectory = path.join(modulesDirectory, id); + await mkdir(path.join(moduleDirectory, "dist/server"), { recursive: true }); + await Promise.all([ + writeFile( + path.join(moduleDirectory, "package.json"), + JSON.stringify({ + ekklesia: { layer: "module" }, + name: `@test/module-${id}`, + type: "module", + version: "0.1.0", + }), + ), + writeFile( + path.join(moduleDirectory, "ekklesia.module.json"), + JSON.stringify({ + compatibility: { moduleSdk: "^0.1.0", platform: "^0.1.0" }, + entrypoints: { server: "./dist/server/index.js" }, + id, + manifestVersion: 3, + name: `${id} module`, + permissions: [{ baseline: "public", description: "Gated drain probe.", id: permissionId }], + version: "0.1.0", + }), + ), + writeFile( + path.join(moduleDirectory, "dist/server/index.js"), + `export default { + id: ${JSON.stringify(id)}, + version: "0.1.0", + register({ http }) { + http.route({ + method: "GET", + path: "/gated", + permission: ${JSON.stringify(permissionId)}, + maxBodyBytes: 0, + async handler() { + ${recorder.expression}.push(${JSON.stringify(`${id}:handler-entered`)}); + ${gate.expression}.enter(); + await ${gate.expression}.released; + ${recorder.expression}.push(${JSON.stringify(`${id}:handler-finished`)}); + return Response.json({ gated: true }); + }, + }); + }, + start() {}, + stop() { ${recorder.expression}.push(${JSON.stringify(`${id}:stop`)}); }, + };`, + ), + ]); +} + async function createFixtureModule( modulesDirectory: string, id: string, @@ -274,6 +372,7 @@ async function createFixtureModule( method: "GET", path: "/events/:eventId", permission: "calendar.events.read", + maxBodyBytes: 0, async handler(request, context) { return Response.json({ eventId: context.params.eventId, principal: context.principal }); }, @@ -282,6 +381,7 @@ async function createFixtureModule( method: "POST", path: "/events", permission: "calendar.events.read", + maxBodyBytes: 1024, async handler(request, context) { return Response.json({ created: true, principal: context.principal }, { status: 201 }); }, @@ -325,30 +425,81 @@ function apiOrigin(running: RunningApi): string { return `http://127.0.0.1:${address.port}`; } -async function startMigratedApi( +interface StartConfiguredApiOptions { + readonly clientAddress?: ClientAddressConfigInput; + /** Test-only narrowing of the platform's 30-second graceful HTTP drain deadline. */ + readonly drainTimeoutMsOverride?: number; + readonly modulesDirectoryOverride?: string; + readonly nodeServerTimeoutOverride?: Partial; + /** Defaults to `true`: almost every existing scenario in this suite signs accounts up over HTTP + * and needs it enabled. Pass `false` explicitly for the dedicated signup-gating scenarios. */ + readonly publicSignupEnabled?: boolean; + /** Defaults to a generous override so this suite's own request volume never trips Better Auth's + * strict built-in sign-in/sign-up rule. The dedicated rate-limit/client-address scenarios pass + * `undefined` explicitly to exercise the real, unmodified production rule instead. */ + readonly rateLimitOverride?: { readonly max: number; readonly window: number } | undefined; +} + +async function startConfiguredApi( url: string, - modulesDirectoryOverride?: string, + options: StartConfiguredApiOptions = {}, ): Promise { + const publicSignupEnabled = options.publicSignupEnabled ?? true; + // This harness explicitly selects development configuration, for two independent reasons that + // both apply to every scenario in this suite: + // + // 1. It connects to the disposable *plaintext* loopback PostgreSQL. Production configuration + // requires certificate-verified TLS with no exception, so a plaintext test database can + // only be used by a deployment that has explicitly declared itself non-production. + // 2. Scenarios that create accounts over HTTP need public signup, which production refuses to + // enable at all until durable abuse controls exist. + // + // This changes only Ekklesia's own configuration-validation strictness — `environment` never + // reaches Better Auth — and the harness keeps the identical https, non-loopback base URL and + // trusted origins regardless, so the production-equivalent origin/CSRF behavior ADR 0012 + // established is exercised exactly the same either way. + const environment = "development" as const; const config = createApiRuntimeConfig( { + ...(options.clientAddress === undefined ? {} : { clientAddress: options.clientAddress }), databaseUrl: url, host: "127.0.0.1", identity: { baseUrl: identityBaseUrl, + publicSignupEnabled, secret: "integration-test-secret-0123456789abcdefgh", trustedOrigins: [identityBaseUrl, dashboardOrigin], }, port: 0, }, - { environment: "production" }, + { environment }, ); + return startApi(config, { + ...("rateLimitOverride" in options + ? { ...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}) } + : { rateLimitOverride: { max: 1000, window: 1 } }), + ...(options.modulesDirectoryOverride === undefined + ? {} + : { modulesDirectoryOverride: options.modulesDirectoryOverride }), + ...(options.nodeServerTimeoutOverride === undefined + ? {} + : { nodeServerTimeoutOverride: options.nodeServerTimeoutOverride }), + ...(options.drainTimeoutMsOverride === undefined + ? {} + : { drainTimeoutMsOverride: options.drainTimeoutMsOverride }), + }); +} + +async function startMigratedApi( + url: string, + modulesDirectoryOverride?: string, +): Promise { // Narrow, explicit test-only overrides: production composition (server.ts's `main`) never sets // either. Better Auth's built-in sign-in/sign-up rate limit (3 requests / 10s) would otherwise // reject this suite's many sequential requests; `modulesDirectoryOverride` lets a scenario point // permission composition at a real, disposable module fixture instead of this repository's own // (empty) `modules/` directory. - return startApi(config, { - rateLimitOverride: { max: 1000, window: 1 }, + return startConfiguredApi(url, { ...(modulesDirectoryOverride === undefined ? {} : { modulesDirectoryOverride }), }); } @@ -456,6 +607,69 @@ function rawPostRequest( }); } +interface RawSignInAttemptOptions { + /** Originates the outbound connection from this local address — real, distinct loopback + * addresses (127.0.0.2, 127.0.0.3, ...) are all independently routable on the loopback interface, + * so this proves genuinely different real socket peers, not merely different header claims. */ + readonly localAddress?: string; + /** A raw, unvalidated `X-Forwarded-For` header value to send — deliberately not parsed or + * validated here, since these tests are proving how the *server* handles it. */ + readonly forwardedFor?: string; +} + +interface RawResponse { + readonly body: string; + readonly headers: Record; + readonly status: number; +} + +/** + * A raw `node:http` sign-in attempt against a deliberately wrong password. Better Auth's rate + * limiter keys and counts every `/sign-in*` request before ever validating credentials, so this + * never needs a real account and always resolves to either `401` (accepted by the limiter, wrong + * credentials) or `429` (rejected by the limiter) — the only two outcomes these tests distinguish. + * Raw `node:http`, not `fetch()`, for the same Fetch-Metadata reason `rawPostRequest` documents. + */ +function rawSignInAttempt( + origin: string, + options: RawSignInAttemptOptions = {}, +): Promise { + return new Promise((resolve, reject) => { + const url = new URL("/api/auth/sign-in/email", origin); + const payload = JSON.stringify({ + email: "nobody-in-particular@example.test", + password: "definitely-the-wrong-password", + }); + const req = httpRequest( + url, + { + headers: { + "content-length": Buffer.byteLength(payload), + "content-type": "application/json", + ...(options.forwardedFor === undefined + ? {} + : { "x-forwarded-for": options.forwardedFor }), + }, + ...(options.localAddress === undefined ? {} : { localAddress: options.localAddress }), + method: "POST", + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk) => chunks.push(chunk as Buffer)); + res.on("end", () => { + resolve({ + body: Buffer.concat(chunks).toString("utf8"), + headers: res.headers, + status: res.statusCode ?? 0, + }); + }); + }, + ); + req.on("error", reject); + req.end(payload); + }); +} + describe.sequential("live API — signup, sign-in, principal, sign-out", () => { let database: { name: string; url: string }; let running: RunningApi; @@ -798,12 +1012,15 @@ describe.sequential("live API — startup and migration guarantees", () => { host: "127.0.0.1", identity: { baseUrl: identityBaseUrl, + publicSignupEnabled: true, secret: "integration-test-secret-0123456789abcdefgh", trustedOrigins: [identityBaseUrl], }, port: 0, }, - { environment: "production" }, + // Development validation, for the same reason `startConfiguredApi` documents: this scenario + // drives a real sign-up request, which production configuration refuses to enable at all. + { environment: "development" }, ); const running = await startApi(config, { rateLimitOverride: { max: 1000, window: 1 } }); const origin = apiOrigin(running); @@ -869,6 +1086,277 @@ describe.sequential("live API — principal infrastructure failure is never down }); }); +describe.sequential("live API — session-store readiness against real PostgreSQL", () => { + /** + * Every scenario below drives a real, migrated PostgreSQL database, breaks exactly one property + * of the *session store specifically*, and asserts the public contract. The administrative + * connection each helper opens (`openAdministrativeClient`) is **test-only provisioning + * infrastructure** — it renames a relation, drops a column, or revokes a privilege in a + * disposable database, and is closed in `finally`. It is never part of the API runtime, which + * continues to own exactly one pool through `startApi`. + */ + async function withBrokenSessionStore( + label: string, + breakStore: (admin: Client) => Promise, + assertions: (context: { + readonly cookie: string; + readonly origin: string; + readonly logs: string[]; + }) => Promise, + ): Promise { + const database = await createDisposableDatabase(label); + await applyCoreMigrations(database.url); + const running = await startConfiguredApi(database.url); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + try { + const origin = apiOrigin(running); + const email = `${label}@example.test`; + const password = "correct horse battery staple"; + await signUp(origin, email, password); + const { cookiePair } = await signIn(origin, email, password); + expect(cookiePair).toBeDefined(); + + // The session resolves normally before anything is broken. + const before = await fetchPrincipal(origin, { cookie: cookiePair as string }); + expect(before.response.status).toBe(200); + expect(before.body).toMatchObject({ kind: "authenticated" }); + + const admin = await openAdministrativeClient(database.url); + try { + await breakStore(admin); + } finally { + await safeEnd(admin); + } + + await assertions({ + cookie: cookiePair as string, + logs: errorSpy.mock.calls.flat().map((value) => String(value)), + origin, + }); + } finally { + errorSpy.mockRestore(); + await running.shutdown().catch(() => undefined); + await dropDatabase(database.name); + } + } + + /** The exact public contract a broken session store must produce — never anonymous, never 403, + * and never carrying a relation name, column name, privilege detail, SQL, host, or stack. */ + async function expectSanitizedUnavailable(origin: string, cookie: string): Promise { + const response = await fetch(`${origin}/api/v1/principal`, { headers: { cookie } }); + expect(response.status).toBe(503); + expect(response.headers.get("cache-control")).toBe("no-store"); + const text = await response.clone().text(); + expect(JSON.parse(text)).toEqual({ code: "PRINCIPAL_RESOLUTION_FAILED" }); + for (const forbidden of [ + "ekklesia_core_session", + "permission denied", + "does not exist", + "relation", + "column", + "select", + "postgres", + "5432", + ]) { + expect(text.toLowerCase()).not.toContain(forbidden.toLowerCase()); + } + return response; + } + + it("returns a sanitized 503 — not anonymous — when the session table is missing, even though a generic SELECT 1 still succeeds", async () => { + await withBrokenSessionStore( + "session_missing_table", + async (admin) => { + // Prove the distinction this pass exists for: the connection is perfectly healthy for a + // trivial statement, so the previous `select 1` probe would have reported "reachable" and + // the caller would have been reported as genuinely anonymous. + const trivial = await safeQuery<{ one: number }>(admin, "SELECT 1 AS one"); + expect(trivial[0]?.one).toBe(1); + await safeQuery( + admin, + 'ALTER TABLE "ekklesia_core_session" RENAME TO "ekklesia_core_session_moved"', + ); + }, + async ({ cookie, origin }) => { + await expectSanitizedUnavailable(origin, cookie); + }, + ); + }); + + it("returns a sanitized 503 when an expected session column is missing", async () => { + await withBrokenSessionStore( + "session_missing_column", + async (admin) => { + await safeQuery(admin, 'ALTER TABLE "ekklesia_core_session" DROP COLUMN "token" CASCADE'); + }, + async ({ cookie, origin }) => { + await expectSanitizedUnavailable(origin, cookie); + }, + ); + }); + + it("returns a sanitized 503 when SELECT privilege on the session table is genuinely revoked", async () => { + // A real privilege denial needs the API to connect as a role that is *not* the table owner — + // an owner's implicit rights cannot be revoked. This provisions a dedicated restricted role + // (test-only infrastructure), grants it everything except SELECT on the session table, and + // runs the API entirely as that role. + const database = await createDisposableDatabase("session_denied"); + await applyCoreMigrations(database.url); + const restrictedRole = `ekk_restricted_${randomBytes(4).toString("hex")}`; + const restrictedPassword = randomBytes(12).toString("hex"); + + const admin = await openAdministrativeClient(database.url); + try { + await safeQuery( + admin, + `CREATE ROLE "${restrictedRole}" LOGIN PASSWORD '${restrictedPassword}'`, + ); + await safeQuery(admin, `GRANT USAGE ON SCHEMA public TO "${restrictedRole}"`); + await safeQuery( + admin, + `GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO "${restrictedRole}"`, + ); + // The one thing this role may not do: read the session relation. + await safeQuery(admin, `REVOKE ALL ON "ekklesia_core_session" FROM "${restrictedRole}"`); + } finally { + await safeEnd(admin); + } + + const restrictedUrl = (() => { + const parsed = new URL(database.url); + parsed.username = restrictedRole; + parsed.password = restrictedPassword; + return parsed.toString(); + })(); + + const running = await startConfiguredApi(restrictedUrl); + try { + const origin = apiOrigin(running); + // The restricted role can still run a trivial statement and can still read non-session + // tables, so only the session store specifically is broken. + const response = await fetch(`${origin}/api/v1/principal`, { + headers: { cookie: "__Secure-better-auth.session_token=any-token-shape" }, + }); + const text = await response.clone().text(); + + expect(response.status).toBe(503); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(JSON.parse(text)).toEqual({ code: "PRINCIPAL_RESOLUTION_FAILED" }); + expect(text).not.toContain("ekklesia_core_session"); + expect(text.toLowerCase()).not.toContain("permission denied"); + expect(text).not.toContain(restrictedRole); + } finally { + await running.shutdown().catch(() => undefined); + // Drop the disposable database first (it holds the role's dependencies), then the role. + await dropDatabase(database.name).catch(() => undefined); + const cleanup = await openAdministrativeClient(baseDatabaseUrl()); + try { + await safeQuery(cleanup, `DROP ROLE IF EXISTS "${restrictedRole}"`); + } finally { + await safeEnd(cleanup); + } + } + }); + + it("keeps a genuinely unknown, expired, or revoked session anonymous while the store is healthy", async () => { + const database = await createDisposableDatabase("session_healthy"); + await applyCoreMigrations(database.url); + const running = await startConfiguredApi(database.url); + try { + const origin = apiOrigin(running); + const email = "healthy-store@example.test"; + const password = "correct horse battery staple"; + await signUp(origin, email, password); + const { cookiePair } = await signIn(origin, email, password); + expect(cookiePair).toBeDefined(); + const cookieName = (cookiePair as string).split("=")[0] as string; + + // A completely unknown token against a healthy store is genuinely anonymous. + const unknown = await fetchPrincipal(origin, { cookie: `${cookieName}=not-a-real-token` }); + expect(unknown.response.status).toBe(200); + expect(unknown.body).toEqual({ kind: "anonymous" }); + + // A revoked (signed-out) session is likewise genuinely anonymous, not a 503. + const signOut = await fetch(`${origin}/api/auth/sign-out`, { + body: "{}", + headers: { + "content-type": "application/json", + cookie: cookiePair as string, + origin: dashboardOrigin, + }, + method: "POST", + }); + expect(signOut.status).toBe(200); + const revoked = await fetchPrincipal(origin, { cookie: cookiePair as string }); + expect(revoked.response.status).toBe(200); + expect(revoked.body).toEqual({ kind: "anonymous" }); + + // An expired session (expiry forced into the past) is anonymous, not a 503. + const admin = await openAdministrativeClient(database.url); + try { + await safeQuery( + admin, + `UPDATE "ekklesia_core_session" SET expires_at = now() - interval '1 day'`, + ); + } finally { + await safeEnd(admin); + } + const secondSignIn = await signIn(origin, email, password); + const expiredCookie = secondSignIn.cookiePair as string; + const adminAgain = await openAdministrativeClient(database.url); + try { + await safeQuery( + adminAgain, + `UPDATE "ekklesia_core_session" SET expires_at = now() - interval '1 day'`, + ); + } finally { + await safeEnd(adminAgain); + } + const expired = await fetchPrincipal(origin, { cookie: expiredCookie }); + expect(expired.response.status).toBe(200); + expect(expired.body).toEqual({ kind: "anonymous" }); + } finally { + await running.shutdown().catch(() => undefined); + await dropDatabase(database.name); + } + }); + + it("performs no identity database work for an unrelated cookie, proven against a database whose session table is missing", async () => { + const database = await createDisposableDatabase("session_unrelated_cookie"); + await applyCoreMigrations(database.url); + const running = await startConfiguredApi(database.url); + try { + const origin = apiOrigin(running); + // Break the session store outright. Any request that actually touched it would now 503. + const admin = await openAdministrativeClient(database.url); + try { + await safeQuery( + admin, + 'ALTER TABLE "ekklesia_core_session" RENAME TO "ekklesia_core_session_moved"', + ); + } finally { + await safeEnd(admin); + } + + // Unrelated cookies must still resolve as anonymous with a 200: they never triggered + // session-store work in the first place. This is the original reproduction, inverted. + for (const cookie of ["x=y", "a=1; b=2; c=3", "better_auth_session_token=v"]) { + const { body, response } = await fetchPrincipal(origin, { cookie }); + expect(response.status).toBe(200); + expect(body).toEqual({ kind: "anonymous" }); + } + + // No cookie at all behaves identically. + const none = await fetchPrincipal(origin); + expect(none.response.status).toBe(200); + expect(none.body).toEqual({ kind: "anonymous" }); + } finally { + await running.shutdown().catch(() => undefined); + await dropDatabase(database.name); + } + }); +}); + describe.sequential("live API — Better Auth logging never leaks operational detail", () => { it("emits no raw exception, SQL, connection detail, origin header, or stack trace when a real identity database failure occurs", async () => { const database = await createDisposableDatabase("logging_leak"); @@ -1004,7 +1492,7 @@ describe.sequential("live API — platform owner and delegated grant management" headers: { cookie: ownerCookie }, }); expect(response.status).toBe(200); - expect(await response.json()).toEqual({ grants: [] }); + expect(await response.json()).toEqual({ grants: [], nextCursor: null }); }); it("lets the owner delegate grants.manage to another account through the explicit-grant HTTP API", async () => { @@ -1088,10 +1576,13 @@ describe.sequential("live API — platform owner and delegated grant management" it("creates an explicit grant via HTTP, deriving sourceKind and actor server-side, and records an atomic audit entry", async () => { const response = await fetch(`${origin}/api/v1/grants`, { + // A subject with no equivalent grant yet — the manager already holds an equivalent + // `grants.manage` grant from the delegation scenario above, which the new duplicate + // invariant would (correctly) refuse. Unscoped: the generic HTTP surface creates only + // unscoped explicit grants, since a scope it accepted could never authorize anything. body: JSON.stringify({ permission: testPermission, - scope: "calendar-event:1", - subjectUserId: managerUserId, + subjectUserId: ownerUserId, }), headers: { "content-type": "application/json", @@ -1106,7 +1597,7 @@ describe.sequential("live API — platform owner and delegated grant management" expect(response.status).toBe(201); expect(body.grant.sourceKind).toBe("explicit"); - expect(body.grant.userId).toBe(managerUserId); + expect(body.grant.userId).toBe(ownerUserId); createdGrantId = body.grant.id; const admin = await openAdministrativeClient(database.url); @@ -1122,14 +1613,21 @@ describe.sequential("live API — platform owner and delegated grant management" } }); - it("lists the explicit grant via HTTP", async () => { - const response = await fetch(`${origin}/api/v1/grants?userId=${managerUserId}`, { + it("lists the explicit grant via HTTP, bounded and with an explicit next-page cursor field", async () => { + const response = await fetch(`${origin}/api/v1/grants?userId=${ownerUserId}`, { headers: { cookie: ownerCookie }, }); - const body = (await response.json()) as { grants: Array<{ id: string }> }; + const body = (await response.json()) as { + grants: Array<{ id: string; scope: string | null }>; + nextCursor: string | null; + }; expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); expect(body.grants.some((grant) => grant.id === createdGrantId)).toBe(true); + // Created through the generic HTTP surface, so it is unscoped. + expect(body.grants.find((grant) => grant.id === createdGrantId)?.scope).toBeNull(); + expect(body.nextCursor).toBeNull(); }); it("rejects a create-grant request carrying disallowed fields such as actor or sourceKind, even alongside otherwise-valid fields", async () => { @@ -1267,6 +1765,302 @@ describe.sequential("live API — authorization infrastructure failure is never }); }); +/** + * Pass 3's explicit-grant invariants, exercised end-to-end through a real authorized + * grants-management session against real PostgreSQL — the layer where a validation decision that + * "should" happen before the database either does or does not. + */ +describe.sequential("live API — explicit-grant invariants over real HTTP", () => { + let database: { name: string; url: string }; + let running: RunningApi; + let origin: string; + let ownerUserId: string; + let subjectUserId: string; + let ownerCookie: string; + const password = "correct horse battery staple"; + const managePermission = "core.authorization.grants.manage"; + + async function post(path: string, body?: unknown): Promise { + return await fetch(`${origin}${path}`, { + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + headers: { + ...(body === undefined ? {} : { "content-type": "application/json" }), + cookie: ownerCookie, + origin: dashboardOrigin, + }, + method: "POST", + }); + } + + async function list(query: string): Promise<{ body: unknown; response: Response }> { + const response = await fetch(`${origin}/api/v1/grants?${query}`, { + headers: { cookie: ownerCookie }, + }); + return { body: await response.json(), response }; + } + + it("starts a freshly migrated API with a bootstrapped owner and a separate grant subject", async () => { + database = await createDisposableDatabase("grant_invariants"); + await applyCoreMigrations(database.url); + running = await startMigratedApi(database.url); + origin = apiOrigin(running); + + ownerUserId = (await signUp(origin, "invariants-owner@example.test", password)).body.user.id; + subjectUserId = (await signUp(origin, "invariants-subject@example.test", password)).body.user + .id; + + const { close, db } = createAuthorizationDrizzleDatabase(database.url); + try { + await bootstrapPlatformOwner(db, { userId: parseEkklesiaUserId(ownerUserId) }); + } finally { + await close(); + } + const ownerSignIn = await signIn(origin, "invariants-owner@example.test", password); + expect(ownerSignIn.cookiePair).toBeDefined(); + ownerCookie = ownerSignIn.cookiePair as string; + }); + + it("rejects a malformed non-UUID grant id with a sanitized 400, never a database failure", async () => { + for (const malformed of [ + "not-a-uuid", + "0B36B1B6-9A29-4C4E-9E2D-4C0E4C5E6F70", + "0b36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f7", + ]) { + const response = await post(`/api/v1/grants/${malformed}/revoke`); + const raw = await response.text(); + + // Before Pass 3 this reached PostgreSQL's own UUID comparison and returned 503. + expect(response.status, malformed).toBe(400); + expect(JSON.parse(raw)).toEqual({ code: "INVALID_REQUEST" }); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(raw).not.toContain(malformed); + } + }); + + it("returns the sanitized 404 for a canonical but unknown grant id", async () => { + const response = await post("/api/v1/grants/00000000-0000-4000-8000-000000000000/revoke"); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_NOT_FOUND" }); + }); + + it("rejects a scoped create body with a sanitized 400 and writes nothing", async () => { + const response = await post("/api/v1/grants", { + permission: managePermission, + scope: "calendar-event:1", + subjectUserId, + }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ code: "INVALID_REQUEST" }); + + const admin = await openAdministrativeClient(database.url); + try { + expect( + await safeQuery(admin, `select id from "ekklesia_core_grant" where user_id = $1`, [ + subjectUserId, + ]), + ).toHaveLength(0); + } finally { + await safeEnd(admin); + } + }); + + it("rejects normalized-impossible, non-canonical, and past expirations with a sanitized 400", async () => { + for (const expiresAt of [ + "2026-02-31T00:00:00.000Z", + "March 5, 2027", + "2027-01-01", + "2027-01-01T00:00:00Z", + "2027-01-01T00:00:00.000+02:00", + "2020-01-01T00:00:00.000Z", + ]) { + const response = await post("/api/v1/grants", { + expiresAt, + permission: managePermission, + subjectUserId, + }); + expect(response.status, expiresAt).toBe(400); + expect(await response.json()).toEqual({ code: "INVALID_REQUEST" }); + } + }); + + it("creates one grant, then answers an equivalent creation with a sanitized 409 and no second row", async () => { + const created = await post("/api/v1/grants", { + permission: managePermission, + subjectUserId, + }); + expect(created.status).toBe(201); + + const duplicate = await post("/api/v1/grants", { + permission: managePermission, + subjectUserId, + }); + const raw = await duplicate.text(); + expect(duplicate.status).toBe(409); + expect(JSON.parse(raw)).toEqual({ code: "AUTHORIZATION_GRANT_ALREADY_EXISTS" }); + expect(duplicate.headers.get("cache-control")).toBe("no-store"); + // No existing grant id, actor, scope, source reference, SQL, or driver text leaks. + expect(raw).not.toContain(ownerUserId); + expect(raw).not.toContain(subjectUserId); + expect(raw).not.toContain("ekklesia_core_grant"); + + // A differing expiration is not a different grant either. + const withExpiry = await post("/api/v1/grants", { + expiresAt: "2099-01-01T00:00:00.000Z", + permission: managePermission, + subjectUserId, + }); + expect(withExpiry.status).toBe(409); + + const admin = await openAdministrativeClient(database.url); + try { + expect( + await safeQuery( + admin, + `select id from "ekklesia_core_grant" where user_id = $1 and revoked_at is null`, + [subjectUserId], + ), + ).toHaveLength(1); + } finally { + await safeEnd(admin); + } + }); + + it("paginates deterministically across at least three pages and ends with a null cursor", async () => { + // Seeded through the test-only administrative client so the page count is deterministic and + // independent of how many grants the scenarios above happened to create. + const admin = await openAdministrativeClient(database.url); + try { + 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.page' || generation, null, 'explicit', null, 'user', $2, + timestamptz '2026-01-01 00:00:00Z' + from generate_series(1, 6) as generation`, + [subjectUserId, ownerUserId], + ); + 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) + values ($1, 'roster.volunteer.read', null, 'relationship', 'roster:1', 'user', $2)`, + [subjectUserId, ownerUserId], + ); + } finally { + await safeEnd(admin); + } + + const seen: string[] = []; + let cursor: string | null = null; + let pages = 0; + for (;;) { + const query = `userId=${subjectUserId}&limit=3${ + cursor === null ? "" : `&cursor=${encodeURIComponent(cursor)}` + }`; + const { body, response } = await list(query); + const page = body as { + grants: Array<{ id: string; sourceKind: string }>; + nextCursor: string | null; + }; + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(page.grants.length).toBeLessThanOrEqual(3); + // Relationship grants never appear on any page. + expect(page.grants.every((grant) => grant.sourceKind === "explicit")).toBe(true); + seen.push(...page.grants.map((grant) => grant.id)); + pages += 1; + if (page.nextCursor === null) { + break; + } + cursor = page.nextCursor; + expect(pages).toBeLessThan(10); + } + + expect(pages).toBeGreaterThanOrEqual(3); + expect(seen).toHaveLength(7); + expect(new Set(seen).size).toBe(seen.length); + }); + + it("rejects an out-of-range limit and a foreign or malformed cursor with a sanitized 400", async () => { + for (const query of [ + `userId=${subjectUserId}&limit=0`, + `userId=${subjectUserId}&limit=101`, + `userId=${subjectUserId}&limit=01`, + `userId=${subjectUserId}&limit=1&limit=2`, + `userId=${subjectUserId}&unknown=1`, + `userId=${subjectUserId}&cursor=!!!`, + // A cursor minted for a different subject must not be replayable against this one. + `userId=${subjectUserId}&cursor=${encodeURIComponent( + Buffer.from( + JSON.stringify({ + g: "00000000-0000-4000-8000-000000000000", + t: "2026-01-01T00:00:00.000Z", + u: ownerUserId, + v: 1, + }), + "utf8", + ).toString("base64url"), + )}`, + ]) { + const { body, response } = await list(query); + expect(response.status, query).toBe(400); + expect(body).toEqual({ code: "INVALID_REQUEST" }); + } + }); + + it("revokes every unrevoked equivalent through one HTTP revocation, leaving nothing authorizing", async () => { + const permission = "calendar.events.legacydupe"; + const admin = await openAdministrativeClient(database.url); + let ids: string[] = []; + try { + // Legacy duplicates the pre-Pass-3 store could produce, planted directly because the + // production store now refuses to create them. + 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), + ($1, $2, null, 'explicit', null, 'user', $3) + returning id`, + [subjectUserId, permission, ownerUserId], + ); + ids = rows.map((row) => row.id); + } finally { + await safeEnd(admin); + } + + const response = await post(`/api/v1/grants/${ids[0]}/revoke`); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ success: true }); + + const inspector = await openAdministrativeClient(database.url); + try { + const remaining = await safeQuery( + inspector, + `select id from "ekklesia_core_grant" + where user_id = $1 and permission_id = $2 and revoked_at is null`, + [subjectUserId, permission], + ); + expect(remaining).toHaveLength(0); + const audit = await safeQuery<{ grant_id: string }>( + inspector, + `select grant_id from "ekklesia_core_authorization_audit" + where action = 'grant.revoke' and grant_id = any($1::uuid[])`, + [ids], + ); + expect([...new Set(audit.map((row) => row.grant_id))].sort()).toEqual([...ids].sort()); + } finally { + await safeEnd(inspector); + } + }); + + it("shuts down cleanly", async () => { + await running.shutdown(); + await dropDatabase(database.name); + }); +}); + describe.sequential("live API — installed module permission end-to-end", () => { let database: { name: string; url: string }; let modulesDirectory: string; @@ -1528,3 +2322,684 @@ describe.sequential("live API — installed module permission end-to-end", () => await dropDatabase(database.name); }); }); + +describe.sequential("live API — public signup is disabled by default", () => { + it("rejects sign-up with a stable sanitized 400 when publicSignupEnabled is not set, creating no user row", async () => { + const database = await createDisposableDatabase("signup_disabled"); + await applyCoreMigrations(database.url); + const running = await startConfiguredApi(database.url, { publicSignupEnabled: false }); + try { + const origin = apiOrigin(running); + const response = await fetch(`${origin}/api/auth/sign-up/email`, { + body: JSON.stringify({ + email: "blocked@example.test", + name: "Blocked", + password: "correct horse battery staple", + }), + headers: { "content-type": "application/json", origin: dashboardOrigin }, + method: "POST", + }); + const text = await response.text(); + + expect(response.status).toBe(400); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(text.toLowerCase()).not.toContain("stack"); + expect(text).not.toContain(database.url); + + const admin = await openAdministrativeClient(database.url); + try { + const rows = await safeQuery<{ count: string }>( + admin, + 'select count(*)::text as count from "ekklesia_core_user"', + ); + expect(rows[0]?.count).toBe("0"); + } finally { + await safeEnd(admin); + } + } finally { + await running.shutdown().catch(() => undefined); + await dropDatabase(database.name); + } + }); + + it("gives an identical rejection whether or not the requested email is already registered", async () => { + const database = await createDisposableDatabase("signup_disabled_enum"); + await applyCoreMigrations(database.url); + const existingEmail = "already-registered@example.test"; + + // Briefly enable signup on this same database to seed one real, pre-existing account, matching + // how a production database might have been seeded before signup was later disabled. + const seedRunning = await startConfiguredApi(database.url, { publicSignupEnabled: true }); + try { + const seedSignUp = await signUp( + apiOrigin(seedRunning), + existingEmail, + "correct horse battery", + ); + expect(seedSignUp.response.status).toBe(200); + } finally { + await seedRunning.shutdown().catch(() => undefined); + } + + const running = await startConfiguredApi(database.url, { publicSignupEnabled: false }); + try { + const origin = apiOrigin(running); + const signUpAttempt = (email: string) => + fetch(`${origin}/api/auth/sign-up/email`, { + body: JSON.stringify({ email, name: "X", password: "correct horse battery staple" }), + headers: { "content-type": "application/json", origin: dashboardOrigin }, + method: "POST", + }); + + const existingResponse = await signUpAttempt(existingEmail); + const newResponse = await signUpAttempt("brand-new-address@example.test"); + + expect(existingResponse.status).toBe(400); + expect(newResponse.status).toBe(400); + expect(existingResponse.status).toBe(newResponse.status); + expect(await existingResponse.text()).toBe(await newResponse.text()); + } finally { + await running.shutdown().catch(() => undefined); + await dropDatabase(database.name); + } + }); + + it("keeps sign-in and existing-session behavior fully functional for a pre-existing account while signup stays disabled", async () => { + const database = await createDisposableDatabase("signup_disabled_signin"); + await applyCoreMigrations(database.url); + const email = "still-can-sign-in@example.test"; + const password = "correct horse battery staple"; + + const seedRunning = await startConfiguredApi(database.url, { publicSignupEnabled: true }); + try { + const seedSignUp = await signUp(apiOrigin(seedRunning), email, password); + expect(seedSignUp.response.status).toBe(200); + } finally { + await seedRunning.shutdown().catch(() => undefined); + } + + const running = await startConfiguredApi(database.url, { publicSignupEnabled: false }); + try { + const origin = apiOrigin(running); + const signInResult = await signIn(origin, email, password); + expect(signInResult.response.status).toBe(200); + expect(signInResult.cookiePair).toBeDefined(); + + const { body } = await fetchPrincipal(origin, { cookie: signInResult.cookiePair as string }); + expect(body).toMatchObject({ kind: "authenticated" }); + + const blockedSignUp = await fetch(`${origin}/api/auth/sign-up/email`, { + body: JSON.stringify({ email: "another-new-address@example.test", name: "X", password }), + headers: { "content-type": "application/json", origin: dashboardOrigin }, + method: "POST", + }); + expect(blockedSignUp.status).toBe(400); + } finally { + await running.shutdown().catch(() => undefined); + await dropDatabase(database.name); + } + }); +}); + +describe.sequential("live API — client-address resolution and auth rate-limit identity", () => { + let database: { name: string; url: string }; + let directModeApi: RunningApi; + let directOrigin: string; + + it("starts a direct-mode API (the safe default) against a freshly migrated database, with Better Auth's real unmodified rate-limit rule", async () => { + database = await createDisposableDatabase("client_address"); + await applyCoreMigrations(database.url); + directModeApi = await startConfiguredApi(database.url, { rateLimitOverride: undefined }); + directOrigin = apiOrigin(directModeApi); + expect(directModeApi.server.listening).toBe(true); + }); + + it("reproduces the original vulnerability's exact shape and proves it is closed: rotating spoofed X-Forwarded-For values never bypass the limiter in direct mode", async () => { + // Before this pass, Better Auth trusted a caller-controlled X-Forwarded-For directly: each + // distinct spoofed value looked like an unrelated client, so a caller could send an unlimited + // number of sign-in attempts merely by rotating the header on every request. Direct mode now + // resolves identity from the real socket peer only — the header is never consulted. + const peer = "127.0.0.21"; + const first = await rawSignInAttempt(directOrigin, { + forwardedFor: "1.1.1.1", + localAddress: peer, + }); + const second = await rawSignInAttempt(directOrigin, { + forwardedFor: "2.2.2.2", + localAddress: peer, + }); + const third = await rawSignInAttempt(directOrigin, { + forwardedFor: "3.3.3.3", + localAddress: peer, + }); + expect([first.status, second.status, third.status]).toEqual([401, 401, 401]); + + // The fabricated value on this fourth request is new *again* — under the pre-fix behavior this + // would still look like a brand-new, never-before-seen client and would succeed. It must not. + const fourth = await rawSignInAttempt(directOrigin, { + forwardedFor: "4.4.4.4", + localAddress: peer, + }); + expect(fourth.status).toBe(429); + // The limiter's own existing response semantics are preserved unmodified. + expect(fourth.headers["x-retry-after"]).toBeDefined(); + expect(JSON.parse(fourth.body)).toMatchObject({ message: expect.any(String) }); + + // A request from the identical real peer with *no* forwarded header at all is equally blocked + // — proving the header was never what determined the bucket, in either direction. + const fifth = await rawSignInAttempt(directOrigin, { localAddress: peer }); + expect(fifth.status).toBe(429); + }); + + it("gives distinct real peer addresses independent rate-limit buckets", async () => { + const peerA = "127.0.0.22"; + const peerB = "127.0.0.23"; + expect((await rawSignInAttempt(directOrigin, { localAddress: peerA })).status).toBe(401); + expect((await rawSignInAttempt(directOrigin, { localAddress: peerA })).status).toBe(401); + expect((await rawSignInAttempt(directOrigin, { localAddress: peerA })).status).toBe(401); + expect((await rawSignInAttempt(directOrigin, { localAddress: peerA })).status).toBe(429); + + // An unrelated real peer's budget is completely untouched by peerA's exhaustion. + expect((await rawSignInAttempt(directOrigin, { localAddress: peerB })).status).toBe(401); + }); + + it("shuts down the direct-mode API", async () => { + await directModeApi.shutdown(); + }); + + let proxyModeApi: RunningApi; + let proxyOrigin: string; + const trustedProxyPeer = "127.0.0.24"; + + it("starts a second API in explicit trusted-proxy mode against the same migrated database", async () => { + proxyModeApi = await startConfiguredApi(database.url, { + clientAddress: { + header: "x-forwarded-for", + mode: "trusted-proxy", + trustedProxies: [trustedProxyPeer], + }, + rateLimitOverride: undefined, + }); + proxyOrigin = apiOrigin(proxyModeApi); + }); + + it("honors a forwarded address only when it comes from the explicitly trusted proxy peer", async () => { + expect( + ( + await rawSignInAttempt(proxyOrigin, { + forwardedFor: "203.0.113.50", + localAddress: trustedProxyPeer, + }) + ).status, + ).toBe(401); + expect( + ( + await rawSignInAttempt(proxyOrigin, { + forwardedFor: "203.0.113.50", + localAddress: trustedProxyPeer, + }) + ).status, + ).toBe(401); + expect( + ( + await rawSignInAttempt(proxyOrigin, { + forwardedFor: "203.0.113.50", + localAddress: trustedProxyPeer, + }) + ).status, + ).toBe(401); + expect( + ( + await rawSignInAttempt(proxyOrigin, { + forwardedFor: "203.0.113.50", + localAddress: trustedProxyPeer, + }) + ).status, + ).toBe(429); + + // A *different* forwarded client from the same trusted proxy gets its own independent bucket. + expect( + ( + await rawSignInAttempt(proxyOrigin, { + forwardedFor: "203.0.113.51", + localAddress: trustedProxyPeer, + }) + ).status, + ).toBe(401); + }); + + it("ignores a forwarded address claimed by an untrusted peer, using that peer's own real address instead", async () => { + // 203.0.113.50 is already exhausted above when legitimately forwarded by the trusted proxy — + // an untrusted peer merely claiming to *be* that address must not inherit its exhausted bucket, + // and must not be able to choose an arbitrary bucket of its own by forging the header either. + const untrustedPeer = "127.0.0.25"; + const response = await rawSignInAttempt(proxyOrigin, { + forwardedFor: "203.0.113.50", + localAddress: untrustedPeer, + }); + expect(response.status).toBe(401); + }); + + it("walks a chained forwarded header right-to-left, skipping the trusted-proxy hop to find the genuine client", async () => { + const chained = `203.0.113.60, ${trustedProxyPeer}`; + expect( + ( + await rawSignInAttempt(proxyOrigin, { + forwardedFor: chained, + localAddress: trustedProxyPeer, + }) + ).status, + ).toBe(401); + expect( + ( + await rawSignInAttempt(proxyOrigin, { + forwardedFor: chained, + localAddress: trustedProxyPeer, + }) + ).status, + ).toBe(401); + expect( + ( + await rawSignInAttempt(proxyOrigin, { + forwardedFor: chained, + localAddress: trustedProxyPeer, + }) + ).status, + ).toBe(401); + expect( + ( + await rawSignInAttempt(proxyOrigin, { + forwardedFor: chained, + localAddress: trustedProxyPeer, + }) + ).status, + ).toBe(429); + }); + + it("fails safely with a sanitized 503 on a malformed forwarded chain from a trusted proxy, never falling back to a shared bucket", async () => { + const response = await rawSignInAttempt(proxyOrigin, { + forwardedFor: "not-an-ip", + localAddress: trustedProxyPeer, + }); + expect(response.status).toBe(503); + expect(JSON.parse(response.body)).toEqual({ code: "CLIENT_ADDRESS_UNRESOLVED" }); + expect(response.headers["cache-control"]).toBe("no-store"); + }); + + it("shuts down the trusted-proxy-mode API and drops the shared database", async () => { + await proxyModeApi.shutdown(); + await dropDatabase(database.name); + }); +}); + +describe.sequential("live API — Node header/request timeouts and client errors", () => { + /** + * Drives one raw, deliberately-malformed or deliberately-slow byte sequence through a real + * listening API and returns everything observable at the transport boundary, plus everything the + * *real* production logging seam (`console.error`, which `apps/api/src/server.ts` writes its + * sanitized operational line through) actually emitted while it happened. + * + * This is the only shape of proof that counts for the timeout-observability claim: no event is + * manually emitted, no logger is called directly, and nothing asserts merely that a listener was + * registered — a real socket is opened against a real server, real bytes are written, and the + * real Node transport is left to do whatever it genuinely does. + */ + async function driveRawConnection( + label: string, + write: (socket: net.Socket) => void, + timeouts: Partial = { + connectionsCheckingIntervalMs: 100, + headersTimeoutMs: 200, + keepAliveTimeoutMs: 200, + requestTimeoutMs: 300, + }, + ): Promise<{ destroyed: boolean; logs: string[]; received: string }> { + const database = await createDisposableDatabase(label); + await applyCoreMigrations(database.url); + const config = createApiRuntimeConfig( + { + databaseUrl: database.url, + host: "127.0.0.1", + identity: { + baseUrl: identityBaseUrl, + publicSignupEnabled: false, + secret: "integration-test-secret-0123456789abcdefgh", + trustedOrigins: [identityBaseUrl, dashboardOrigin], + }, + port: 0, + }, + // Development configuration, for the same reason `startConfiguredApi` documents: this suite + // connects to the disposable plaintext loopback PostgreSQL, which production configuration + // never permits. Nothing about the transport-level timeout behavior under test depends on it. + { environment: "development" }, + ); + // Deliberately tiny, test-only timeouts — production composition (server.ts's `main`) never + // sets this override — so a real timeout is observed deterministically in well under a second + // rather than by waiting out the production values. + const running = await startApi(config, { nodeServerTimeoutOverride: timeouts }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + try { + const address = running.server.address(); + if (!address || typeof address !== "object") { + throw harnessError("The API integration server has no bound address."); + } + + const socket = net.connect({ host: "127.0.0.1", port: address.port }); + await new Promise((resolve, reject) => { + socket.once("connect", () => resolve()); + socket.once("error", reject); + }); + const receivedChunks: Buffer[] = []; + // A `net.Socket` stays paused (and never surfaces the peer's eventual FIN as `close`) until + // something actively consumes its readable side — attaching this listener is what makes the + // socket start flowing, not merely a way to capture the response for the assertions below. + socket.on("data", (chunk) => receivedChunks.push(chunk as Buffer)); + socket.on("error", () => undefined); + const closed = new Promise((resolve) => socket.once("close", resolve)); + + write(socket); + await closed; + + const logs = errorSpy.mock.calls.flat().map((value) => String(value)); + return { + destroyed: socket.destroyed, + logs, + received: Buffer.concat(receivedChunks).toString("utf8"), + }; + } finally { + errorSpy.mockRestore(); + await running.shutdown().catch(() => undefined); + await dropDatabase(database.name); + } + } + + /** Every status line in a response stream — more than one would mean the platform corrupted the + * connection by writing a second response after Node (or a handler) already wrote one. */ + function statusLines(received: string): readonly string[] { + return received.match(/^HTTP\/1\.1 \d{3}[^\r\n]*/gmu) ?? []; + } + + /** The exact operational line `apps/api/src/server.ts` promises for a transport-level client + * failure — no request path, query, body, cookie, authorization value, client-address header, + * forwarding chain, raw peer address, stack, or parser payload may appear anywhere in the logs. */ + function expectSanitizedTransportLog(logs: readonly string[]): void { + expect(logs).toHaveLength(1); + const emitted = logs.join("\n"); + for (const forbidden of [ + "/api/v1/principal", + "/api/auth", + "127.0.0.1", + "Cookie", + "cookie", + "Authorization", + "authorization", + "x-forwarded-for", + "x-ekklesia-client-address", + "partial", + "Parse Error", + "HPE_", + "ERR_HTTP_REQUEST_TIMEOUT", + "at ", + ]) { + expect(emitted).not.toContain(forbidden); + } + } + + it("answers a never-completed header sequence with exactly one 408, closes the socket, invokes no handler, and emits exactly one sanitized operational log", async () => { + const { destroyed, logs, received } = await driveRawConnection( + "node_timeouts", + // An incomplete request: a request line and one header, deliberately never terminated by the + // blank line that would mark the end of the headers, and no further data ever sent. + (socket) => socket.write("GET /api/v1/principal HTTP/1.1\r\nHost: 127.0.0.1\r\n"), + ); + + expect(received).toBe("HTTP/1.1 408 Request Timeout\r\nConnection: close\r\n\r\n"); + expect(statusLines(received)).toHaveLength(1); + // A handler would have produced a JSON principal body; Node answered before routing. + expect(received).not.toContain("anonymous"); + expect(destroyed).toBe(true); + expectSanitizedTransportLog(logs); + }); + + it("answers a never-completed body with exactly one 408, closes the socket, invokes no auth logic, and emits exactly one sanitized operational log", async () => { + const { destroyed, logs, received } = await driveRawConnection( + "node_timeouts_body", + // Complete, well-formed headers declaring a body that never actually arrives. + (socket) => + socket.write( + "POST /api/auth/sign-in/email HTTP/1.1\r\n" + + "Host: 127.0.0.1\r\n" + + "Content-Type: application/json\r\n" + + "Content-Length: 1000\r\n" + + "\r\n" + + '{"partial":', + ), + { + connectionsCheckingIntervalMs: 100, + headersTimeoutMs: 300, + keepAliveTimeoutMs: 200, + requestTimeoutMs: 400, + }, + ); + + expect(received).toBe("HTTP/1.1 408 Request Timeout\r\nConnection: close\r\n\r\n"); + expect(statusLines(received)).toHaveLength(1); + expect(received).not.toContain("Set-Cookie"); + expect(destroyed).toBe(true); + expectSanitizedTransportLog(logs); + }); + + it("preserves Node's own 400 for a malformed request line, without logging the malformed bytes", async () => { + const { destroyed, logs, received } = await driveRawConnection("node_bad_request", (socket) => + socket.write("!!!!NOT-HTTP-AT-ALL!!!! /secret-path\r\n\r\n"), + ); + + expect(received).toBe("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"); + expect(statusLines(received)).toHaveLength(1); + expect(destroyed).toBe(true); + expectSanitizedTransportLog(logs); + // The attacker-controlled bytes must never reach the log. + expect(logs.join("\n")).not.toContain("NOT-HTTP-AT-ALL"); + expect(logs.join("\n")).not.toContain("secret-path"); + }); + + it("preserves Node's own 431 for an oversized header block, without logging the header contents", async () => { + const { destroyed, logs, received } = await driveRawConnection( + "node_header_overflow", + (socket) => + socket.write( + `GET /api/v1/principal HTTP/1.1\r\nHost: 127.0.0.1\r\nX-Overflow: ${"z".repeat(20_000)}\r\n\r\n`, + ), + ); + + expect(received).toBe( + "HTTP/1.1 431 Request Header Fields Too Large\r\nConnection: close\r\n\r\n", + ); + expect(statusLines(received)).toHaveLength(1); + expect(destroyed).toBe(true); + expectSanitizedTransportLog(logs); + expect(logs.join("\n")).not.toContain("zzz"); + }); +}); + +/** + * Pass 4: shutdown is finite even when HTTP work does not settle. + * + * These drive a real bound listener and a real socket rather than a mock, because the behavior + * under test is what Node's own `server.close()` does and does not do. Every socket, server, and + * disposable database is closed in `finally`, and each scenario carries its own outer deadline so + * a regression fails rather than hangs. + */ +describe.sequential("live API — bounded HTTP drain and shutdown", () => { + async function withDeadline(operation: Promise, timeoutMs: number): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + operation, + new Promise((_resolve, reject) => { + timer = setTimeout( + () => reject(harnessError("shutdown did not settle in time")), + timeoutMs, + ); + }), + ]); + } finally { + clearTimeout(timer); + } + } + + it("serves a real request and then drains cleanly, well inside the deadline", async () => { + const database = await createDisposableDatabase("drain_ok"); + await applyCoreMigrations(database.url); + const running = await startConfiguredApi(database.url, { drainTimeoutMsOverride: 5_000 }); + try { + const origin = apiOrigin(running); + const response = await fetch(`${origin}/api/v1/principal`); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ kind: "anonymous" }); + + // Nothing is stalled, so the graceful path completes and shutdown reports success — the + // drain deadline is never reached and no connection is force-closed. + await expect(withDeadline(running.shutdown(), 20_000)).resolves.toBeUndefined(); + + // The listener really is closed afterwards. + await expect(fetch(`${origin}/api/v1/principal`)).rejects.toBeTruthy(); + } finally { + await running.shutdown().catch(() => undefined); + await dropDatabase(database.name); + } + }); + + it("lets a handler that has already entered finish, and closes its transport before module stop", async () => { + // A real handler gate, not an inference from the connection count: the fixture module's route + // handler announces that it has *entered*, then blocks until this test releases it. Shutdown + // therefore begins at a point where the handler is provably inside user code, and the ordering + // claim below is an assertion about recorded events rather than about timing. + const modulesDirectory = await createFixtureModulesDirectory(); + const recorder = createEventRecorder(); + const gate = createHandlerGate(); + await createGatedRouteModule(modulesDirectory, "drainorder", recorder, gate); + + const database = await createDisposableDatabase("drain_order"); + await applyCoreMigrations(database.url); + const running = await startConfiguredApi(database.url, { + drainTimeoutMsOverride: 20_000, + modulesDirectoryOverride: modulesDirectory, + }); + const address = running.server.address(); + if (!address || typeof address !== "object") { + throw harnessError("The API integration server has no bound address."); + } + const socket = net.connect({ host: "127.0.0.1", port: address.port }); + let shutdown: Promise | undefined; + try { + const responseBytes: Buffer[] = []; + const responseClosed = new Promise((resolve) => { + socket.once("close", () => resolve()); + }); + socket.on("data", (chunk: Buffer) => { + responseBytes.push(chunk); + }); + await new Promise((resolve, reject) => { + socket.once("error", reject); + socket.write( + `GET /api/v1/modules/drainorder/gated HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n`, + () => resolve(), + ); + }); + + // Bounded wait on the handler's own signal — no sleep, no proxy for "entered". + await withDeadline(gate.entered, 20_000); + expect(recorder.events).toContain("drainorder:handler-entered"); + + // Shutdown starts while the handler is still inside user code. + shutdown = running.shutdown(); + // Release it, then require the graceful path to complete. + gate.release(); + await expect(withDeadline(shutdown, 30_000)).resolves.toBeUndefined(); + + // The response really was served, in full, rather than force-closed mid-flight. + await withDeadline(responseClosed, 10_000); + const raw = Buffer.concat(responseBytes).toString("utf8"); + expect(raw).toContain("HTTP/1.1 200"); + expect(raw).toContain('"gated":true'); + + // Explicit ordering: the handler finished and the module stopped, in that order. + const finished = recorder.events.indexOf("drainorder:handler-finished"); + const stopped = recorder.events.indexOf("drainorder:stop"); + expect(finished).toBeGreaterThanOrEqual(0); + expect(stopped).toBeGreaterThanOrEqual(0); + expect(finished).toBeLessThan(stopped); + } finally { + gate.release(); + if (shutdown) { + await shutdown.catch(() => undefined); + } + socket.destroy(); + await running.shutdown().catch(() => undefined); + await dropDatabase(database.name); + } + }); + + it("force-closes a stalled connection at the drain deadline and still reaches pool closure", async () => { + const database = await createDisposableDatabase("drain_forced"); + await applyCoreMigrations(database.url); + // A short, test-only drain deadline; production keeps HTTP_DRAIN_TIMEOUT_MS unchanged. + const running = await startConfiguredApi(database.url, { drainTimeoutMsOverride: 250 }); + const address = running.server.address(); + if (!address || typeof address !== "object") { + throw harnessError("The API integration server has no bound address."); + } + const socket = net.connect({ host: "127.0.0.1", port: address.port }); + try { + // A real, deliberately incomplete request: headers are never finished, so Node keeps the + // connection open and `server.close()` alone would wait indefinitely. + await new Promise((resolve, reject) => { + socket.once("error", reject); + socket.on("data", () => undefined); + socket.write("GET /api/v1/principal HTTP/1.1\r\nHost: 127.0.0.1\r\n", () => resolve()); + }); + + const closed = new Promise((resolve) => { + socket.once("close", () => resolve()); + }); + + let captured: unknown; + try { + await withDeadline(running.shutdown(), 20_000); + } catch (error) { + captured = error; + } + + // A forced drain is a genuine shutdown failure, never reported as success. + expect(captured).toMatchObject({ drainTimedOut: true, name: "ApiShutdownError" }); + // ...and it still carries nothing about the request, socket, or database. + const publicText = `${String(captured)}\n${(captured as Error).stack ?? ""}`; + expect(publicText).not.toContain("127.0.0.1"); + expect(publicText).not.toContain(database.name); + // The connection was actually closed by the platform, not left dangling. + await withDeadline(closed, 10_000); + } finally { + socket.destroy(); + await running.shutdown().catch(() => undefined); + await dropDatabase(database.name); + } + }); + + it("shares one memoized shutdown across concurrent callers", async () => { + const database = await createDisposableDatabase("drain_once"); + await applyCoreMigrations(database.url); + const running = await startConfiguredApi(database.url, { drainTimeoutMsOverride: 5_000 }); + try { + const [first, second, third] = await withDeadline( + Promise.all([running.shutdown(), running.shutdown(), running.shutdown()]), + 20_000, + ); + expect([first, second, third]).toEqual([undefined, undefined, undefined]); + // A later caller observes the same completed result rather than restarting cleanup. + await expect(running.shutdown()).resolves.toBeUndefined(); + } finally { + await dropDatabase(database.name); + } + }); +}); diff --git a/apps/api/tests/app.test.ts b/apps/api/tests/app.test.ts index 8daa650..7a5ea2a 100644 --- a/apps/api/tests/app.test.ts +++ b/apps/api/tests/app.test.ts @@ -14,6 +14,7 @@ import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import { IdentityRuntimeError } from "@ekklesia/core/identity"; import { describe, expect, it, vi } from "vitest"; 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 untrustedOrigin = "https://evil.example.test"; @@ -61,7 +62,7 @@ function fakeAuthorization(overrides: { grantPlatformOwner: vi.fn(), hasActiveGrant: vi.fn().mockResolvedValue(false), isPlatformOwner: vi.fn().mockResolvedValue(false), - listGrantsForUser: vi.fn().mockResolvedValue([]), + listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }), revokeGrant: vi.fn(), revokePlatformOwner: vi.fn(), ...overrides.store, @@ -207,6 +208,135 @@ describe("Ekklesia API Hono application", () => { 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({ + 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({ + 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 () => { const app = createApiApp({ moduleRoutes: Object.freeze([]), diff --git a/apps/api/tests/client-address.test.ts b/apps/api/tests/client-address.test.ts new file mode 100644 index 0000000..d6674aa --- /dev/null +++ b/apps/api/tests/client-address.test.ts @@ -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[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: "a@example.test" }), + 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: "a@example.test" })); + }); +}); diff --git a/apps/api/tests/config.test.ts b/apps/api/tests/config.test.ts index 91f8a08..49dc48b 100644 --- a/apps/api/tests/config.test.ts +++ b/apps/api/tests/config.test.ts @@ -21,10 +21,14 @@ describe("API runtime configuration", () => { const config = createApiRuntimeConfig(validInput(), { environment: "production" }); expect(config).toEqual({ + clientAddress: { mode: "direct" }, + // Production defaults to certificate-verified TLS with no configuration at all. + databaseTransport: { mode: "verify-full" }, databaseUrl: validInput().databaseUrl, host: "127.0.0.1", identity: { baseUrl: "https://identity.example.test", + publicSignupEnabled: false, secret: validSecret, trustedOrigins: ["https://identity.example.test"], }, @@ -131,6 +135,187 @@ describe("API runtime configuration", () => { 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:ekklesia@db.example.test: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:hunter2@db.internal.example: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", () => { const hostileUrl = "postgresql://admin:s3cr3t@db.internal.example:5432/ekklesia"; let captured: unknown; diff --git a/apps/api/tests/grants-routes.test.ts b/apps/api/tests/grants-routes.test.ts index 07012a5..5d685c5 100644 --- a/apps/api/tests/grants-routes.test.ts +++ b/apps/api/tests/grants-routes.test.ts @@ -1,6 +1,5 @@ import { createAuthenticatedPrincipal, - parseAuthorizationScope, parseEkklesiaUserId, parseGrantId, parsePermissionId, @@ -15,6 +14,7 @@ import { import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity"; import { Hono } from "hono"; 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"; const trustedOrigin = "https://dashboard.example.test"; @@ -49,7 +49,7 @@ function fakeStore(overrides: Partial = {}): AuthorizationSt grantPlatformOwner: vi.fn(), hasActiveGrant: vi.fn().mockResolvedValue(false), isPlatformOwner: vi.fn().mockResolvedValue(false), - listGrantsForUser: vi.fn().mockResolvedValue([]), + listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }), revokeGrant: vi.fn(), revokePlatformOwner: vi.fn(), ...overrides, @@ -58,6 +58,7 @@ function fakeStore(overrides: Partial = {}): AuthorizationSt function buildApp(options: { readonly allowed?: boolean; + readonly now?: () => Date; readonly runtime?: EkklesiaIdentityRuntime; readonly store?: AuthorizationStore; }) { @@ -71,6 +72,7 @@ function buildApp(options: { "/", createGrantsRoutes({ evaluator, + ...(options.now === undefined ? {} : { now: options.now }), registry: CORE_PERMISSION_REGISTRY, runtime, store, @@ -102,15 +104,20 @@ describe("GET /api/v1/grants", () => { userId: subjectUserId, }, ]; - const store = fakeStore({ listGrantsForUser: vi.fn().mockResolvedValue(grants) }); + const store = fakeStore({ listGrantsForUser: vi.fn().mockResolvedValue({ grants }) }); const { app } = buildApp({ store }); 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(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 () => { @@ -183,7 +190,6 @@ describe("POST /api/v1/grants", () => { createdAt: new Date("2026-01-01T00:00:00.000Z"), id: grantId, permission: registeredPermission, - scope: parseAuthorizationScope("calendar-event:123"), sourceKind: "explicit" as const, userId: subjectUserId, }; @@ -191,11 +197,7 @@ describe("POST /api/v1/grants", () => { const { app } = buildApp({ store }); const response = await app.request("/api/v1/grants", { - body: JSON.stringify({ - permission: registeredPermission, - scope: "calendar-event:123", - subjectUserId, - }), + body: JSON.stringify({ permission: registeredPermission, subjectUserId }), headers: { "content-type": "application/json", origin: trustedOrigin }, method: "POST", }); @@ -207,11 +209,14 @@ describe("POST /api/v1/grants", () => { expect.objectContaining({ actor: { type: "user", userId: manager.userId }, permission: registeredPermission, - scope: "calendar-event:123", sourceKind: "explicit", 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 () => { @@ -378,3 +383,499 @@ describe("POST /api/v1/grants/:grantId/revoke", () => { 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({ + 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({ + 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", + }); + }); +}); diff --git a/apps/api/tests/lifecycle.test.ts b/apps/api/tests/lifecycle.test.ts index 3628d25..23f4781 100644 --- a/apps/api/tests/lifecycle.test.ts +++ b/apps/api/tests/lifecycle.test.ts @@ -130,6 +130,9 @@ interface WorkingFixtureModuleOptions { readonly registerThrows?: boolean; readonly startThrows?: 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; } @@ -155,6 +158,7 @@ async function createWorkingFixtureModule( method: "GET", path: "/ping", permission: ${JSON.stringify(permissionId)}, + maxBodyBytes: 0, handler: async () => new Response("pong", { status: 200 }), });` : ""; @@ -173,6 +177,7 @@ async function createWorkingFixtureModule( stop() { ${recorder.expression}.push(${JSON.stringify(`${id}:stop`)}); ${options.stopThrows ? 'throw new Error("stop failed");' : ""} + ${options.stopNeverSettles ? "return new Promise(() => {});" : ""} }, };`; 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` * 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). */ +type ClientErrorHandler = (error: NodeJS.ErrnoException, socket: import("node:net").Socket) => void; + function fakeServer() { let errorHandler: ((error: Error) => void) | undefined; + let clientErrorHandler: ClientErrorHandler | undefined; const server = { address: () => ({ address: "127.0.0.1", family: "IPv4", port: 4_000 }), close: vi.fn((callback?: () => void) => { @@ -221,6 +229,12 @@ function fakeServer() { return server; }), 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) => { if (event === "error") { errorHandler = handler; @@ -229,6 +243,9 @@ function fakeServer() { }), }; 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, triggerError: (error: Error) => errorHandler?.(error), }; @@ -239,12 +256,12 @@ function fakeServer() { * resolving synchronously would reference `bindServer`'s own `server` const before it is * initialized. */ function mockSuccessfulServe() { - const { server, triggerError } = fakeServer(); + const { clientErrorHandler, server, triggerError } = fakeServer(); serveMock.mockImplementation((_options: unknown, listeningListener?: () => void) => { queueMicrotask(() => listeningListener?.()); return server; }); - return { server, triggerError }; + return { clientErrorHandler, server, triggerError }; } function mockFailingServe(error: Error) { @@ -409,6 +426,97 @@ describe("Ekklesia API shutdown lifecycle", () => { 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:ekklesia@127.0.0.1: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:ekklesia@db.example.test:5432/ekklesia", + }, + ], + [ + "connection-string TLS downgrade", + { + databaseUrl: + "postgresql://ekklesia:ekklesia@db.example.test: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 () => { const poolEnd = vi.fn().mockResolvedValue(undefined); 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; + write: ReturnType; + }; + } + + 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", () => { afterEach(async () => { await Promise.all( @@ -688,6 +989,7 @@ describe("Ekklesia API module runtime startup and shutdown", () => { method: "GET", path: "/ping", permission: "db-identity.ping.read", + maxBodyBytes: 0, handler: async (_request, context) => { ${refs.expression}.push(context.services.database); 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" })], }); + // 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([ "one:register", "two:register", "one:start", "two:start", + "two:stop", "one:stop", ]); expect(poolConstructor).toHaveBeenCalledTimes(1); @@ -879,8 +1184,14 @@ describe("Ekklesia API module runtime startup and shutdown", () => { 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({ - issues: [expect.objectContaining({ code: "MODULE_STOP_FAILED", moduleId: "stop-fails" })], + drainTimedOut: false, + moduleStopError: { + issues: [expect.objectContaining({ code: "MODULE_STOP_FAILED", moduleId: "stop-fails" })], + }, + moduleStopFailed: true, + name: "ApiShutdownError", }); expect(poolEnd).toHaveBeenCalledTimes(1); }); @@ -923,4 +1234,154 @@ describe("Ekklesia API module runtime startup and shutdown", () => { expect(betterAuthMock).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()); + } + }); + }); }); diff --git a/apps/api/tests/module-database.integration.ts b/apps/api/tests/module-database.integration.ts index 82112a7..6956817 100644 --- a/apps/api/tests/module-database.integration.ts +++ b/apps/api/tests/module-database.integration.ts @@ -219,6 +219,7 @@ async function applyCoreMigrations(url: string): Promise { expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([ "core:0000_identity_core_tables", "core:0001_authorization_core_tables", + "core:0002_authorization_integrity", ]); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { @@ -239,9 +240,11 @@ async function applyMigrationsWithModule(url: string): Promise { 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([ "core:0000_identity_core_tables", "core:0001_authorization_core_tables", + "core:0002_authorization_integrity", "db-facade:0000_initial", ]); expect(aggregate.migrations[0]?.checksum).toBe( @@ -250,6 +253,9 @@ async function applyMigrationsWithModule(url: string): Promise { expect(aggregate.migrations[1]?.checksum).toBe( "830e1fbc26d05af08df8d8710c3d10e1f3016d32c41b8363cb661c49afcbe81e", ); + expect(aggregate.migrations[2]?.checksum).toBe( + "63429473118065dfe3877a1baec61b8f61663b88127d9cb17707c859f27f6036", + ); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } @@ -263,12 +269,19 @@ async function startMigratedApi( host: "127.0.0.1", identity: { baseUrl: identityBaseUrl, + publicSignupEnabled: true, secret: "integration-test-secret-0123456789abcdefgh", trustedOrigins: [identityBaseUrl, dashboardOrigin], }, 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, { 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( 1, ); + expect(await migrationHistoryCount(database.url, "core:0002_authorization_integrity")).toBe( + 1, + ); } finally { await dropDatabase(database.name); } @@ -434,8 +450,15 @@ describe.sequential("live API — module database explicit migration ownership", await applyCoreMigrations(database.url); 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({ - 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. diff --git a/apps/api/tests/module-http-routes.test.ts b/apps/api/tests/module-http-routes.test.ts index d0fbdbb..73a5334 100644 --- a/apps/api/tests/module-http-routes.test.ts +++ b/apps/api/tests/module-http-routes.test.ts @@ -5,6 +5,7 @@ import { type ModuleRouteHandler, type ModuleRouteHandlerContext, type ModuleRuntimeServices, + moduleRoutesOverlap, parsePermissionId, type PermissionId, type RegisteredModuleHttpRoute, @@ -43,14 +44,20 @@ function fakeStore(): AuthorizationStore { grantPlatformOwner: vi.fn(), hasActiveGrant: vi.fn().mockResolvedValue(false), isPlatformOwner: vi.fn().mockResolvedValue(false), - listGrantsForUser: vi.fn().mockResolvedValue([]), + listGrantsForUser: vi.fn().mockResolvedValue({ grants: [] }), revokeGrant: vi.fn(), revokePlatformOwner: vi.fn(), }; } -function moduleRoute(fields: RegisteredModuleHttpRoute): RegisteredModuleHttpRoute { - return Object.freeze({ ...fields }); +/** Defaults `maxBodyBytes` to a generous value for tests that are not themselves exercising the + * 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 & { maxBodyBytes?: number }, +): RegisteredModuleHttpRoute { + return Object.freeze({ maxBodyBytes: 1024, ...fields }); } /** 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(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(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({ + 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(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(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(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({ + 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(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(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({ + 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(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", () => { it("does not invoke a handler for an unmatched module path", async () => { const handler = vi.fn(async () => Response.json({ ok: true })); @@ -1192,3 +1455,153 @@ describe("module routes and the rest of the API", () => { 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>> = []; + const handler = vi.fn(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(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(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(async () => Response.json({ handler: "byId" })); + const create = vi.fn(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); + }); +}); diff --git a/core/drizzle/0002_authorization_integrity.sql b/core/drizzle/0002_authorization_integrity.sql new file mode 100644 index 0000000..7e0b6bf --- /dev/null +++ b/core/drizzle/0002_authorization_integrity.sql @@ -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; diff --git a/core/drizzle/meta/0002_snapshot.json b/core/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..877ad89 --- /dev/null +++ b/core/drizzle/meta/0002_snapshot.json @@ -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": {} + } +} diff --git a/core/drizzle/meta/_ekklesia.json b/core/drizzle/meta/_ekklesia.json index 856273c..4663ed3 100644 --- a/core/drizzle/meta/_ekklesia.json +++ b/core/drizzle/meta/_ekklesia.json @@ -13,6 +13,12 @@ "destructive": { "mode": "none" } + }, + { + "tag": "0002_authorization_integrity", + "destructive": { + "mode": "none" + } } ] } diff --git a/core/drizzle/meta/_journal.json b/core/drizzle/meta/_journal.json index 3d8715f..c5f1080 100644 --- a/core/drizzle/meta/_journal.json +++ b/core/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1784819635300, "tag": "0001_authorization_core_tables", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1785104670124, + "tag": "0002_authorization_integrity", + "breakpoints": true } ] } diff --git a/core/src/authorization/bootstrap.ts b/core/src/authorization/bootstrap.ts index ab18850..073a7e4 100644 --- a/core/src/authorization/bootstrap.ts +++ b/core/src/authorization/bootstrap.ts @@ -2,7 +2,12 @@ import type { EkklesiaUserId } from "@ekklesia/contracts"; import { eq, sql } from "drizzle-orm"; import { ekklesiaCoreUser } from "../identity/schema.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 = | "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED" @@ -54,17 +59,25 @@ export interface BootstrapPlatformOwnerInput { /** * 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 * 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 * re-evaluates against the now-current state. * - * "Already consumed" is global and permanent: it is true the moment - * `ekklesia_core_platform_owner` has ever held any row for any user, including one whose - * `revoked_at` is set — a later, separate owner-management flow (not part of this slice) is how a - * platform owner would ever be re-established after a revocation, not this command. + * **Single-use authority lives in `ekklesia_core_platform_owner_bootstrap`, never in the continued + * existence of a platform-owner row.** The owner table's `user_id` cascades on user deletion, so + * treating "an owner row exists" as the consumed marker meant deleting the bootstrapped owner's + * 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( db: AuthorizationDrizzleDatabase, @@ -76,11 +89,11 @@ export async function bootstrapPlatformOwner( sql`select pg_advisory_xact_lock(${bootstrapLockNamespace}, ${bootstrapLockKey})`, ); - const existingOwnerRows = await tx - .select({ userId: ekklesiaCorePlatformOwner.userId }) - .from(ekklesiaCorePlatformOwner) + const consumedRows = await tx + .select({ id: ekklesiaCorePlatformOwnerBootstrap.id }) + .from(ekklesiaCorePlatformOwnerBootstrap) .limit(1); - if (existingOwnerRows.length > 0) { + if (consumedRows.length > 0) { 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"); } + // 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({ grantedByActorId: BOOTSTRAP_ACTOR_ID, grantedByActorType: BOOTSTRAP_ACTOR_TYPE, diff --git a/core/src/authorization/database.ts b/core/src/authorization/database.ts index 8050614..a0dbe1d 100644 --- a/core/src/authorization/database.ts +++ b/core/src/authorization/database.ts @@ -1,5 +1,9 @@ import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; import { Pool, type PoolConfig } from "pg"; +import { + type DatabaseTransportPolicy, + databaseTlsConnectionOptions, +} from "../database/transport.js"; import { authorizationSchema } from "./schema.js"; export type AuthorizationDrizzleDatabase = NodePgDatabase; @@ -27,6 +31,10 @@ export interface AuthorizationDatabaseOptions { * `core/src/identity/database.ts`: `connectionTimeoutMs` alone only bounds establishing a * connection, not a hung query already running on one. */ 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 { @@ -80,8 +88,13 @@ export function createAuthorizationDrizzleDatabase( const validatedConnectionString = validateConnectionString(connectionString); const validatedConnectionTimeoutMs = validateTimeout(options.connectionTimeoutMs); const validatedOperationTimeoutMs = validateTimeout(options.operationTimeoutMs); + const ssl = + options.transport === undefined + ? undefined + : databaseTlsConnectionOptions(validatedConnectionString, options.transport); const poolConfig: PoolConfig = { connectionString: validatedConnectionString, + ...(ssl === undefined ? {} : { ssl }), ...(validatedConnectionTimeoutMs === undefined ? {} : { connectionTimeoutMillis: validatedConnectionTimeoutMs }), diff --git a/core/src/authorization/schema.ts b/core/src/authorization/schema.ts index 87c05ed..ba90b42 100644 --- a/core/src/authorization/schema.ts +++ b/core/src/authorization/schema.ts @@ -72,11 +72,20 @@ export const ekklesiaCorePlatformOwner = pgTable( ); /** - * Append-only: no update or delete path is ever exposed by Core's authorization store. `scope` - * intentionally rejects an 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. + * Append-only *by application convention*: Core's authorization store exposes no update or delete + * path for these rows. That is a property of this codebase, not a database guarantee — no + * PostgreSQL role, trigger, or rule prevents a database administrator, a future migration, or + * anything else holding the same credentials from modifying them. `scope` intentionally rejects an + * 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( "ekklesia_core_authorization_audit", @@ -85,9 +94,7 @@ export const ekklesiaCoreAuthorizationAudit = pgTable( action: text("action").notNull(), actorType: text("actor_type").notNull(), actorId: text("actor_id"), - subjectUserId: text("subject_user_id") - .notNull() - .references(() => ekklesiaCoreUser.id, { onDelete: "cascade" }), + subjectUserId: text("subject_user_id").notNull(), permissionId: text("permission_id"), scope: text("scope"), 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 }) => ({ user: one(ekklesiaCoreUser, { fields: [ekklesiaCoreGrant.userId], @@ -127,19 +168,14 @@ export const ekklesiaCorePlatformOwnerRelations = relations( }), ); -export const ekklesiaCoreAuthorizationAuditRelations = relations( - ekklesiaCoreAuthorizationAudit, - ({ one }) => ({ - subject: one(ekklesiaCoreUser, { - fields: [ekklesiaCoreAuthorizationAudit.subjectUserId], - references: [ekklesiaCoreUser.id], - }), - }), -); +// `ekklesiaCoreAuthorizationAudit` deliberately declares no `relations(...)` to the user table: +// its subject is a retained historical identifier that may name a user row that no longer exists, +// so modelling it as a live relation would misdescribe the data. /** Explicit map passed to Drizzle, mirroring `identityAdapterSchema`'s explicit-mapping style. */ export const authorizationSchema = { authorizationAudit: ekklesiaCoreAuthorizationAudit, grant: ekklesiaCoreGrant, platformOwner: ekklesiaCorePlatformOwner, + platformOwnerBootstrap: ekklesiaCorePlatformOwnerBootstrap, } as const; diff --git a/core/src/authorization/store.ts b/core/src/authorization/store.ts index 0946539..8687db3 100644 --- a/core/src/authorization/store.ts +++ b/core/src/authorization/store.ts @@ -28,7 +28,10 @@ import { } from "./schema.js"; export type AuthorizationStoreErrorCode = + | "AUTHORIZATION_GRANT_ALREADY_EXISTS" | "AUTHORIZATION_GRANT_CREATE_FAILED" + | "AUTHORIZATION_GRANT_EXPIRATION_INVALID" + | "AUTHORIZATION_GRANT_LIMIT_INVALID" | "AUTHORIZATION_GRANT_LIST_FAILED" | "AUTHORIZATION_GRANT_LOOKUP_FAILED" | "AUTHORIZATION_GRANT_NOT_FOUND" @@ -38,7 +41,12 @@ export type AuthorizationStoreErrorCode = | "AUTHORIZATION_PLATFORM_OWNER_NOT_FOUND"; const storeErrorMessages: Readonly> = 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_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_LOOKUP_FAILED: "The authorization grant lookup could not be completed.", AUTHORIZATION_GRANT_NOT_FOUND: "The authorization grant was not found or is already revoked.", @@ -49,6 +57,10 @@ const storeErrorMessages: Readonly> "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. */ export class AuthorizationStoreError extends Error { readonly code: AuthorizationStoreErrorCode; @@ -101,11 +113,38 @@ export interface RevokeGrantInput { 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 { + /** 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. */ 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 * 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 { createGrant(input: CreateGrantInput): Promise; revokeGrant(input: RevokeGrantInput): Promise; - /** Every grant ever recorded for the user, active or revoked, newest first — the caller decides - * what to display. Never includes another user's grants. */ - listGrantsForUser( - userId: EkklesiaUserId, - options?: ListGrantsForUserOptions, - ): Promise; + /** One bounded page of the user's grants, active or revoked, ordered `created_at DESC, id DESC`. + * Never includes another user's grants. */ + listGrantsForUser(userId: EkklesiaUserId, options: ListGrantsForUserOptions): Promise; grantPlatformOwner(userId: EkklesiaUserId, actor: AuthorizationActor): Promise; revokePlatformOwner(userId: EkklesiaUserId, actor: AuthorizationActor): Promise; } @@ -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[0]>[0], + equivalence: GrantEquivalence, +): Promise { + 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({ createGrant: async (input: CreateGrantInput): Promise => { + // 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 { 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 .insert(ekklesiaCoreGrant) .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 => { try { await db.transaction(async (tx) => { - const [row] = await tx - .update(ekklesiaCoreGrant) - .set({ - revokedAt: sql`now()`, - revokedByActorId: actorId(input.actor), - revokedByActorType: input.actor.type, + // This first read exists only to discover the equivalence tuple, which is safe to read + // before the lock because those columns are immutable once written. It deliberately does + // *not* settle whether the revocation may proceed: everything it observed can change + // while this transaction waits for the lock below. + const [target] = await tx + .select({ + permissionId: ekklesiaCoreGrant.permissionId, + scope: ekklesiaCoreGrant.scope, + sourceKind: ekklesiaCoreGrant.sourceKind, + sourceRef: ekklesiaCoreGrant.sourceRef, + userId: ekklesiaCoreGrant.userId, }) + .from(ekklesiaCoreGrant) .where( and( eq(ekklesiaCoreGrant.id, input.grantId), @@ -221,20 +404,74 @@ export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): Auth : [eq(ekklesiaCoreGrant.sourceKind, input.sourceKind)]), ), ) - .returning(); - if (!row) { + .limit(1); + if (!target) { throw new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND"); } - await tx.insert(ekklesiaCoreAuthorizationAudit).values({ - action: "grant.revoke", - actorId: actorId(input.actor), - actorType: input.actor.type, - grantId: row.id, - permissionId: row.permissionId, - scope: row.scope, - subjectUserId: row.userId, - }); + 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", + actorId: actorId(input.actor), + actorType: input.actor.type, + grantId: row.id, + permissionId: row.permissionId, + scope: row.scope, + subjectUserId: row.userId, + })), + ); }); } catch (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 ( userId: EkklesiaUserId, - options: ListGrantsForUserOptions = {}, - ): Promise => { + listOptions: ListGrantsForUserOptions, + ): Promise => { + const limit = assertValidLimit(listOptions.limit); + const after = listOptions.after; try { const rows = await db .select() @@ -280,14 +528,31 @@ export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): Auth .where( and( 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)); - return Object.freeze(rows.map(toGrantRecord)); - } catch { + .orderBy(desc(ekklesiaCoreGrant.createdAt), desc(ekklesiaCoreGrant.id)) + .limit(limit + 1); + + 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"); } }, diff --git a/core/src/cli/apply-migrations.ts b/core/src/cli/apply-migrations.ts index f1b91cf..50501f5 100644 --- a/core/src/cli/apply-migrations.ts +++ b/core/src/cli/apply-migrations.ts @@ -1,11 +1,19 @@ import { formatMigrationApplyReport } from "./migration-report.js"; import { discoverRepositoryMigrations } from "./migration-command-support.js"; import { reportMigrationCommandError } from "./migration-command-errors.js"; +import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js"; import { applyModuleMigrations, createPostgresMigrationDatabase } from "../migrations/index.js"; async function main(): Promise { 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 }); for (const line of formatMigrationApplyReport(result)) { diff --git a/core/src/cli/bootstrap-platform-owner.ts b/core/src/cli/bootstrap-platform-owner.ts index 4be74c7..af35078 100644 --- a/core/src/cli/bootstrap-platform-owner.ts +++ b/core/src/cli/bootstrap-platform-owner.ts @@ -6,6 +6,7 @@ import { createAuthorizationDrizzleDatabase, PlatformOwnerBootstrapError, } from "../authorization/index.js"; +import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js"; class BootstrapCliError extends Error { constructor(message: string) { @@ -67,9 +68,13 @@ async function main(): Promise { 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, { connectionTimeoutMs: 10_000, operationTimeoutMs: 10_000, + transport, }); try { await bootstrapPlatformOwner(db, { userId: parsedUserId.data }); diff --git a/core/src/cli/database-transport-environment.ts b/core/src/cli/database-transport-environment.ts new file mode 100644 index 0000000..0940f9e --- /dev/null +++ b/core/src/cli/database-transport-environment.ts @@ -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; +} diff --git a/core/src/cli/doctor.ts b/core/src/cli/doctor.ts index 570703a..7dbf938 100644 --- a/core/src/cli/doctor.ts +++ b/core/src/cli/doctor.ts @@ -39,6 +39,7 @@ function requiredString(value: unknown): string { function readIdentityConfigurationFromEnvironment(): DoctorOptions["identityConfiguration"] { const secret = process.env.EKKLESIA_IDENTITY_SECRET; 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 trustedOrigins = trustedOriginsValue ? trustedOriginsValue @@ -50,11 +51,28 @@ function readIdentityConfigurationFromEnvironment(): DoctorOptions["identityConf return { ...(baseUrl ? { baseUrl } : {}), environment: process.env.NODE_ENV === "development" ? "development" : "production", + ...(publicSignupEnabled ? { publicSignupEnabled } : {}), ...(secret ? { secret } : {}), ...(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 { const [rootValue, moduleSdkValue] = await Promise.all([ readJson(path.join(repositoryRoot, "package.json")), @@ -63,7 +81,19 @@ export async function runDoctorCommand(repositoryRoot = process.cwd()): Promise< const rootPackage = rootPackageDocument(rootValue); const moduleSdkPackage = rootPackageDocument(moduleSdkValue); + const databaseEnvironment = + process.env.NODE_ENV === "development" ? ("development" as const) : ("production" as const); 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, identityConfiguration: readIdentityConfigurationFromEnvironment(), moduleDiscovery: { diff --git a/core/src/cli/migration-command-errors.ts b/core/src/cli/migration-command-errors.ts index acb31a1..479457c 100644 --- a/core/src/cli/migration-command-errors.ts +++ b/core/src/cli/migration-command-errors.ts @@ -1,7 +1,16 @@ +import { DatabaseTransportError } from "../database/transport.js"; import { MigrationRunnerError, PostgresMigrationDatabaseError } from "../migrations/index.js"; import { ModuleDiscoveryError, ModuleMigrationDiscoveryError } from "../modules/index.js"; 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) { console.error(error.message); for (const issue of error.issues) { diff --git a/core/src/cli/plan-migrations.ts b/core/src/cli/plan-migrations.ts index 2459f4f..a23f6d3 100644 --- a/core/src/cli/plan-migrations.ts +++ b/core/src/cli/plan-migrations.ts @@ -1,11 +1,19 @@ import { formatMigrationPlanReport } from "./migration-report.js"; import { discoverRepositoryMigrations } from "./migration-command-support.js"; import { reportMigrationCommandError } from "./migration-command-errors.js"; +import { resolveExecutableDatabaseTransport } from "./database-transport-environment.js"; import { createPostgresMigrationDatabase, planModuleMigrations } from "../migrations/index.js"; async function main(): Promise { 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 }); for (const line of formatMigrationPlanReport(plan)) { diff --git a/core/src/database/index.ts b/core/src/database/index.ts index 8636161..f98a1cd 100644 --- a/core/src/database/index.ts +++ b/core/src/database/index.ts @@ -1 +1,2 @@ export * from "./pool.js"; +export * from "./transport.js"; diff --git a/core/src/database/pool.ts b/core/src/database/pool.ts index 0273141..a09d3e4 100644 --- a/core/src/database/pool.ts +++ b/core/src/database/pool.ts @@ -1,4 +1,5 @@ import { Pool, type PoolConfig } from "pg"; +import { type DatabaseTransportPolicy, databaseTlsConnectionOptions } from "./transport.js"; 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 * establishing a connection, not a hung query already running on one. */ 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 { @@ -84,8 +90,16 @@ export function createValidatedPostgresPool( const validatedConnectionString = validateConnectionString(connectionString); const validatedConnectionTimeoutMs = validateTimeout(options.connectionTimeoutMs); 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 = { connectionString: validatedConnectionString, + ...(ssl === undefined ? {} : { ssl }), ...(validatedConnectionTimeoutMs === undefined ? {} : { connectionTimeoutMillis: validatedConnectionTimeoutMs }), diff --git a/core/src/database/transport.ts b/core/src/database/transport.ts new file mode 100644 index 0000000..b4b1b31 --- /dev/null +++ b/core/src/database/transport.ts @@ -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> = 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 `pg-connection-string@2.14.0` 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 }; diff --git a/core/src/diagnostics/doctor-report.ts b/core/src/diagnostics/doctor-report.ts index bd4003c..6b1e1aa 100644 --- a/core/src/diagnostics/doctor-report.ts +++ b/core/src/diagnostics/doctor-report.ts @@ -13,8 +13,8 @@ const sections: readonly DoctorSection[] = Object.freeze([ section("Platform", ["platform.runtime"]), section("Modules", ["modules.discovery", "modules.dependencies", "modules.permissions"]), section("Data definitions", ["schemas.discovery", "migrations.discovery"]), - section("PostgreSQL", ["postgresql.connection", "postgresql.plan"]), - section("Identity", ["identity.configuration"]), + section("PostgreSQL", ["database.transport", "postgresql.connection", "postgresql.plan"]), + section("Identity", ["identity.configuration", "identity.client-address"]), section("Object storage", ["object-storage.integration"]), ]); diff --git a/core/src/diagnostics/doctor.ts b/core/src/diagnostics/doctor.ts index 88934a5..40f7981 100644 --- a/core/src/diagnostics/doctor.ts +++ b/core/src/diagnostics/doctor.ts @@ -7,11 +7,25 @@ import { } from "@ekklesia/contracts"; import { getTableName } from "drizzle-orm"; 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 { type CreateIdentityConfigOptions, createIdentityConfig, IdentityConfigError, } from "../identity/config.js"; +import { TRUSTED_CLIENT_ADDRESS_HEADER } from "../identity/runtime.js"; import { identityAdapterSchema } from "../identity/schema.js"; import { createPostgresMigrationDatabase, @@ -35,6 +49,7 @@ import { } from "../modules/index.js"; export type DoctorCheckId = + | "database.transport" | "platform.runtime" | "modules.discovery" | "modules.dependencies" @@ -44,6 +59,7 @@ export type DoctorCheckId = | "postgresql.connection" | "postgresql.plan" | "identity.configuration" + | "identity.client-address" | "object-storage.integration"; export type DoctorCheckStatus = "failure" | "pass" | "skipped" | "warning"; @@ -64,11 +80,17 @@ export interface DoctorReport { export interface DoctorIdentityConfigurationOptions { readonly baseUrl?: string | undefined; readonly environment?: CreateIdentityConfigOptions["environment"]; + readonly publicSignupEnabled?: string | undefined; readonly secret?: string | undefined; readonly trustedOrigins?: readonly string[] | undefined; } 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 identityConfiguration?: DoctorIdentityConfigurationOptions | undefined; readonly moduleDiscovery: ModuleDiscoveryOptions; @@ -363,6 +385,7 @@ function identityConfigurationCheck( const hasAnyValue = input?.secret !== undefined || input?.baseUrl !== undefined || + input?.publicSignupEnabled !== undefined || (input?.trustedOrigins?.length ?? 0) > 0; if (!hasAnyValue) { @@ -373,9 +396,10 @@ function identityConfigurationCheck( } try { - createIdentityConfig( + const config = createIdentityConfig( { baseUrl: input?.baseUrl, + publicSignupEnabled: input?.publicSignupEnabled, secret: input?.secret, trustedOrigins: input?.trustedOrigins, }, @@ -383,6 +407,9 @@ function identityConfigurationCheck( ); return freezeCheck("identity.configuration", "pass", "Identity configuration valid", [ schemaDetail, + config.publicSignupEnabled + ? "Public signup is explicitly ENABLED." + : "Public signup is disabled (safe default).", ]); } catch (error) { 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] { return [ freezeCheck("postgresql.connection", "skipped", "Connection check skipped", [detail]), @@ -531,9 +628,16 @@ export async function diagnoseEkklesia( ); } else { 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, { connectionTimeoutMs: doctorPostgresTimeoutMs, operationTimeoutMs: doctorPostgresTimeoutMs, + transport: doctorTransport, }); const plan = await dependencies.planMigrations({ aggregate: migrations, database }); postgresConnectionCheck = freezeCheck( @@ -548,6 +652,12 @@ export async function diagnoseEkklesia( } 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", [ "Core storage integration has not been implemented yet.", @@ -560,9 +670,11 @@ export async function diagnoseEkklesia( permissionsCheck, schemaCheck, migrationCheck, + transportCheck, postgresConnectionCheck, postgresPlanCheck, identityCheck, + clientAddressDoctorCheck, storageCheck, ]); } diff --git a/core/src/identity/client-address.ts b/core/src/identity/client-address.ts new file mode 100644 index 0000000..b6649ce --- /dev/null +++ b/core/src/identity/client-address.ts @@ -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> = + 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" }; + }, + }; +} diff --git a/core/src/identity/config.ts b/core/src/identity/config.ts index 48485ef..352dc51 100644 --- a/core/src/identity/config.ts +++ b/core/src/identity/config.ts @@ -5,6 +5,8 @@ export type IdentityConfigErrorCode = | "BASE_URL_INVALID" | "BASE_URL_LOOPBACK_IN_PRODUCTION" | "BASE_URL_MISSING" + | "PUBLIC_SIGNUP_ENABLED_INVALID" + | "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION" | "SECRET_LOW_ENTROPY" | "SECRET_MISSING" | "SECRET_TOO_SHORT" @@ -16,12 +18,108 @@ export type IdentityConfigErrorCode = | "TRUSTED_ORIGINS_MISSING"; const MIN_SECRET_LENGTH = 32; -const LOOPBACK_HOSTNAMES: ReadonlySet = new Set([ - "localhost", - "127.0.0.1", - "::1", - "0.0.0.0", -]); + +/** + * Hostnames that are never a legitimate public production origin, matched exactly (after WHATWG + * URL canonicalization, which already folds `127.1`, `2130706433`, and `0x7f000001` into + * `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 = new Set(["0.0.0.0", "[::]"]); +const LITERAL_LOOPBACK_HOSTNAMES: ReadonlySet = 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> = Object.freeze({ BASE_URL_INSECURE_IN_PRODUCTION: "The identity base URL must use https in production.", @@ -29,6 +127,10 @@ const errorMessages: Readonly> = Object. BASE_URL_LOOPBACK_IN_PRODUCTION: "The identity base URL must not resolve to a loopback host in production.", 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_MISSING: "The identity secret is required.", 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 { 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 trustedOrigins: readonly string[]; } export interface IdentityConfigInput { readonly baseUrl?: unknown; + readonly publicSignupEnabled?: unknown; readonly secret?: unknown; readonly trustedOrigins?: unknown; } @@ -76,20 +186,38 @@ export interface CreateIdentityConfigOptions { readonly environment?: IdentityEnvironment; } -function isLoopbackHostname(hostname: string): boolean { - return LOOPBACK_HOSTNAMES.has(hostname.toLowerCase()); -} - -function parseAbsoluteHttpUrl(value: unknown): URL | undefined { +/** + * 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. + * + * 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) { return undefined; } + let url: URL; try { - const url = new URL(value); - return url.protocol === "http:" || url.protocol === "https:" ? url : undefined; + url = new URL(value); } catch { 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 { @@ -112,7 +240,11 @@ function validateBaseUrl(baseUrl: unknown, environment: IdentityEnvironment): st if (typeof baseUrl !== "string" || baseUrl.length === 0) { 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) { throw new IdentityConfigError("BASE_URL_INVALID"); } @@ -120,11 +252,13 @@ function validateBaseUrl(baseUrl: unknown, environment: IdentityEnvironment): st if (url.protocol !== "https:") { throw new IdentityConfigError("BASE_URL_INSECURE_IN_PRODUCTION"); } - if (isLoopbackHostname(url.hostname)) { + if (isLoopbackOrUnspecifiedHostname(url.hostname)) { 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 { @@ -138,19 +272,61 @@ function validateTrustedOrigin(origin: unknown, environment: IdentityEnvironment if (origin.includes("*") || origin.includes("?")) { throw new IdentityConfigError("TRUSTED_ORIGIN_WILDCARD_UNSUPPORTED"); } - const url = parseAbsoluteHttpUrl(origin); - if (!url || url.origin !== origin) { + const url = parseCanonicalHttpOrigin(origin); + if (!url) { throw new IdentityConfigError("TRUSTED_ORIGIN_INVALID"); } if (environment === "production") { if (url.protocol !== "https:") { 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"); } } - 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( @@ -179,6 +355,7 @@ export function createIdentityConfig( const secret = validateSecret(input.secret); const baseUrl = validateBaseUrl(input.baseUrl, 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 }); } diff --git a/core/src/identity/database.ts b/core/src/identity/database.ts index ff68722..cf1c28d 100644 --- a/core/src/identity/database.ts +++ b/core/src/identity/database.ts @@ -1,5 +1,9 @@ import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; import { Pool, type PoolConfig } from "pg"; +import { + type DatabaseTransportPolicy, + databaseTlsConnectionOptions, +} from "../database/transport.js"; import { identityAdapterSchema } from "./schema.js"; export type IdentityDrizzleDatabase = NodePgDatabase; @@ -28,6 +32,10 @@ export interface IdentityDatabaseOptions { * indefinitely. `connectionTimeoutMs` alone only bounds establishing a connection — it does * nothing once a query is already running on one. */ 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 { @@ -81,8 +89,13 @@ export function createIdentityDrizzleDatabase( const validatedConnectionString = validateConnectionString(connectionString); const validatedConnectionTimeoutMs = validateTimeout(options.connectionTimeoutMs); const validatedOperationTimeoutMs = validateTimeout(options.operationTimeoutMs); + const ssl = + options.transport === undefined + ? undefined + : databaseTlsConnectionOptions(validatedConnectionString, options.transport); const poolConfig: PoolConfig = { connectionString: validatedConnectionString, + ...(ssl === undefined ? {} : { ssl }), ...(validatedConnectionTimeoutMs === undefined ? {} : { connectionTimeoutMillis: validatedConnectionTimeoutMs }), diff --git a/core/src/identity/index.ts b/core/src/identity/index.ts index 843a30d..b943617 100644 --- a/core/src/identity/index.ts +++ b/core/src/identity/index.ts @@ -1,3 +1,4 @@ +export * from "./client-address.js"; export * from "./config.js"; export * from "./database.js"; export * from "./runtime.js"; diff --git a/core/src/identity/runtime.ts b/core/src/identity/runtime.ts index 723b2df..5f49834 100644 --- a/core/src/identity/runtime.ts +++ b/core/src/identity/runtime.ts @@ -3,12 +3,13 @@ import { createAuthenticatedPrincipal, type EkklesiaPrincipal, } from "@ekklesia/contracts"; -import { betterAuth } from "better-auth"; +import { betterAuth, type BetterAuthOptions } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { getCookies } from "better-auth/cookies"; import { sql } from "drizzle-orm"; import type { IdentityConfig } from "./config.js"; import type { IdentityDrizzleDatabase } from "./database.js"; -import { identityAdapterSchema } from "./schema.js"; +import { ekklesiaCoreSession, identityAdapterSchema } from "./schema.js"; export type IdentityRuntimeErrorCode = | "AUTH_REQUEST_FAILED" @@ -23,6 +24,18 @@ const runtimeErrorMessages: Readonly> = 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 * 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(operation: Promise, timeoutMs: number): Promise } } -async function probeIdentityDatabase(db: IdentityDrizzleDatabase): Promise { +/** + * 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 { 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; } 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; } } @@ -135,76 +213,109 @@ export function createIdentityRuntime( ): EkklesiaIdentityRuntime { const rateLimitOverride = options.rateLimitOverride; - const auth = constructBetterAuth(() => - betterAuth({ - advanced: { - // 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 - // `disableOriginCheck` is explicitly set — and origin skip in turn also silently skips - // CSRF validation as long as `disableCSRFCheck` is left unset (Better Auth's documented - // backward-compatibility coupling). Left implicit, this means origin/CSRF protection is - // silently *off* under the test runner and *on* in production: the exact same code path - // behaves differently in the two places it matters most to verify. Setting both flags to - // `false` explicitly removes the `isTest()` branch entirely, so the identical protection - // is enforced in every environment, including under Vitest. - disableCSRFCheck: false, - disableOriginCheck: false, + // One options object feeds both `betterAuth()` and `getCookies()` below, so the session-cookie + // 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: { + // 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 + // `disableOriginCheck` is explicitly set — and origin skip in turn also silently skips + // CSRF validation as long as `disableCSRFCheck` is left unset (Better Auth's documented + // backward-compatibility coupling). Left implicit, this means origin/CSRF protection is + // silently *off* under the test runner and *on* in production: the exact same code path + // behaves differently in the two places it matters most to verify. Setting both flags to + // `false` explicitly removes the `isTest()` branch entirely, so the identical protection + // is enforced in every environment, including under Vitest. + disableCSRFCheck: 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, - database: drizzleAdapter(options.db, { - provider: "pg", - schema: identityAdapterSchema, - }), - emailAndPassword: { - autoSignIn: false, - disableSignUp: false, - enabled: true, - // 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 - // upgrade, and Ekklesia's own tests assert this exact 8–128 boundary. - maxPasswordLength: 128, - minPasswordLength: 8, - }, - logger: { - disabled: false, - level: "error", - log: (level) => sanitizedBetterAuthLogger(level), - }, - // A second, independent layer alongside `logger` above: the pinned release's own transport - // dependency (`better-call`'s router) has an unconditional `console.error("# SERVER_ERROR: - // ", error)` fallback for any thrown value it does not recognize as one of Better Auth's own - // `APIError`s — this fires *before* Better Auth's `logger` option is ever consulted, so no - // logger configuration can reach it. `onAPIError.throw: true` closes that gap: Better Auth's - // own `APIError`s (wrong password, invalid origin, disabled sign-up, ...) still resolve to - // their normal HTTP response completely unaffected, but a genuinely unexpected failure (for - // example a database outage) now propagates out of `auth.handler()` instead of being caught - // and printed raw internally, so it reaches `handleAuthRequest`'s own try/catch below and - // becomes a sanitized `IdentityRuntimeError` like any other unexpected failure. - onAPIError: { - throw: true, - }, - rateLimit: { - enabled: true, - max: 100, - window: 10, - ...(rateLimitOverride - ? { - customRules: { - "/sign-in/email": { - max: rateLimitOverride.max ?? 100, - window: rateLimitOverride.window ?? 10, - }, - "/sign-up/email": { - max: rateLimitOverride.max ?? 100, - window: rateLimitOverride.window ?? 10, - }, - }, - } - : {}), - }, - secret: options.config.secret, - trustedOrigins: [...options.config.trustedOrigins], + }, + baseURL: options.config.baseUrl, + database: drizzleAdapter(options.db, { + provider: "pg", + schema: identityAdapterSchema, }), + emailAndPassword: { + autoSignIn: 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, + // 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 + // upgrade, and Ekklesia's own tests assert this exact 8–128 boundary. + maxPasswordLength: 128, + minPasswordLength: 8, + }, + logger: { + disabled: false, + level: "error", + log: (level) => sanitizedBetterAuthLogger(level), + }, + // A second, independent layer alongside `logger` above: the pinned release's own transport + // dependency (`better-call`'s router) has an unconditional `console.error("# SERVER_ERROR: + // ", error)` fallback for any thrown value it does not recognize as one of Better Auth's own + // `APIError`s — this fires *before* Better Auth's `logger` option is ever consulted, so no + // logger configuration can reach it. `onAPIError.throw: true` closes that gap: Better Auth's + // own `APIError`s (wrong password, invalid origin, disabled sign-up, ...) still resolve to + // their normal HTTP response completely unaffected, but a genuinely unexpected failure (for + // example a database outage) now propagates out of `auth.handler()` instead of being caught + // and printed raw internally, so it reaches `handleAuthRequest`'s own try/catch below and + // becomes a sanitized `IdentityRuntimeError` like any other unexpected failure. + onAPIError: { + throw: true, + }, + rateLimit: { + enabled: true, + max: 100, + window: 10, + ...(rateLimitOverride + ? { + customRules: { + "/sign-in/email": { + max: rateLimitOverride.max ?? 100, + window: rateLimitOverride.window ?? 10, + }, + "/sign-up/email": { + max: rateLimitOverride.max ?? 100, + window: rateLimitOverride.window ?? 10, + }, + }, + } + : {}), + }, + secret: options.config.secret, + 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({ @@ -230,14 +341,16 @@ export function createIdentityRuntime( return createAuthenticatedPrincipal(userId); } - // Better Auth's getSession swallows infrastructure failures internally and returns null - // for them exactly like a genuinely missing, expired, or malformed session — it never - // throws. A request with no Cookie header at all is unambiguous: Better Auth never queries - // the database for that case, so it is always genuinely anonymous. Only when a cookie was - // actually presented is the null result ambiguous enough to require disambiguation. - if (headers.has("cookie")) { - const reachable = await probeIdentityDatabase(options.db); - if (!reachable) { + // Better Auth's getSession swallows infrastructure failures internally and returns null for + // them exactly like a genuinely missing, expired, revoked, or malformed session — it never + // throws. Only a request that actually presented *the configured session cookie* is + // ambiguous enough to be worth disambiguating: with no cookie at all, or with only unrelated + // cookies, Better Auth never resolved an Ekklesia session in the first place, so `null` is + // unambiguous and no session-store work is performed. This is what keeps an arbitrary + // anonymous cookie from causing identity database work on every request. + if (hasConfiguredSessionCookie(headers, sessionCookieName)) { + const sessionStoreUsable = await probeSessionStore(options.db); + if (!sessionStoreUsable) { throw new IdentityRuntimeError("PRINCIPAL_RESOLUTION_FAILED"); } } diff --git a/core/src/migrations/postgres-database.ts b/core/src/migrations/postgres-database.ts index f730831..7e038da 100644 --- a/core/src/migrations/postgres-database.ts +++ b/core/src/migrations/postgres-database.ts @@ -1,4 +1,8 @@ import { Client, type ClientConfig, type QueryResultRow } from "pg"; +import { + type DatabaseTransportPolicy, + databaseTlsConnectionOptions, +} from "../database/transport.js"; import type { AppliedMigrationRecord, 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 `pg@8.22.0`/`pg-connection-string@2.14.0` 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 { + /** Omitted means `MIGRATION_CONNECTION_TIMEOUT_MS`. Tests may narrow it; nothing disables it. */ readonly connectionTimeoutMs?: number; + /** Omitted means `MIGRATION_OPERATION_TIMEOUT_MS`. Tests may narrow it; nothing disables it. */ 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 { @@ -169,26 +267,31 @@ function createClientConfig( throw invalidConfiguration("The PostgreSQL migration database options are invalid."); } - const validatedConnectionTimeoutMs = validateTimeout( - connectionTimeoutMs, - "The PostgreSQL connection timeout is invalid.", - ); - const validatedOperationTimeoutMs = validateTimeout( - operationTimeoutMs, - "The PostgreSQL operation timeout is invalid.", - ); + // Defaults are applied here, at the shared construction boundary, so every migration command + // inherits a finite deadline without having to remember to ask for one. + const validatedConnectionTimeoutMs = + validateTimeout(connectionTimeoutMs, "The PostgreSQL connection timeout is invalid.") ?? + MIGRATION_CONNECTION_TIMEOUT_MS; + const validatedOperationTimeoutMs = + 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 { connectionString, - ...(validatedConnectionTimeoutMs === undefined - ? {} - : { connectionTimeoutMillis: validatedConnectionTimeoutMs }), - ...(validatedOperationTimeoutMs === undefined - ? {} - : { - query_timeout: validatedOperationTimeoutMs, - statement_timeout: validatedOperationTimeoutMs, - }), + connectionTimeoutMillis: validatedConnectionTimeoutMs, + query_timeout: validatedOperationTimeoutMs, + ...(ssl === undefined ? {} : { ssl }), + statement_timeout: validatedOperationTimeoutMs, }; } @@ -386,7 +489,7 @@ class PostgresMigrationDatabaseSession implements MigrationDatabaseSession { const transaction = Object.freeze({ ensureHistoryTable: async () => this.#ensureHistoryTable(), - executeStatement: async (sql) => this.#executeStatement(sql), + executeMigrationSql: async (sql) => this.#executeMigrationSql(sql), insertAppliedMigration: async (migration) => this.#insertAppliedMigration(migration), }); @@ -463,15 +566,17 @@ class PostgresMigrationDatabaseSession implements MigrationDatabaseSession { } } - async #executeStatement(sql: string): Promise { + /** + * 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 { this.#assertTransactionActive("POSTGRES_MIGRATION_EXECUTION_FAILED"); try { await this.#client.query(sql); } catch { - throw databaseError( - "POSTGRES_MIGRATION_EXECUTION_FAILED", - "A module migration statement failed.", - ); + throw databaseError("POSTGRES_MIGRATION_EXECUTION_FAILED", "A module migration failed."); } } diff --git a/core/src/migrations/runner.ts b/core/src/migrations/runner.ts index 34ddff4..690cef3 100644 --- a/core/src/migrations/runner.ts +++ b/core/src/migrations/runner.ts @@ -4,7 +4,6 @@ import type { } from "../modules/migration-discovery.js"; const DEFAULT_MIGRATION_LOCK_TIMEOUT_MS = 10_000; -const DRIZZLE_STATEMENT_BREAKPOINT = "--> statement-breakpoint"; export type MigrationRunnerErrorCode = | "MIGRATION_APPROVAL_LOOKUP_FAILED" @@ -40,7 +39,12 @@ export interface MigrationHistoryInsert { export interface MigrationTransaction { ensureHistoryTable(): Promise; - executeStatement(sql: string): Promise; + /** + * 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; insertAppliedMigration(migration: MigrationHistoryInsert): Promise; } @@ -484,9 +488,14 @@ export async function applyModuleMigrations( await transaction.ensureHistoryTable(); for (const migration of pending) { executingIdentity = migration.identity; - for (const statement of migration.sql.split(DRIZZLE_STATEMENT_BREAKPOINT)) { - await transaction.executeStatement(statement); - } + // PostgreSQL parses the batch, not this runner. Drizzle's `--> statement-breakpoint` + // 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)); } }); diff --git a/core/src/modules/runtime.ts b/core/src/modules/runtime.ts index 60a63fd..168a192 100644 --- a/core/src/modules/runtime.ts +++ b/core/src/modules/runtime.ts @@ -12,6 +12,7 @@ import { type ModuleRuntimeDefinition, type ModuleRuntimeServices, moduleRouteShapeKey, + moduleRoutesOverlap, parseModuleRuntimeDefinition, type RegisteredCapability, type RegisteredModuleHttpRoute, @@ -33,9 +34,12 @@ export type ModuleRuntimeIssueCode = | "MODULE_CAPABILITY_UNREGISTERED" | "MODULE_REGISTER_FAILED" | "MODULE_ROLLBACK_FAILED" + | "MODULE_ROLLBACK_TIMEOUT" | "MODULE_ROUTE_DEFINITION_INVALID" | "MODULE_ROUTE_DUPLICATE" + | "MODULE_ROUTE_MAX_BODY_BYTES_INVALID" | "MODULE_ROUTE_METHOD_INVALID" + | "MODULE_ROUTE_OVERLAP" | "MODULE_ROUTE_PATH_INVALID" | "MODULE_ROUTE_PERMISSION_INVALID" | "MODULE_ROUTE_PERMISSION_UNDECLARED" @@ -44,7 +48,8 @@ export type ModuleRuntimeIssueCode = | "MODULE_RUNTIME_IMPORT_FAILED" | "MODULE_RUNTIME_VERSION_MISMATCH" | "MODULE_START_FAILED" - | "MODULE_STOP_FAILED"; + | "MODULE_STOP_FAILED" + | "MODULE_STOP_TIMEOUT"; export interface ModuleRuntimeIssue { readonly code: ModuleRuntimeIssueCode; @@ -71,7 +76,16 @@ export interface StartedModuleRuntime { stop(): Promise; } -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 * `apps/api` mounts routes) HTTP handlers receive for this runtime — a required production * 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); local.push( Object.freeze({ handler: parsed.data.handler, + maxBodyBytes: parsed.data.maxBodyBytes, method: parsed.data.method, moduleId: module.manifest.id, 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, + timeoutMs: number, +): Promise<"completed" | "timed-out"> { + let timer: ReturnType | 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( modules: readonly LoadedModule[], registry: StagedCapabilityRegistry, phase: "rollback" | "stop", services: ModuleRuntimeServices, + options: StopModulesOptions = {}, ): Promise { + const timeoutMs = options.hookTimeoutMs ?? MODULE_STOP_HOOK_TIMEOUT_MS; const issues: ModuleRuntimeIssue[] = []; 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 { - await module.definition.stop?.( - registry.createRuntimeContext(module.discovered, phase, services), + const outcome = await awaitHookWithinDeadline( + 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) { if (error instanceof ModuleRuntimeSignal) { issues.push(error.issue); @@ -489,8 +595,8 @@ async function stopModules( runtimeIssue( phase === "rollback" ? "MODULE_ROLLBACK_FAILED" : "MODULE_STOP_FAILED", phase, - module.discovered.manifest.id, - `Module ${module.discovered.manifest.id} ${phase} failed.`, + moduleId, + `Module ${moduleId} ${phase} failed.`, ), ); } @@ -503,14 +609,21 @@ async function startModules( modules: readonly LoadedModule[], registry: StagedCapabilityRegistry, services: ModuleRuntimeServices, + options: StopModulesOptions = {}, ): Promise { - 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) { + rollbackCandidates.push(module); try { await module.definition.start?.( registry.createRuntimeContext(module.discovered, "start", services), ); - started.push(module); } catch (error) { const startIssue = error instanceof ModuleRuntimeSignal @@ -521,7 +634,13 @@ async function startModules( module.discovered.manifest.id, `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]); } } @@ -538,12 +657,13 @@ async function startModules( export async function startDiscoveredModuleRuntime( discoveredModules: readonly DiscoveredModule[], services: ModuleRuntimeServices, + timeouts: ModuleRuntimeTimeoutOptions = {}, ): Promise { const modules = await loadRuntimeDefinitions(discoveredModules); const capabilityRegistry = new StagedCapabilityRegistry(); const httpRoutes = new StagedModuleHttpRoutes(); await registerModules(modules, capabilityRegistry, httpRoutes); - await startModules(modules, capabilityRegistry, services); + await startModules(modules, capabilityRegistry, services, timeouts); const capabilities = capabilityRegistry.publish(); const routes = httpRoutes.publish(); @@ -563,7 +683,7 @@ export async function startDiscoveredModuleRuntime( routes, stop: (): Promise => { stopPromise ??= (async () => { - const issues = await stopModules(modules, capabilityRegistry, "stop", services); + const issues = await stopModules(modules, capabilityRegistry, "stop", services, timeouts); if (issues.length > 0) { throw new ModuleRuntimeError(issues); } @@ -577,5 +697,7 @@ export async function startModuleRuntime( options: StartModuleRuntimeOptions, ): Promise { const discovered = await discoverModules(options); - return startDiscoveredModuleRuntime(discovered, options.services); + return startDiscoveredModuleRuntime(discovered, options.services, { + ...(options.hookTimeoutMs === undefined ? {} : { hookTimeoutMs: options.hookTimeoutMs }), + }); } diff --git a/core/tests/authorization-bootstrap.integration.ts b/core/tests/authorization-bootstrap.integration.ts index 246cb94..745cb75 100644 --- a/core/tests/authorization-bootstrap.integration.ts +++ b/core/tests/authorization-bootstrap.integration.ts @@ -3,12 +3,13 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { parseEkklesiaUserId } from "@ekklesia/contracts"; +import { createUserActor, parseEkklesiaUserId, parsePermissionId } from "@ekklesia/contracts"; import { Client, type QueryResultRow } from "pg"; import { afterAll, describe, expect, it } from "vitest"; import { bootstrapPlatformOwner, createAuthorizationDrizzleDatabase, + createAuthorizationStore, PlatformOwnerBootstrapError, } from "../src/authorization/index.js"; import { @@ -255,7 +256,7 @@ describe.sequential("live platform-owner bootstrap", () => { 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( admin, `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 { + 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, "durable-owner@example.test"); + await seedUser(admin, laterId, "durable-later@example.test"); + + 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, "audit-subject@example.test"); + await seedUser(admin, managerId, "audit-manager@example.test"); + + 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, "failed-claim@example.test"); + + // 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 { + 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, "never-owned@example.test"); + + 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); + } + }); +}); diff --git a/core/tests/authorization-store.integration.ts b/core/tests/authorization-store.integration.ts index 5e8ced7..ce28307 100644 --- a/core/tests/authorization-store.integration.ts +++ b/core/tests/authorization-store.integration.ts @@ -9,6 +9,7 @@ import { type GrantId, parseAuthorizationScope, parseEkklesiaUserId, + parseGrantId, parsePermissionId, SYSTEM_ACTOR, } from "@ekklesia/contracts"; @@ -171,6 +172,7 @@ async function applyCoreMigrations(url: string): Promise { expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([ "core:0000_identity_core_tables", "core:0001_authorization_core_tables", + "core:0002_authorization_integrity", ]); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { @@ -222,6 +224,7 @@ describe.sequential("live Core authorization store", () => { "ekklesia_core_grant", "ekklesia_core_migrations", "ekklesia_core_platform_owner", + "ekklesia_core_platform_owner_bootstrap", "ekklesia_core_session", "ekklesia_core_user", "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 () => { - const expiringPermission = parsePermissionId("calendar.events.export"); - const past = new Date(Date.now() - 1_000); + it("refuses to create a grant that is already expired on arrival", async () => { + // The store no longer accepts a past expiration at all: such a grant is dead the moment it is + // written, which is never what a caller meant. This is validated before any transaction opens. + let captured: unknown; + try { + await store.createGrant({ + actor, + 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: past, + expiresAt, permission: expiringPermission, sourceKind: "explicit", userId: userOneId, }); - const active = await store.hasActiveGrant({ - now: new Date(), - permission: expiringPermission, - userId: userOneId, - }); - expect(active).toBe(false); + // Deterministic rather than sleeping: the read port evaluates expiration against the instant + // 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, + userId: userOneId, + }), + ).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 () => { @@ -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; + let close: () => Promise; + + 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 { + 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, "equiv-subject@example.test"); + await seedUser(admin, other, "equiv-other@example.test"); + 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; + let close: () => Promise; + + 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, "page-subject@example.test"); + 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 { + 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(operation: Promise, timeoutMs: number): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + operation, + new Promise((_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 | undefined; + let lockHeld = false; + + try { + const subject = parseEkklesiaUserId("stale_subject"); + const manager = parseEkklesiaUserId("stale_manager"); + await seedUser(admin, subject, "stale-subject@example.test"); + await seedUser(admin, manager, "stale-manager@example.test"); + 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); + } + }); +}); diff --git a/core/tests/authorization-store.test.ts b/core/tests/authorization-store.test.ts index 865067d..a9f3f2b 100644 --- a/core/tests/authorization-store.test.ts +++ b/core/tests/authorization-store.test.ts @@ -4,8 +4,13 @@ const poolConstructor = vi.hoisted(() => vi.fn()); vi.mock("pg", () => ({ Pool: poolConstructor })); +import { parseEkklesiaUserId, parsePermissionId, SYSTEM_ACTOR } from "@ekklesia/contracts"; 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", () => { 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", () => { it.each([ + "AUTHORIZATION_GRANT_ALREADY_EXISTS", "AUTHORIZATION_GRANT_CREATE_FAILED", + "AUTHORIZATION_GRANT_EXPIRATION_INVALID", + "AUTHORIZATION_GRANT_LIMIT_INVALID", + "AUTHORIZATION_GRANT_LIST_FAILED", "AUTHORIZATION_GRANT_LOOKUP_FAILED", "AUTHORIZATION_GRANT_NOT_FOUND", "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); + }); +}); diff --git a/core/tests/database-tls.integration.ts b/core/tests/database-tls.integration.ts new file mode 100644 index 0000000..dac061e --- /dev/null +++ b/core/tests/database-tls.integration.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, +): 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 { + 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. + } + } + }); +}); diff --git a/core/tests/database-transport.test.ts b/core/tests/database-transport.test.ts new file mode 100644 index 0000000..571e709 --- /dev/null +++ b/core/tests/database-transport.test.ts @@ -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:secret@db.example.test: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:hunter2@db.example.test: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).", + ); + }); +}); diff --git a/core/tests/doctor-report.test.ts b/core/tests/doctor-report.test.ts index 7a11f49..ffdda2a 100644 --- a/core/tests/doctor-report.test.ts +++ b/core/tests/doctor-report.test.ts @@ -87,6 +87,8 @@ Data definitions 0 migrations · checksums valid PostgreSQL + ✓ Database TLS verified + TLS required with certificate and hostname verification. ○ Connection check skipped DATABASE_URL is not configured. ○ Migration plan skipped @@ -96,6 +98,8 @@ Identity ○ Identity configuration skipped 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. + ✓ Direct client-address mode + Trusts only the actual immediate socket peer for rate limiting; no forwarded header is trusted. Object storage ○ Check unavailable diff --git a/core/tests/doctor.test.ts b/core/tests/doctor.test.ts index 1a76ac5..625de96 100644 --- a/core/tests/doctor.test.ts +++ b/core/tests/doctor.test.ts @@ -206,9 +206,11 @@ describe("Ekklesia Doctor diagnostics", () => { "modules.permissions", "schemas.discovery", "migrations.discovery", + "database.transport", "postgresql.connection", "postgresql.plan", "identity.configuration", + "identity.client-address", "object-storage.integration", ]); 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 }), ); + // 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( "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(check(report, "postgresql.connection")).toMatchObject({ @@ -613,6 +621,212 @@ describe("Ekklesia Doctor diagnostics", () => { 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:hunter2@db.internal.example: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 () => { const services = dependencies(); await diagnoseEkklesia( diff --git a/core/tests/fixtures/module-database-integration/db-facade/server/index.js b/core/tests/fixtures/module-database-integration/db-facade/server/index.js index bf04f57..212061d 100644 --- a/core/tests/fixtures/module-database-integration/db-facade/server/index.js +++ b/core/tests/fixtures/module-database-integration/db-facade/server/index.js @@ -37,6 +37,7 @@ export default { method: "GET", path: "/records/:id", permission: "db-facade.records.read", + maxBodyBytes: 0, async handler(_request, context) { recordEvent({ contextKeys: Object.keys(context).sort(), @@ -62,6 +63,7 @@ export default { // 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. permission: "db-facade.records.write", + maxBodyBytes: 0, async handler(_request, context) { recordEvent({ contextKeys: Object.keys(context).sort(), @@ -81,6 +83,7 @@ export default { method: "POST", path: "/records", permission: "db-facade.records.write", + maxBodyBytes: 1024, async handler(request, context) { recordEvent({ contextKeys: Object.keys(context).sort(), @@ -98,6 +101,7 @@ export default { method: "PATCH", path: "/records/:id", permission: "db-facade.records.write", + maxBodyBytes: 1024, async handler(request, context) { recordEvent({ contextKeys: Object.keys(context).sort(), @@ -122,6 +126,7 @@ export default { method: "POST", path: "/records/transactional", permission: "db-facade.records.write", + maxBodyBytes: 1024, async handler(request, context) { recordEvent({ contextKeys: Object.keys(context).sort(), @@ -152,6 +157,7 @@ export default { method: "GET", path: "/boom", permission: "db-facade.records.read", + maxBodyBytes: 0, async handler(_request, context) { recordEvent({ contextKeys: Object.keys(context).sort(), @@ -170,6 +176,7 @@ export default { method: "GET", path: "/safe-boom", permission: "db-facade.records.read", + maxBodyBytes: 0, async handler(_request, context) { recordEvent({ contextKeys: Object.keys(context).sort(), diff --git a/core/tests/identity-client-address.test.ts b/core/tests/identity-client-address.test.ts new file mode 100644 index 0000000..598ca6a --- /dev/null +++ b/core/tests/identity-client-address.test.ts @@ -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 }); + }); +}); diff --git a/core/tests/identity-config.test.ts b/core/tests/identity-config.test.ts index a975d76..2c24a4e 100644 --- a/core/tests/identity-config.test.ts +++ b/core/tests/identity-config.test.ts @@ -9,12 +9,24 @@ function validInput() { 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", () => { it("accepts a valid secret, base URL, and trusted origins", () => { const config = createIdentityConfig(validInput(), { environment: "production" }); expect(config).toEqual({ baseUrl: validBaseUrl, + publicSignupEnabled: false, secret: validSecret, trustedOrigins: validTrustedOrigins, }); @@ -167,6 +179,226 @@ describe("Core identity configuration", () => { 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:pw@id.example.test", + "https://:pw@id.example.test", + " 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", () => { const hostileSecret = "s3cr3t-do-not-log-0123456789abcdef"; let captured: unknown; diff --git a/core/tests/identity-runtime.test.ts b/core/tests/identity-runtime.test.ts index 84c8eb0..30828af 100644 --- a/core/tests/identity-runtime.test.ts +++ b/core/tests/identity-runtime.test.ts @@ -9,19 +9,61 @@ vi.mock("better-auth", () => ({ betterAuth: betterAuthMock })); vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock })); 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"; -function validConfig(): IdentityConfig { +function validConfig(overrides: Partial = {}): IdentityConfig { return Object.freeze({ baseUrl: "https://identity.example.test", + publicSignupEnabled: false, secret: "correct-horse-battery-staple-0123456789", trustedOrigins: Object.freeze(["https://identity.example.test"]), + ...overrides, }); } -function fakeDb(overrides: Partial<{ execute: ReturnType }> = {}) { - return { __fakeDb: true, execute: vi.fn().mockResolvedValue(undefined), ...overrides } as never; +interface FakeDbOptions { + /** 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) => { + 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", () => { @@ -87,7 +129,7 @@ describe("Core identity runtime construction boundary", () => { ]); expect(options.emailAndPassword).toEqual({ autoSignIn: false, - disableSignUp: false, + disableSignUp: true, enabled: true, maxPasswordLength: 128, minPasswordLength: 8, @@ -98,13 +140,37 @@ describe("Core identity runtime construction boundary", () => { 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]; + expect(options.emailAndPassword).toMatchObject({ disableSignUp: false }); + }); + it("explicitly disables Better Auth's default test-environment origin/CSRF skip", () => { betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() }); createIdentityRuntime({ config: validConfig(), db: fakeDb() }); const [options] = betterAuthMock.mock.calls[0] as [Record]; - 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]; + 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", () => { @@ -297,65 +363,152 @@ describe("Core identity runtime — resolvePrincipal", () => { 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({ session: { id: "sess_1", token: "tok" }, user: { email: "person@example.test", id: "user_123", name: "Person" }, }); 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( - new Headers({ cookie: "better-auth.session_token=tok" }), + new Headers({ cookie: `${productionSessionCookie}=tok` }), ); expect(principal).toEqual({ kind: "authenticated", userId: "user_123" }); expect(Object.keys(principal)).toEqual(["kind", "userId"]); 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 execute = vi.fn().mockResolvedValue(undefined); betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() }); - const runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb({ execute }) }); - const principal = await runtime.resolvePrincipal(new Headers()); + for (const cookie of [ + undefined, + "x=y", + "a=1; b=2; c=3", + // 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`, + `extra_${productionSessionCookie}=v`, + `${productionSessionCookie.toUpperCase()}=v`, + "__Secure-better-auth%2Esession_token=v", + "better-auth.session_token=v", + ]) { + const db = fakeDb(); + const runtime = createIdentityRuntime({ config: validConfig(), db }); + const principal = await runtime.resolvePrincipal( + cookie === undefined ? new Headers() : new Headers({ cookie }), + ); - expect(principal).toEqual({ kind: "anonymous" }); - expect(execute).not.toHaveBeenCalled(); + expect(principal).toEqual({ kind: "anonymous" }); + expect(db.queries).toEqual([]); + } }); - it("maps an expired/revoked/malformed session (cookie present, null session) to anonymous when the database is reachable", 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); - const execute = vi.fn().mockResolvedValue(undefined); betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() }); + const db = fakeDb(); - const runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb({ execute }) }); + const runtime = createIdentityRuntime({ config: validConfig(), db }); const principal = await runtime.resolvePrincipal( - new Headers({ cookie: "better-auth.session_token=stale-or-garbage" }), + 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" }); - expect(execute).toHaveBeenCalledTimes(1); + // 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("never downgrades an infrastructure failure to anonymous: throws when the database is unreachable", async () => { + it("detects the session cookie among several cookies and with irregular whitespace", async () => { const getSession = vi.fn().mockResolvedValue(null); - const execute = vi.fn().mockRejectedValue(new Error("connection terminated unexpectedly")); betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() }); - const runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb({ execute }) }); - - let captured: unknown; - try { - await runtime.resolvePrincipal(new Headers({ cookie: "better-auth.session_token=x" })); - } catch (error) { - captured = error; + 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"]); } + }); - expect(captured).toBeInstanceOf(IdentityRuntimeError); - expect(captured).toMatchObject({ code: "PRINCIPAL_RESOLUTION_FAILED" }); + 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; + try { + await runtime.resolvePrincipal(new Headers({ cookie: `${productionSessionCookie}=x` })); + } catch (error) { + captured = error; + } + + expect(captured).toBeInstanceOf(IdentityRuntimeError); + 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 () => { diff --git a/core/tests/module-migration-runner.test.ts b/core/tests/module-migration-runner.test.ts index 43f046a..51da051 100644 --- a/core/tests/module-migration-runner.test.ts +++ b/core/tests/module-migration-runner.test.ts @@ -200,7 +200,7 @@ class FakeMigrationDatabaseSession implements MigrationDatabaseSession { } stagedTableExists = true; }, - executeStatement: async (sql) => { + executeMigrationSql: async (sql) => { this.#database.events.push("sql:execute"); this.#database.executedStatements.push(sql); 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 = '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 }); @@ -666,15 +666,54 @@ describe("module migration apply", () => { database, }); - expect(database.executedStatements).toEqual([ - 'CREATE TABLE "people_records" ("id" integer);\n', - '\nALTER TABLE "people_records" ADD COLUMN "name" text; SELECT 2;', - ]); + // One call, unmodified: the runner never splits, trims, normalizes, or rewrites migration SQL. + // PostgreSQL parses the batch, and Drizzle's marker lines are ordinary `--` comments to it. + expect(database.executedStatements).toEqual([sql]); expect(database.events.indexOf(`history:insert:${initial.identity}`)).toBeGreaterThan( 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 () => { const prior = migration("people", 0); const pendingFirst = migration("people", 1); diff --git a/core/tests/module-runtime.test.ts b/core/tests/module-runtime.test.ts index 51a1f78..5b0a28c 100644 --- a/core/tests/module-runtime.test.ts +++ b/core/tests/module-runtime.test.ts @@ -369,10 +369,13 @@ describe("module runtime", () => { const error = await captureRuntimeError(modulesDirectory); 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([ "one:start", "two:start", "three:start", + "three:stop", "two:stop", "one:stop", ]); @@ -444,6 +447,7 @@ describe("module HTTP routes", () => { method: "GET", path: "/items", permission: "single.items.read", + maxBodyBytes: 1024, handler: ${noopHandlerSource()}, }); } @@ -473,8 +477,8 @@ describe("module HTTP routes", () => { serverSource: `export default { id: "alpha", version: "0.1.0", register({ http }) { - http.route({ method: "GET", path: "/first", permission: "alpha.first.read", handler: ${noopHandlerSource()} }); - http.route({ method: "GET", path: "/second", permission: "alpha.second.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", maxBodyBytes: 1024, handler: ${noopHandlerSource()} }); } };`, }); @@ -485,7 +489,7 @@ describe("module HTTP routes", () => { serverSource: `export default { id: "beta", version: "0.1.0", 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 { id: "same-path", version: "0.1.0", register({ http }) { - http.route({ method: "GET", path: "/items", permission: "same-path.items.read", handler: ${noopHandlerSource()} }); - http.route({ method: "POST", path: "/items", permission: "same-path.items.write", 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", maxBodyBytes: 1024, handler: ${noopHandlerSource()} }); } };`, }); @@ -532,8 +536,8 @@ describe("module HTTP routes", () => { serverSource: `export default { id: "dup", version: "0.1.0", register({ http }) { - http.route({ method: "GET", path: "/items", permission: "dup.items.read", 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()} }); + 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 { id: "param-shape", version: "0.1.0", register({ http }) { - http.route({ method: "GET", path: "/events/:eventId", permission: "param-shape.events.read", handler: ${noopHandlerSource()} }); - http.route({ method: "GET", path: "/events/:id", 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", maxBodyBytes: 1024, handler: ${noopHandlerSource()} }); } };`, }); @@ -561,6 +565,141 @@ describe("module HTTP routes", () => { 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 () => { const badMethodDirectory = await createModulesDirectory(); await createRuntimeModule(badMethodDirectory, "bad-method", { @@ -570,7 +709,7 @@ describe("module HTTP routes", () => { serverSource: `export default { id: "bad-method", version: "0.1.0", 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 { id: "bad-path", version: "0.1.0", 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", ]); + 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(); await createRuntimeModule(badDefinitionDirectory, "bad-shape", { manifest: manifestFor("bad-shape", { @@ -624,7 +779,7 @@ describe("module HTTP routes", () => { serverSource: `export default { id: "undeclared", version: "0.1.0", 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", path: "/items", permission: "core.authorization.grants.manage", + maxBodyBytes: 1024, handler: ${noopHandlerSource()}, }); } @@ -667,7 +823,7 @@ describe("module HTTP routes", () => { serverSource: `export default { id: "beta", version: "0.1.0", 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 { id: "baselines", version: "0.1.0", register({ http }) { - http.route({ method: "GET", path: "/public", permission: "baselines.public.read", handler: ${noopHandlerSource()} }); - http.route({ method: "GET", path: "/authenticated", permission: "baselines.authenticated.read", handler: ${noopHandlerSource()} }); - http.route({ method: "GET", path: "/restricted", permission: "baselines.restricted.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", maxBodyBytes: 1024, 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) { expect(Object.keys(route).sort()).toEqual([ "handler", + "maxBodyBytes", "method", "moduleId", "path", @@ -724,6 +881,7 @@ describe("module HTTP routes", () => { method: "GET", path: "/items", permission: "never-invoked.items.read", + maxBodyBytes: 1024, handler: () => { ${recorder.expression}.push("handler:called"); return new Response(null, { status: 204 }); }, }); } @@ -745,7 +903,7 @@ describe("module HTTP routes", () => { serverSource: `export default { id: "frozen", version: "0.1.0", 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", path: "/items", permission: "mutate.items.read", + maxBodyBytes: 1024, handler: ${noopHandlerSource()}, }; http.route(definition); @@ -800,7 +959,7 @@ describe("module HTTP routes", () => { serverSource: `export default { id: "first-ok", version: "0.1.0", 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 { id: "second-bad", version: "0.1.0", 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 { id: "routed", version: "0.1.0", 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"); }, stop() { ${recorder.expression}.push("routed:stop"); } @@ -995,3 +1154,200 @@ describe("module runtime services", () => { 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); + } + }); +}); diff --git a/core/tests/postgres-migration-database.test.ts b/core/tests/postgres-migration-database.test.ts index 86f0206..e061d78 100644 --- a/core/tests/postgres-migration-database.test.ts +++ b/core/tests/postgres-migration-database.test.ts @@ -6,6 +6,8 @@ vi.mock("pg", () => ({ Client: clientConstructor })); import { createPostgresMigrationDatabase, + MIGRATION_CONNECTION_TIMEOUT_MS, + MIGRATION_OPERATION_TIMEOUT_MS, PostgresMigrationDatabaseError, } from "../src/migrations/postgres-database.js"; @@ -110,3 +112,187 @@ describe("PostgreSQL migration database composition", () => { 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 pg@8.22.0 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(); + }); +}); diff --git a/core/tests/postgres-migration.integration.ts b/core/tests/postgres-migration.integration.ts index bc8a61d..ada4ebb 100644 --- a/core/tests/postgres-migration.integration.ts +++ b/core/tests/postgres-migration.integration.ts @@ -1,4 +1,5 @@ import { createHash, randomBytes } from "node:crypto"; +import net from "node:net"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { Client, type QueryResult, type QueryResultRow } from "pg"; @@ -13,6 +14,7 @@ import { MigrationRunnerError, type MigrationRunnerErrorCode, planModuleMigrations, + PostgresMigrationDatabaseError, } from "../src/migrations/index.js"; import type { 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((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:ekklesia@127.0.0.1:${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((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 `pg@8.22.0`, 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((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:ekklesia@127.0.0.1:${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((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> = {}, + ): Promise { + const saved = new Map(); + 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); + }); + }); +}); diff --git a/docs/architecture/decisions/0009-transactional-module-migrations.md b/docs/architecture/decisions/0009-transactional-module-migrations.md index 3c4ceed..d6c19ed 100644 --- a/docs/architecture/decisions/0009-transactional-module-migrations.md +++ b/docs/architecture/decisions/0009-transactional-module-migrations.md @@ -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 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 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; diff --git a/docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md b/docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md index 8634fee..ccd32bd 100644 --- a/docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md +++ b/docs/architecture/decisions/0013-core-authorization-and-owner-bootstrap.md @@ -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 +> **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 establish a platform owner is `pnpm authorization:bootstrap-owner -- --user-id --confirm-user-id ` (`core/src/cli/bootstrap-platform-owner.ts`, diff --git a/docs/architecture/decisions/0015-static-module-http-routing-and-runtime-lifecycle.md b/docs/architecture/decisions/0015-static-module-http-routing-and-runtime-lifecycle.md index e0b0058..7adfd6d 100644 --- a/docs/architecture/decisions/0015-static-module-http-routing-and-runtime-lifecycle.md +++ b/docs/architecture/decisions/0015-static-module-http-routing-and-runtime-lifecycle.md @@ -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 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 -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 a variable-length handler array — Hono's overload resolution is arity-sensitive, and a spread call silently resolves to an unrelated, incompatible overload. diff --git a/docs/architecture/decisions/0017-public-request-perimeter-hardening.md b/docs/architecture/decisions/0017-public-request-perimeter-hardening.md new file mode 100644 index 0000000..6ac0043 --- /dev/null +++ b/docs/architecture/decisions/0017-public-request-perimeter-hardening.md @@ -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. diff --git a/docs/architecture/decisions/0018-identity-session-resolution-and-database-transport.md b/docs/architecture/decisions/0018-identity-session-resolution-and-database-transport.md new file mode 100644 index 0000000..9b4203e --- /dev/null +++ b/docs/architecture/decisions/0018-identity-session-resolution-and-database-transport.md @@ -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. diff --git a/docs/architecture/decisions/0019-durable-authorization-history-and-grant-management.md b/docs/architecture/decisions/0019-durable-authorization-history-and-grant-management.md new file mode 100644 index 0000000..65d55ec --- /dev/null +++ b/docs/architecture/decisions/0019-durable-authorization-history-and-grant-management.md @@ -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=&limit=<1..100>&cursor=` 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. diff --git a/docs/architecture/decisions/0020-module-runtime-and-migration-execution-hardening.md b/docs/architecture/decisions/0020-module-runtime-and-migration-execution-hardening.md new file mode 100644 index 0000000..14c54c6 --- /dev/null +++ b/docs/architecture/decisions/0020-module-runtime-and-migration-execution-hardening.md @@ -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 `pg@8.22.0` 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. diff --git a/docs/architecture/decisions/README.md b/docs/architecture/decisions/README.md index a2eca9f..7be97f0 100644 --- a/docs/architecture/decisions/README.md +++ b/docs/architecture/decisions/README.md @@ -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 - [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 +- [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 diff --git a/docs/architecture/platform-architecture.md b/docs/architecture/platform-architecture.md index f4e1772..cf77f7b 100644 --- a/docs/architecture/platform-architecture.md +++ b/docs/architecture/platform-architecture.md @@ -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 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 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 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 -depend on Hono. Public email/password registration is intentional; every account receives -identical lowest-privilege baseline status, and signup order never grants elevation. See +depend on Hono. Public email/password registration is the intended eventual product policy — every +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 [the API guide](../development/api.md), 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 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 -`/api/v1/modules/`, enforced and response-sanitized exactly like Core's own routes. This -branch completes Phase 5C: typed module database access (`context.services.database`) for a -module's lifecycle hooks and authorized HTTP handlers, over the platform's one existing PostgreSQL -pool. See [ADR 0014](decisions/0014-module-permission-declarations-and-platform-registry.md), +`/api/v1/modules/`, enforced and response-sanitized exactly like Core's own routes. Phase +5C added typed module database access (`context.services.database`) for a module's lifecycle hooks +and authorized HTTP handlers, over the platform's one existing PostgreSQL pool. +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 0016](decisions/0016-module-database-access.md), and the repository root [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 -work begins. Target architecture not yet implemented includes production approval-provider +**Phase 5D is complete.** Five passes hardened the platform against an independent security audit of +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 and action contracts, a Dashboard, a Builder, and reference feature modules; none of these are scheduled by this document. diff --git a/docs/development/api.md b/docs/development/api.md index 0ec7f99..83e33a7 100644 --- a/docs/development/api.md +++ b/docs/development/api.md @@ -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). 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). +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 -After this slice it is possible to start the API, publicly create an ordinary account, sign in, -resolve a request to an Ekklesia principal, and sign out. It is **not** possible to verify an email -address, recover a forgotten password, use any provider besides email/password, reach a Dashboard, -or hold any role or permission — none of that exists yet. **Public registration is not ready for a -real public launch after this PR**: there is no email delivery service, so an unverified email is -trusted as entered and a forgotten password has no recovery path. +After this slice it is possible to start the API, sign in, resolve a request to an Ekklesia +principal, and sign out; public account creation is also possible once explicitly enabled (see +[Public registration policy](#public-registration-policy)). It is **not** possible to verify an +email address, recover a forgotten password, use any provider besides email/password, reach a +Dashboard, or hold any role or permission — none of that exists yet. **Public registration is not +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 @@ -42,12 +54,18 @@ No real values are shown here — configure per environment and never commit the | 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_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_BASE_URL` | An absolute `http`/`https` URL. `https` is required in production; loopback 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_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 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. | ## 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`, ...). | | `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//*` | 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 @@ -76,7 +94,7 @@ Authenticated: { "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): ```json @@ -84,6 +102,13 @@ never silently reported as anonymous): ``` 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, 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 @@ -93,7 +118,35 @@ permission, membership, or volunteer data. ## 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 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 @@ -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 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 -slice — a deployment behind a reverse proxy is future work. +attribute is set, so every cookie is host-only. See [Client-address resolution](#client-address-resolution) +for how (and whether) a reverse proxy's forwarded headers are trusted — a separate concern from +CORS/CSRF above. ## 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 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 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 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 -own many requests. Storage remains the default in-memory backend, which has two honest limitations: -it is **per-process** (no protection across multiple API instances or restarts), and because no -trusted-proxy IP policy is configured, Better Auth may fall back to a single shared bucket per path -rather than a genuine per-client bucket. This is not yet the final, distributed, production-grade -abuse-control design. +own many requests. Storage remains the default in-memory backend, which is **per-process**: no +protection across multiple API instances or restarts. Client identity for this limiter is now +always the resolved, server-owned address described above — never a caller-controlled header — so +rotating a spoofed `X-Forwarded-For` on every request no longer lets a caller choose a fresh +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 @@ -259,6 +422,11 @@ are mounted under `/api/v1/modules/`, in the module runtime's publish 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 (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 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 @@ -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 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 `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 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 -load/register/start failure uses Core's own existing rollback (already-started modules stop, in -reverse order, before the failure propagates) and then closes the pool — there is no separate +load/register/start failure uses Core's own rollback — the module whose `start` hook failed is +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. ## A real signup → sign-in → principal → sign-out transcript 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, -not real credentials. +database, configured as **development** with public sign-up explicitly enabled — public sign-up is +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 POST /api/auth/sign-up/email diff --git a/docs/development/authorization.md b/docs/development/authorization.md index cfca816..4bec5d5 100644 --- a/docs/development/authorization.md +++ b/docs/development/authorization.md @@ -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 [API](api.md#module-http-routes)); and every enforced route fails closed on infrastructure trouble instead of silently allowing or denying. It is **not** possible to manage roles, groups, teams, or -tenants; there is no owner-management HTTP endpoint (revoking or re-granting platform-owner status -is not exposed at all); there are no service accounts or API tokens, so only cookie-authenticated +tenants; there is no user-deletion endpoint, owner-management HTTP endpoint, or owner-recovery +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 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. @@ -33,7 +35,41 @@ Established as a prior checkpoint on this branch (`ekklesia_core_platform_owner` `AuthorizationStore`; `@ekklesia/contracts`'s `PermissionId`/`GrantId`/`AuthorizationScope` branded types). This page does not restate that design — read `core/src/authorization/schema.ts`, `evaluator.ts`, and `store.ts` directly for it. This page covers what pass 2 added on top: the -permission registry, the bootstrap operation, and HTTP enforcement. +permission registry, 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 @@ -236,13 +272,37 @@ twice. `core/src/authorization/bootstrap.ts`'s `bootstrapPlatformOwner(db, {userId})` is a dedicated, Hono-independent Core operation — deliberately *not* a composition of "check for an owner, then -call the existing grant function." "Already consumed" means **any row has ever existed** in -`ekklesia_core_platform_owner`, for any user, including one whose `revoked_at` is set. There is no -owner-management flow in this slice that clears that state; a revoked owner staying revoked (and -bootstrap staying consumed) is intentional here. +call the existing grant function." -The single-use check, the target user's existence check, the owner-grant insert, and its audit -entry are all one PostgreSQL transaction. The very first statement inside that transaction is a +"Already consumed" means **the singleton row exists** in `ekklesia_core_platform_owner_bootstrap` +— 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: ```sql @@ -281,7 +341,7 @@ path, or stack — only a stable `code` and a fixed message: | Code | Meaning | | --- | --- | -| `PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED` | A platform-owner row already exists (any user, revoked or not). | +| `PLATFORM_OWNER_BOOTSTRAP_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_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 | | --- | --- | --- | -| `GET /api/v1/grants?userId=` | No | Lists the subject's explicit grants only (`sourceKind: "explicit"` filter applied server-side). | -| `POST /api/v1/grants` | Yes | Creates a new explicit grant. | -| `POST /api/v1/grants/:grantId/revoke` | Yes | Revokes an explicit grant without deleting it. | +| `GET /api/v1/grants?userId=&limit=<1..100>&cursor=` | 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 **unscoped** explicit grant. | +| `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 -Only four keys are accepted; any other key present (`actor`, `owner`, `isOwner`, `sourceKind`, -`sourceRef`, `id`, `createdAt`, `revokedAt`, `createdByActorId`, `effectivePermissions`, or -anything else) makes the **entire body** invalid rather than being silently dropped, so a caller -cannot smuggle a server-derived field through a request that otherwise looks well-formed: +Only three keys are accepted; any other key present (`scope`, `actor`, `owner`, `isOwner`, +`sourceKind`, `sourceRef`, `id`, `createdAt`, `revokedAt`, `createdByActorId`, +`effectivePermissions`, or anything else) makes the **entire body** invalid rather than being +silently dropped, so a caller cannot smuggle a server-derived field through a request that +otherwise looks well-formed: | Field | Required | Notes | | --- | --- | --- | | `subjectUserId` | Yes | Must parse as a valid `EkklesiaUserId`. | | `permission` | Yes | Must parse as a valid `PermissionId` **and** be registered in the running API's composed platform registry (Core's own declarations plus every installed module's — see [API startup](#api-startup-discovery-composition-and-enforcement)). A malformed value is a `400`; a well-formed but unregistered one is a `403`. | -| `scope` | No | Must parse as a valid, non-empty `AuthorizationScope` if present. | -| `expiresAt` | No | An ISO date string if present. | +| `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. | The server always derives, and the request body can never supply: the grant id, `sourceKind` (always `"explicit"`), the actor (always the authenticated caller, via `createUserActor(principal.userId)`), and all audit/timestamp fields. +**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 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}`. +`GET /api/v1/grants` → `200 {"grants": [...], "nextCursor": }`. + +### Listing is bounded keyset pagination + +```text +GET /api/v1/grants?userId=&limit=<1..100>&cursor= +``` + +| 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 | When | @@ -387,7 +542,8 @@ A grant, wherever returned: | `400 {"code":"INVALID_REQUEST"}` | Malformed query/body/param (including a disallowed extra field). | | `403 {"code":"AUTHORIZATION_DENIED"}` | Caller lacks `core.authorization.grants.manage`; the caller's own required permission id was unregistered; or, for `POST /api/v1/grants`, the well-formed *target* permission named in the body is not registered. | | `403 {"code":"UNTRUSTED_ORIGIN"}` | Missing or untrusted `Origin` on a mutation route. | -| `404 {"code":"AUTHORIZATION_GRANT_NOT_FOUND"}` | The targeted grant id does not exist as an *explicit* grant — this is also what revoking a real relationship-derived grant's id looks like, indistinguishably. | +| `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). | 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 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 `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 @@ -445,7 +609,23 @@ module database usage), and exactly one `pool.end()` call regardless of how many being imported. - `core/tests/authorization-bootstrap.integration.ts` (live PostgreSQL) — successful bootstrap and its audit row, nonexistent-user rollback, second-bootstrap rejection, a revoked historical owner - still blocking reuse, and the concurrency scenario described above. + 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- failure paths for `requirePermission`, and `requireTrustedOrigin`'s allow/reject behavior; 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 independently after a composition failure, and the exact documented terminal report shape. - `apps/api/tests/grants-routes.test.ts` — all three routes' allow/deny paths, disallowed-field - and malformed-field rejection, a well-formed but unregistered target permission rejected with + 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, sourceKind-filtered revoke returning 404 for a non-explicit grant, origin enforcement, and store-error mapping. diff --git a/docs/development/identity.md b/docs/development/identity.md index f1ba1c5..b3dc27d 100644 --- a/docs/development/identity.md +++ b/docs/development/identity.md @@ -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 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 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. @@ -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 ``` +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 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. @@ -126,12 +144,28 @@ intentionally contains no real values — configure them per environment, never | 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. | -| Base URL | An absolute `http`/`https` URL. In production, `https` is required and loopback 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. | +| 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 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 -explicitly passes `environment: "development"` — never silently. Every configuration failure -raises a sanitized `IdentityConfigError` carrying only a stable code, never the offending value. +**"Canonical origin" is literal**: the value must equal the WHATWG URL parser's own origin +serialization of itself. A path (`https://id.example.test/auth`), a bare trailing slash +(`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 ADR 0010. @@ -144,7 +178,8 @@ ADR 0010. `handleAuthRequest(request): Promise` (delegates directly to Better Auth's handler; its normal responses, including its own error responses, pass through unmodified) and `resolvePrincipal(headers): Promise` (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 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). @@ -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 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 -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 diff --git a/docs/development/repository-workflow.md b/docs/development/repository-workflow.md index b1a56a8..fa3b6d8 100644 --- a/docs/development/repository-workflow.md +++ b/docs/development/repository-workflow.md @@ -108,9 +108,10 @@ Data definitions ✓ Schemas validated 0 modules · 0 tables ✓ Migrations validated - 2 migrations · checksums valid + 3 migrations · checksums valid core:0000_identity_core_tables · f8f2086ef06f core:0001_authorization_core_tables · 830e1fbc26d0 + core:0002_authorization_integrity · 634294731180 PostgreSQL ○ 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, 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 -not repeat. Core authorization and an explicit platform-owner bootstrap command are the next -architectural phase; Doctor does not diagnose authorization, the `apps/api` HTTP transport, runtime -module health, or production storage yet, and it never constructs an operational Better Auth -instance, starts the API, or claims sign-in works. See [API](api.md) for `pnpm api:start` and the -identity HTTP transport this repository now has. +not repeat. Core authorization and the explicit platform-owner bootstrap command have since shipped, +and Doctor reports the composed permission registry; it still does not diagnose grant evaluation, the +`apps/api` HTTP transport, runtime module health, or production storage, and it never constructs an +operational Better Auth instance, starts the API, or claims sign-in works. See [API](api.md) for +`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` created from `.env.example`: diff --git a/docs/development/security.md b/docs/development/security.md new file mode 100644 index 0000000..2301b0f --- /dev/null +++ b/docs/development/security.md @@ -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 +(), Gitea 1.24's unsupported workflow syntax +includes `permissions` and `jobs..permissions`, `concurrency`, and +`jobs..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 + (). +- **`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. diff --git a/docs/development/testing.md b/docs/development/testing.md index 0b56345..e23f71b 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -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, 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, -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 runtime (explicit construction, exact schema mapping, only email/password and rate limiting 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 -they contain, pinned 8–128 password-length bounds, sanitized failures, and that a database outage -during principal resolution is never silently reported as anonymous), 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 +they contain, pinned 8–128 password-length bounds, sanitized failures, exact whole-name +session-cookie detection that never fires on a prefixed, suffixed, or differently-cased lookalike, +and that a broken session store during principal resolution is never silently reported as anonymous +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 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 @@ -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 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 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) @@ -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 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 `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 @@ -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 [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 the following pull-request status context to `main` branch protection: diff --git a/docs/module-development/README.md b/docs/module-development/README.md index 2c2dbdb..b3314c0 100644 --- a/docs/module-development/README.md +++ b/docs/module-development/README.md @@ -3,6 +3,12 @@ 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. +> **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 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 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 A module registers plain Fetch (`Request` in, `Response` out) route handlers during `register`, @@ -199,6 +220,8 @@ export default defineModuleRuntime({ method: "GET", path: "/events/:eventId", permission: "calendar.events.read", + // This route accepts no request body. See the body-size contract below. + maxBodyBytes: 0, async handler(request, context) { return Response.json({ eventId: context.params.eventId, @@ -210,11 +233,82 @@ export default defineModuleRuntime({ }); ``` -A route definition has exactly four fields — `method`, `path`, `permission`, `handler` — and nothing -else; an unknown field (a baseline, a mount prefix, CORS/origin policy, middleware, audit metadata, -authentication behavior, or another module's namespace) fails registration outright rather than -being ignored. `method` is one of `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, always uppercase; there is -no `OPTIONS` (the platform owns CORS preflight) and no wildcard/all-method registration. +A route definition has exactly five fields — `method`, `path`, `permission`, `maxBodyBytes`, +`handler` — and nothing else; an unknown field (a baseline, a mount prefix, CORS/origin policy, +middleware, audit metadata, authentication behavior, or another module's namespace) fails +registration outright rather than being ignored. `method` is one of `GET`, `POST`, `PUT`, `PATCH`, +`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 production mount prefix, `/api/v1/modules/`: `"/"` is the module's own root, `"/events"` @@ -370,6 +464,7 @@ export default defineModuleRuntime({ method: "GET", path: "/events", permission: "calendar.events.read", + maxBodyBytes: 0, async handler(_request, context) { const rows = await context.services.database .select({ id: events.id, title: events.title }) diff --git a/packages/contracts/src/authorization.ts b/packages/contracts/src/authorization.ts index a1d8e03..3162e4e 100644 --- a/packages/contracts/src/authorization.ts +++ b/packages/contracts/src/authorization.ts @@ -6,10 +6,22 @@ import { parseEkklesiaUserId } from "./identity.js"; /** * 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 - * `parseGrantId`/`safeParseGrantId`. Ekklesia does not assume any particular format; Core's - * Postgres-backed store happens to generate a UUID, but callers must treat the value as opaque. + * `parseGrantId`/`safeParseGrantId`. Callers must still treat the *value* as opaque — nothing + * 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; diff --git a/packages/contracts/src/module-http.ts b/packages/contracts/src/module-http.ts index 9dd9749..8208314 100644 --- a/packages/contracts/src/module-http.ts +++ b/packages/contracts/src/module-http.ts @@ -21,6 +21,23 @@ export function isModuleHttpMethod(value: unknown): value is ModuleHttpMethod { 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 staticSegmentPattern = /^[a-z0-9]+(?:-[a-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("/")}`; } +/** `"/"` 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 * 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. * `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. + * + * ## `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 { 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 * (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 - * 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. + * explicit parse call first; `http.route` validates and brands it. `maxBodyBytes` is the route's + * conscious request-body contract — `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 maximum number of bytes + * 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 { readonly method: ModuleHttpMethod; readonly path: string; readonly permission: string; + readonly maxBodyBytes: number; readonly handler: ModuleRouteHandler; } @@ -169,6 +257,7 @@ export interface ModuleRouteDefinition { readonly method: ModuleHttpMethod; readonly path: ModuleRoutePath; readonly permission: PermissionId; + readonly maxBodyBytes: number; readonly handler: ModuleRouteHandler; } @@ -187,18 +276,29 @@ export interface RegisteredModuleHttpRoute { readonly method: ModuleHttpMethod; readonly path: ModuleRoutePath; readonly permission: PermissionId; + readonly maxBodyBytes: number; 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 { return typeof value === "object" && value !== null && !Array.isArray(value); } -function hasModuleRouteDefinitionShape( - value: unknown, -): value is { method: unknown; path: unknown; permission: unknown; handler: unknown } { +function hasModuleRouteDefinitionShape(value: unknown): value is { + method: unknown; + path: unknown; + permission: unknown; + maxBodyBytes: unknown; + handler: unknown; +} { if (!isRecord(value)) { return false; } @@ -214,7 +314,8 @@ export type ModuleRouteDefinitionIssueCode = | "MODULE_ROUTE_DEFINITION_INVALID" | "MODULE_ROUTE_METHOD_INVALID" | "MODULE_ROUTE_PATH_INVALID" - | "MODULE_ROUTE_PERMISSION_INVALID"; + | "MODULE_ROUTE_PERMISSION_INVALID" + | "MODULE_ROUTE_MAX_BODY_BYTES_INVALID"; export type ModuleRouteDefinitionParseResult = | { 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, - * canonical method, canonical path, well-formed permission id — but never whether the permission is - * actually declared by the registering module. That ownership check needs the module's own manifest - * and belongs to the caller (Core's module runtime), not this framework-neutral contract. Never - * echoes the rejected input in the returned code. + * canonical method, canonical path, well-formed permission id, well-formed body-size contract — but + * never whether the permission is actually declared by the registering module. That ownership check + * needs the module's own manifest and belongs to the caller (Core's module runtime), not this + * framework-neutral contract. Never echoes the rejected input in the returned code. */ export function safeParseModuleRouteDefinition(value: unknown): ModuleRouteDefinitionParseResult { if (!hasModuleRouteDefinitionShape(value)) { @@ -242,12 +343,17 @@ export function safeParseModuleRouteDefinition(value: unknown): ModuleRouteDefin if (!parsedPermission.success) { 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 { success: true, data: Object.freeze({ method: value.method, path: parsedPath.data, permission: parsedPermission.data, + maxBodyBytes: parsedMaxBodyBytes.data, handler: value.handler as ModuleRouteHandler, }), }; diff --git a/packages/contracts/tests/authorization.test.ts b/packages/contracts/tests/authorization.test.ts index 65a5a16..6a2dce2 100644 --- a/packages/contracts/tests/authorization.test.ts +++ b/packages/contracts/tests/authorization.test.ts @@ -135,15 +135,53 @@ describe("PermissionDeclaration", () => { }); 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"); expect(id).toBe("11111111-1111-1111-1111-111111111111"); - expect(() => parseGrantId("")).toThrow(); - expect(() => parseGrantId("x".repeat(129))).toThrow(); + expect(parseGrantId("0b36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f70")).toBe( + "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", () => { - expect(safeParseGrantId("abc").success).toBe(true); + expect(safeParseGrantId("11111111-1111-1111-1111-111111111111").success).toBe(true); expect(safeParseGrantId("").success).toBe(false); }); }); diff --git a/packages/contracts/tests/module-http.test.ts b/packages/contracts/tests/module-http.test.ts index ee281b1..4f2d3c2 100644 --- a/packages/contracts/tests/module-http.test.ts +++ b/packages/contracts/tests/module-http.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it } from "vitest"; import { isModuleHttpMethod, MODULE_HTTP_METHODS, + MODULE_ROUTE_MAX_BODY_BYTES_CEILING, type ModuleRouteDefinitionInput, moduleRoutePathSchema, moduleRouteShapeKey, + moduleRoutesOverlap, parseModuleRouteDefinition, parseModuleRoutePath, safeParseModuleRouteDefinition, @@ -22,6 +24,7 @@ function validDefinition( method: "GET", path: "/events/:eventId", permission: "calendar.events.read", + maxBodyBytes: 0, handler: noopHandler, ...overrides, }; @@ -153,6 +156,7 @@ describe("safeParseModuleRouteDefinition", () => { method: "GET", path: "/events/:eventId", permission: "calendar.events.read", + maxBodyBytes: 0, handler: noopHandler, }); 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", () => { const source = { ...validDefinition() }; 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, + ); + }); +}); diff --git a/packages/module-sdk/tests/define-module-runtime.test.ts b/packages/module-sdk/tests/define-module-runtime.test.ts index 4fd4106..fd004ee 100644 --- a/packages/module-sdk/tests/define-module-runtime.test.ts +++ b/packages/module-sdk/tests/define-module-runtime.test.ts @@ -48,6 +48,7 @@ describe("defineModuleRuntime", () => { method: "GET", path: "/records", permission: "example.records.read", + maxBodyBytes: 0, handler: () => new Response(null, { status: 204 }), }); }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e754f0e..d4a5a23 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,13 @@ settings: autoInstallPeers: true 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: .: @@ -531,12 +538,6 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.18.20': - resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} - engines: {node: '>=12'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} @@ -549,12 +550,6 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm@0.18.20': - resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} - engines: {node: '>=12'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} @@ -567,12 +562,6 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-x64@0.18.20': - resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} - engines: {node: '>=12'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} @@ -585,12 +574,6 @@ packages: cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.18.20': - resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} - engines: {node: '>=12'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} @@ -603,12 +586,6 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.18.20': - resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} @@ -621,12 +598,6 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.18.20': - resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} - engines: {node: '>=12'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} @@ -639,12 +610,6 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.18.20': - resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} @@ -657,12 +622,6 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.18.20': - resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} - engines: {node: '>=12'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} @@ -675,12 +634,6 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.18.20': - resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} - engines: {node: '>=12'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} @@ -693,12 +646,6 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.18.20': - resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} - engines: {node: '>=12'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} @@ -711,12 +658,6 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.18.20': - resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} - engines: {node: '>=12'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} @@ -729,12 +670,6 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.18.20': - resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} - engines: {node: '>=12'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} @@ -747,12 +682,6 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.18.20': - resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} - engines: {node: '>=12'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} @@ -765,12 +694,6 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.18.20': - resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} - engines: {node: '>=12'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} @@ -783,12 +706,6 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.18.20': - resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} - engines: {node: '>=12'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} @@ -801,12 +718,6 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.18.20': - resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} - engines: {node: '>=12'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} @@ -831,12 +742,6 @@ packages: cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.18.20': - resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} - engines: {node: '>=12'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} @@ -861,12 +766,6 @@ packages: cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.18.20': - resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} - engines: {node: '>=12'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} @@ -891,12 +790,6 @@ packages: cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.18.20': - resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} @@ -909,12 +802,6 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.18.20': - resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} - engines: {node: '>=12'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} @@ -927,12 +814,6 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.18.20': - resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} - engines: {node: '>=12'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} @@ -945,12 +826,6 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.18.20': - resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} - engines: {node: '>=12'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} @@ -1579,11 +1454,6 @@ packages: es-module-lexer@2.3.0: resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==} - esbuild@0.18.20: - resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} - engines: {node: '>=12'} - hasBin: true - esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -1757,8 +1627,8 @@ packages: resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} engines: {node: '>=10'} - lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -1769,8 +1639,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.15: - resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -1843,8 +1713,8 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} - postcss@8.5.17: - resolution: {integrity: sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==} + postcss@8.5.24: + resolution: {integrity: sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==} engines: {node: ^10 || ^12 || >=14} postgres-array@2.0.0: @@ -2414,12 +2284,12 @@ snapshots: dependencies: '@chevrotain/gast': 10.5.0 '@chevrotain/types': 10.5.0 - lodash: 4.17.21 + lodash: 4.18.1 '@chevrotain/gast@10.5.0': dependencies: '@chevrotain/types': 10.5.0 - lodash: 4.17.21 + lodash: 4.18.1 '@chevrotain/types@10.5.0': {} @@ -2456,7 +2326,7 @@ snapshots: '@esbuild-kit/core-utils@3.3.2': dependencies: - esbuild: 0.18.20 + esbuild: 0.25.12 source-map-support: 0.5.21 '@esbuild-kit/esm-loader@2.6.5': @@ -2470,144 +2340,96 @@ snapshots: '@esbuild/aix-ppc64@0.28.1': optional: true - '@esbuild/android-arm64@0.18.20': - optional: true - '@esbuild/android-arm64@0.25.12': optional: true '@esbuild/android-arm64@0.28.1': optional: true - '@esbuild/android-arm@0.18.20': - optional: true - '@esbuild/android-arm@0.25.12': optional: true '@esbuild/android-arm@0.28.1': optional: true - '@esbuild/android-x64@0.18.20': - optional: true - '@esbuild/android-x64@0.25.12': optional: true '@esbuild/android-x64@0.28.1': optional: true - '@esbuild/darwin-arm64@0.18.20': - optional: true - '@esbuild/darwin-arm64@0.25.12': optional: true '@esbuild/darwin-arm64@0.28.1': optional: true - '@esbuild/darwin-x64@0.18.20': - optional: true - '@esbuild/darwin-x64@0.25.12': optional: true '@esbuild/darwin-x64@0.28.1': optional: true - '@esbuild/freebsd-arm64@0.18.20': - optional: true - '@esbuild/freebsd-arm64@0.25.12': optional: true '@esbuild/freebsd-arm64@0.28.1': optional: true - '@esbuild/freebsd-x64@0.18.20': - optional: true - '@esbuild/freebsd-x64@0.25.12': optional: true '@esbuild/freebsd-x64@0.28.1': optional: true - '@esbuild/linux-arm64@0.18.20': - optional: true - '@esbuild/linux-arm64@0.25.12': optional: true '@esbuild/linux-arm64@0.28.1': optional: true - '@esbuild/linux-arm@0.18.20': - optional: true - '@esbuild/linux-arm@0.25.12': optional: true '@esbuild/linux-arm@0.28.1': optional: true - '@esbuild/linux-ia32@0.18.20': - optional: true - '@esbuild/linux-ia32@0.25.12': optional: true '@esbuild/linux-ia32@0.28.1': optional: true - '@esbuild/linux-loong64@0.18.20': - optional: true - '@esbuild/linux-loong64@0.25.12': optional: true '@esbuild/linux-loong64@0.28.1': optional: true - '@esbuild/linux-mips64el@0.18.20': - optional: true - '@esbuild/linux-mips64el@0.25.12': optional: true '@esbuild/linux-mips64el@0.28.1': optional: true - '@esbuild/linux-ppc64@0.18.20': - optional: true - '@esbuild/linux-ppc64@0.25.12': optional: true '@esbuild/linux-ppc64@0.28.1': optional: true - '@esbuild/linux-riscv64@0.18.20': - optional: true - '@esbuild/linux-riscv64@0.25.12': optional: true '@esbuild/linux-riscv64@0.28.1': optional: true - '@esbuild/linux-s390x@0.18.20': - optional: true - '@esbuild/linux-s390x@0.25.12': optional: true '@esbuild/linux-s390x@0.28.1': optional: true - '@esbuild/linux-x64@0.18.20': - optional: true - '@esbuild/linux-x64@0.25.12': optional: true @@ -2620,9 +2442,6 @@ snapshots: '@esbuild/netbsd-arm64@0.28.1': optional: true - '@esbuild/netbsd-x64@0.18.20': - optional: true - '@esbuild/netbsd-x64@0.25.12': optional: true @@ -2635,9 +2454,6 @@ snapshots: '@esbuild/openbsd-arm64@0.28.1': optional: true - '@esbuild/openbsd-x64@0.18.20': - optional: true - '@esbuild/openbsd-x64@0.25.12': optional: true @@ -2650,36 +2466,24 @@ snapshots: '@esbuild/openharmony-arm64@0.28.1': optional: true - '@esbuild/sunos-x64@0.18.20': - optional: true - '@esbuild/sunos-x64@0.25.12': optional: true '@esbuild/sunos-x64@0.28.1': optional: true - '@esbuild/win32-arm64@0.18.20': - optional: true - '@esbuild/win32-arm64@0.25.12': optional: true '@esbuild/win32-arm64@0.28.1': optional: true - '@esbuild/win32-ia32@0.18.20': - optional: true - '@esbuild/win32-ia32@0.25.12': optional: true '@esbuild/win32-ia32@0.28.1': optional: true - '@esbuild/win32-x64@0.18.20': - optional: true - '@esbuild/win32-x64@0.25.12': optional: true @@ -3061,7 +2865,7 @@ snapshots: '@chevrotain/gast': 10.5.0 '@chevrotain/types': 10.5.0 '@chevrotain/utils': 10.5.0 - lodash: 4.17.21 + lodash: 4.18.1 regexp-to-ast: 0.5.0 chokidar@5.0.0: @@ -3112,31 +2916,6 @@ snapshots: es-module-lexer@2.3.0: {} - esbuild@0.18.20: - 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 - esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 @@ -3297,7 +3076,7 @@ snapshots: lilconfig@2.1.0: {} - lodash@4.17.21: {} + lodash@4.18.1: {} lru-cache@5.1.1: dependencies: @@ -3309,7 +3088,7 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.15: {} + nanoid@3.3.16: {} nanostores@1.4.1: {} @@ -3375,9 +3154,9 @@ snapshots: exsolve: 1.1.0 pathe: 2.0.3 - postcss@8.5.17: + postcss@8.5.24: dependencies: - nanoid: 3.3.15 + nanoid: 3.3.16 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -3523,7 +3302,7 @@ snapshots: dependencies: lightningcss: 1.32.0 picomatch: 4.0.5 - postcss: 8.5.17 + postcss: 8.5.24 rolldown: 1.1.5 tinyglobby: 0.2.17 optionalDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dd5d4d5..54c9ba1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -9,3 +9,29 @@ packages: allowBuilds: esbuild: true + +minimumReleaseAgeExclude: + - postcss@8.5.24 + +# 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" diff --git a/scripts/lib/compose-job-network.sh b/scripts/lib/compose-job-network.sh index 890ca1f..01e97e6 100644 --- a/scripts/lib/compose-job-network.sh +++ b/scripts/lib/compose-job-network.sh @@ -22,6 +22,17 @@ compose_job_network_initialize() { 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() { if [[ "$COMPOSE_JOB_NETWORK_RUNNING_IN_CONTAINER" != "true" ]]; then return 1 diff --git a/scripts/test-migrations-integration.sh b/scripts/test-migrations-integration.sh index 1f1cf56..40b609c 100755 --- a/scripts/test-migrations-integration.sh +++ b/scripts/test-migrations-integration.sh @@ -96,5 +96,9 @@ else 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" \ pnpm exec vitest run --config vitest.migrations.integration.config.ts diff --git a/templates/module/src/server/index.ts b/templates/module/src/server/index.ts index 0b00625..a9f84e9 100644 --- a/templates/module/src/server/index.ts +++ b/templates/module/src/server/index.ts @@ -21,6 +21,12 @@ export default defineModuleRuntime({ method: "GET", path: "/records", 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) { const rows = await context.services.database .select({ id: records.id, value: records.value }) diff --git a/templates/module/tests/manifest.test.ts b/templates/module/tests/manifest.test.ts index 4af5b8a..7564609 100644 --- a/templates/module/tests/manifest.test.ts +++ b/templates/module/tests/manifest.test.ts @@ -106,6 +106,9 @@ describe("module template", () => { method: "GET", path: "/records", 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, }); }); diff --git a/vitest.migrations.integration.config.ts b/vitest.migrations.integration.config.ts index 108e0c0..5e414f1 100644 --- a/vitest.migrations.integration.config.ts +++ b/vitest.migrations.integration.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ hookTimeout: 30_000, include: [ "core/tests/postgres-migration.integration.ts", + "core/tests/database-tls.integration.ts", "core/tests/authorization-store.integration.ts", "core/tests/authorization-bootstrap.integration.ts", "apps/api/tests/api-http.integration.ts",