Merge pull request 'phase-5/security-hardening' (#14) from phase-5/security-hardening into main
Reviewed-on: http://git.riverofglorymn.org/River_of_Glory_Church/Ekklesia/pulls/14
This commit was merged in pull request #14.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.<job_id>.permissions`, `concurrency`, and
|
||||
# `jobs.<job_id>.timeout-minutes`. They are declared here deliberately, so the workflow is already
|
||||
# correct on a supported release and on GitHub Actions, but on this server they are inert:
|
||||
#
|
||||
# * `permissions:` does NOT restrict the in-job `GITEA_TOKEN`. Job-token permission controls,
|
||||
# repository/owner defaults, and server-side maximum-permission clamping arrive in the 1.26
|
||||
# feature line (https://docs.gitea.com/1.26/usage/actions/token-permissions). Until the server
|
||||
# is upgraded and configured, the token's authority is whatever the server grants — not what
|
||||
# this file requests.
|
||||
# * `concurrency:` and `timeout-minutes:` are NOT bounded-execution guarantees here. Cancellation,
|
||||
# timeout, and teardown must be enforced by the trusted runner provisioner/reaper.
|
||||
#
|
||||
# `persist-credentials: false` below is unaffected by any of this and is enforced by the action
|
||||
# itself. See docs/development/security.md.
|
||||
|
||||
on:
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
+15
-2
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { type ClientAddressResolver, TRUSTED_CLIENT_ADDRESS_HEADER } from "@ekklesia/core/identity";
|
||||
import type { Http2Bindings, HttpBindings } from "@hono/node-server";
|
||||
|
||||
type NodeServerBindings = HttpBindings | Http2Bindings;
|
||||
|
||||
export {
|
||||
type ClientAddressConfig,
|
||||
ClientAddressConfigError,
|
||||
type ClientAddressConfigErrorCode,
|
||||
type ClientAddressConfigInput,
|
||||
type ClientAddressFailureReason,
|
||||
type ClientAddressMode,
|
||||
type ClientAddressResolution,
|
||||
type ClientAddressResolver,
|
||||
createClientAddressConfig,
|
||||
createClientAddressResolver,
|
||||
type DirectClientAddressConfig,
|
||||
findInvalidTrustedProxies,
|
||||
normalizeClientAddress,
|
||||
type TrustedProxyClientAddressConfig,
|
||||
} from "@ekklesia/core/identity";
|
||||
|
||||
/** A fixed, generic diagnostic — never the peer address, forwarded header content, request path, or
|
||||
* any other request detail — logged only when client-address resolution fails closed. */
|
||||
export const CLIENT_ADDRESS_UNRESOLVED_LOG_MESSAGE =
|
||||
"[client-address] A request's caller address could not be resolved safely; rejecting with 503.";
|
||||
|
||||
function sanitizedClientAddressUnresolvedResponse(): Response {
|
||||
return new Response(JSON.stringify({ code: "CLIENT_ADDRESS_UNRESOLVED" }), {
|
||||
headers: { "cache-control": "no-store", "content-type": "application/json" },
|
||||
status: 503,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a `@hono/node-server` fetch callback at the exact Node/API transport boundary — the one
|
||||
* place in this repository below `apps/api` that reads the raw Node `IncomingMessage` — with
|
||||
* Core's framework-agnostic client-address resolution policy (`@ekklesia/core/identity`). Resolves
|
||||
* the caller's trusted address from the real socket peer (`env.incoming.socket.remoteAddress`)
|
||||
* before the wrapped callback — and therefore Better Auth's own rate limiter — ever sees the
|
||||
* request, then overwrites (never merely appends) any externally supplied copy of
|
||||
* `TRUSTED_CLIENT_ADDRESS_HEADER` with that resolved value. A caller can therefore never choose its
|
||||
* own resolved address, whether by spoofing a forwarded header or by sending the internal header
|
||||
* directly. Resolution failure never reaches the wrapped callback at all — no Better Auth logic, no
|
||||
* Core mutation, and no module handler ever runs for a request whose caller address could not be
|
||||
* resolved safely.
|
||||
*/
|
||||
export function wrapFetchWithClientAddress(
|
||||
fetchCallback: (request: Request, env: NodeServerBindings) => Response | Promise<Response>,
|
||||
resolver: ClientAddressResolver,
|
||||
): (request: Request, env: NodeServerBindings) => Promise<Response> {
|
||||
return async (request: Request, env: NodeServerBindings): Promise<Response> => {
|
||||
const resolution = resolver.resolve({
|
||||
headers: request.headers,
|
||||
peerAddress: env.incoming.socket.remoteAddress,
|
||||
});
|
||||
if (!resolution.ok) {
|
||||
console.error(CLIENT_ADDRESS_UNRESOLVED_LOG_MESSAGE);
|
||||
return sanitizedClientAddressUnresolvedResponse();
|
||||
}
|
||||
const headers = new Headers(request.headers);
|
||||
headers.set(TRUSTED_CLIENT_ADDRESS_HEADER, resolution.address);
|
||||
const sanitizedRequest = new Request(request, { headers });
|
||||
return fetchCallback(sanitizedRequest, env);
|
||||
};
|
||||
}
|
||||
+37
-1
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
+274
-77
@@ -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<string>;
|
||||
/** Injected only so expiration validation is deterministic under test. Production composition
|
||||
* leaves this unset and the routes read real time. */
|
||||
readonly now?: () => Date;
|
||||
}
|
||||
|
||||
const DEFAULT_LIST_LIMIT = 50;
|
||||
|
||||
function sanitizedStoreErrorResponse(c: Context, error: unknown): Response {
|
||||
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<string, unknown>;
|
||||
if (Object.keys(payload).sort().join(",") !== "g,t,u,v") {
|
||||
return undefined;
|
||||
}
|
||||
if (payload.v !== CURSOR_VERSION || payload.u !== userId || typeof payload.t !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const createdAt = new Date(payload.t);
|
||||
if (Number.isNaN(createdAt.getTime()) || createdAt.toISOString() !== payload.t) {
|
||||
return undefined;
|
||||
}
|
||||
const id = safeParseGrantId(payload.g);
|
||||
if (!id.success) {
|
||||
return undefined;
|
||||
}
|
||||
const boundary: GrantPageBoundary = Object.freeze({ createdAt, id: id.data });
|
||||
if (encodeCursor(userId, boundary) !== raw) {
|
||||
return undefined;
|
||||
}
|
||||
return boundary;
|
||||
}
|
||||
|
||||
const LIST_ALLOWED_QUERY_KEYS = new Set(["userId", "limit", "cursor"]);
|
||||
/** Canonical base-10, no sign, no leading zero, no decimal point, no whitespace, no exponent. */
|
||||
const CANONICAL_POSITIVE_INTEGER_PATTERN = /^[1-9][0-9]*$/u;
|
||||
|
||||
interface ParsedListQuery {
|
||||
readonly after?: GrantPageBoundary;
|
||||
readonly limit: number;
|
||||
readonly subjectUserId: EkklesiaUserId;
|
||||
}
|
||||
|
||||
/** Fully validated before any store or database work happens, so an invalid page request never
|
||||
* becomes a query, and never becomes a way to probe which subjects or grants exist. */
|
||||
function parseListQuery(url: string): ParsedListQuery | undefined {
|
||||
const searchParams = new URL(url).searchParams;
|
||||
for (const key of searchParams.keys()) {
|
||||
if (!LIST_ALLOWED_QUERY_KEYS.has(key) || searchParams.getAll(key).length > 1) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const subjectUserId = safeParseEkklesiaUserId(searchParams.get("userId"));
|
||||
if (!subjectUserId.success) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let limit = DEFAULT_LIST_LIMIT;
|
||||
const rawLimit = searchParams.get("limit");
|
||||
if (rawLimit !== null) {
|
||||
if (!CANONICAL_POSITIVE_INTEGER_PATTERN.test(rawLimit)) {
|
||||
return undefined;
|
||||
}
|
||||
limit = Number(rawLimit);
|
||||
if (!Number.isSafeInteger(limit) || limit < 1 || limit > AUTHORIZATION_GRANT_LIST_MAX_LIMIT) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
let after: GrantPageBoundary | undefined;
|
||||
const rawCursor = searchParams.get("cursor");
|
||||
if (rawCursor !== null) {
|
||||
after = decodeCursor(rawCursor, subjectUserId.data);
|
||||
if (after === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
...(after === undefined ? {} : { after }),
|
||||
limit,
|
||||
subjectUserId: subjectUserId.data,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string> = 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<Response> => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+292
-13
@@ -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<Record<string, string>> = Object.freeze({
|
||||
ERR_HTTP_REQUEST_TIMEOUT: "HTTP/1.1 408 Request Timeout\r\nConnection: close\r\n\r\n",
|
||||
HPE_HEADER_OVERFLOW: "HTTP/1.1 431 Request Header Fields Too Large\r\nConnection: close\r\n\r\n",
|
||||
});
|
||||
const NODE_CLIENT_ERROR_DEFAULT_RESPONSE = "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n";
|
||||
|
||||
/**
|
||||
* Mirrors Node's own `socketOnError` fallback exactly: write the code-appropriate response only
|
||||
* when the socket is still writable *and* no response has already begun for the in-flight request,
|
||||
* then destroy the socket. The `_httpMessage` guard is what prevents a second, corrupting status
|
||||
* line when a handler already responded and only the (unread) request body subsequently timed out.
|
||||
*/
|
||||
function writeNodeClientErrorResponse(error: NodeJS.ErrnoException, socket: Socket): void {
|
||||
const inFlightResponse = (socket as Socket & { _httpMessage?: { headersSent?: boolean } })
|
||||
._httpMessage;
|
||||
const alreadyResponding = inFlightResponse?.headersSent === true;
|
||||
if (socket.writable && !alreadyResponding) {
|
||||
const code = typeof error.code === "string" ? error.code : "";
|
||||
socket.write(NODE_CLIENT_ERROR_RESPONSES[code] ?? NODE_CLIENT_ERROR_DEFAULT_RESPONSE);
|
||||
}
|
||||
socket.destroy();
|
||||
}
|
||||
|
||||
export interface RunningApi {
|
||||
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<NodeServerTimeouts>;
|
||||
/** Test-only escape hatch: narrows the graceful HTTP drain deadline
|
||||
* (`HTTP_DRAIN_TIMEOUT_MS`) so a forced close can be proven in milliseconds. Never set by the
|
||||
* executable entry point. */
|
||||
readonly drainTimeoutMsOverride?: number;
|
||||
/** Test-only escape hatch: narrows the per-module stop/rollback hook deadline
|
||||
* (`MODULE_STOP_HOOK_TIMEOUT_MS`). Never set by the executable entry point. */
|
||||
readonly moduleHookTimeoutMsOverride?: number;
|
||||
}
|
||||
|
||||
async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise<void> {
|
||||
@@ -61,14 +159,146 @@ async function safeStopModuleRuntime(moduleRuntime: StartedModuleRuntime): Promi
|
||||
}
|
||||
}
|
||||
|
||||
function bindServer(app: Hono, host: string, port: number): Promise<ServerType> {
|
||||
/**
|
||||
* The platform's fixed graceful-drain deadline. In-flight HTTP work has this long to finish after
|
||||
* the listener stops accepting new connections; after it, connections are closed so process
|
||||
* shutdown stays bounded. Not configurable in this slice.
|
||||
*/
|
||||
export const HTTP_DRAIN_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* A shutdown that did not complete cleanly. Carries only fixed booleans and the already-sanitized
|
||||
* module-runtime error — never a request, socket, URL, credential, SQL, or stack detail — so an
|
||||
* operator can see *which* stages failed without any of it becoming a disclosure channel.
|
||||
*/
|
||||
export class ApiShutdownError extends Error {
|
||||
readonly drainTimedOut: boolean;
|
||||
readonly moduleStopFailed: boolean;
|
||||
readonly moduleStopError: unknown;
|
||||
|
||||
constructor(drainTimedOut: boolean, moduleStopError: unknown) {
|
||||
super("The API did not shut down cleanly.");
|
||||
this.name = "ApiShutdownError";
|
||||
this.drainTimedOut = drainTimedOut;
|
||||
this.moduleStopFailed = moduleStopError !== undefined;
|
||||
this.moduleStopError = moduleStopError;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops accepting new connections, then waits up to `timeoutMs` for existing HTTP work to finish.
|
||||
*
|
||||
* At the deadline it closes idle and then still-active connections using Node's own supported
|
||||
* public `Server` APIs (`closeIdleConnections`/`closeAllConnections`, both called only after
|
||||
* `server.close()`), and stops waiting for the close callback. No private socket or server field is
|
||||
* touched.
|
||||
*
|
||||
* Force-closing a connection ends the transport. It does **not** prove that arbitrary in-process
|
||||
* handler JavaScript was canceled — a handler promise may still be running and may still settle.
|
||||
* What is guaranteed is that shutdown no longer waits for it, and that the pool closes afterwards,
|
||||
* so its late database work fails.
|
||||
*/
|
||||
/** `ServerType` is a union that also covers HTTP/2 servers, which do not expose these two methods.
|
||||
* `serve()` here always creates a plain `http.Server`, which does; narrowing with a real capability
|
||||
* check keeps that honest instead of asserting a cast. */
|
||||
interface ConnectionClosingServer {
|
||||
closeAllConnections(): void;
|
||||
closeIdleConnections(): void;
|
||||
}
|
||||
|
||||
function supportsConnectionClosing(
|
||||
server: ServerType,
|
||||
): server is ServerType & ConnectionClosingServer {
|
||||
const candidate = server as Partial<ConnectionClosingServer>;
|
||||
return (
|
||||
typeof candidate.closeIdleConnections === "function" &&
|
||||
typeof candidate.closeAllConnections === "function"
|
||||
);
|
||||
}
|
||||
|
||||
async function drainHttpServer(
|
||||
server: ServerType,
|
||||
timeoutMs: number,
|
||||
): Promise<"drained" | "forced"> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const closed = new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
try {
|
||||
const outcome = await Promise.race([
|
||||
closed.then(() => "drained" as const),
|
||||
new Promise<"forced">((resolve) => {
|
||||
timer = setTimeout(() => resolve("forced"), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
if (outcome === "drained") {
|
||||
return "drained";
|
||||
}
|
||||
if (supportsConnectionClosing(server)) {
|
||||
server.closeIdleConnections();
|
||||
server.closeAllConnections();
|
||||
}
|
||||
return "forced";
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
// The close callback may still fire later; nothing awaits it, and it carries no rejection.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the Node HTTP server. Two platform-owned transport concerns are applied at this exact
|
||||
* boundary, below Hono entirely: `wrapFetchWithClientAddress` resolves and sanitizes the caller's
|
||||
* address from the real socket peer before any route (including Better Auth's own rate limiter)
|
||||
* ever sees the request (see `./client-address.js`), and `serverOptions` gives Node finite
|
||||
* header/request/keep-alive timeouts so a slow-header or slow-body connection cannot hold a socket
|
||||
* indefinitely. A connection Node itself closes for exceeding one of these timeouts never reaches
|
||||
* `app.fetch` and never receives a second response — both are Node's own documented behavior for
|
||||
* `headersTimeout`/`requestTimeout`, not something this function re-implements.
|
||||
*/
|
||||
function bindServer(
|
||||
app: Hono,
|
||||
host: string,
|
||||
port: number,
|
||||
clientAddressResolver: ClientAddressResolver,
|
||||
timeouts: NodeServerTimeouts,
|
||||
): Promise<ServerType> {
|
||||
return new Promise((resolve, reject) => {
|
||||
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<void> | undefined;
|
||||
const shutdown = (): Promise<void> => {
|
||||
if (!shutdownPromise) {
|
||||
shutdownPromise = (async () => {
|
||||
await new Promise<void>((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<typeof createApiRuntimeConfig>[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,
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+131
-1
@@ -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<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode("a"));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.request("/api/auth/sign-in/email", {
|
||||
body: stream,
|
||||
duplex: "half",
|
||||
headers: { "content-length": String(AUTH_REQUEST_MAX_BODY_BYTES * 10) },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an oversized streamed body with no declared Content-Length", async () => {
|
||||
const runtime = fakeRuntime();
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
moduleServices: fakeModuleServices(),
|
||||
authorization: fakeAuthorization({}),
|
||||
runtime,
|
||||
trustedOrigins: [trustedOrigin],
|
||||
});
|
||||
const chunk = new Uint8Array(1024).fill(97);
|
||||
let enqueued = 0;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (enqueued > AUTH_REQUEST_MAX_BODY_BYTES) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
enqueued += chunk.length;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.request("/api/auth/sign-in/email", {
|
||||
body: stream,
|
||||
duplex: "half",
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("has deterministic 404 behavior for an unknown route", async () => {
|
||||
const app = createApiApp({
|
||||
moduleRoutes: Object.freeze([]),
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { TRUSTED_CLIENT_ADDRESS_HEADER } from "@ekklesia/core/identity";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CLIENT_ADDRESS_UNRESOLVED_LOG_MESSAGE,
|
||||
createClientAddressResolver,
|
||||
wrapFetchWithClientAddress,
|
||||
} from "../src/client-address.js";
|
||||
|
||||
function fakeEnv(remoteAddress: string | undefined) {
|
||||
return {
|
||||
incoming: { socket: { remoteAddress } },
|
||||
outgoing: {},
|
||||
} as unknown as Parameters<typeof wrapFetchWithClientAddress>[0] extends (
|
||||
request: Request,
|
||||
env: infer Env,
|
||||
) => unknown
|
||||
? Env
|
||||
: never;
|
||||
}
|
||||
|
||||
describe("wrapFetchWithClientAddress", () => {
|
||||
it("resolves the trusted address from the real socket peer and sets the internal header before calling the wrapped callback", async () => {
|
||||
const resolver = createClientAddressResolver({ mode: "direct" });
|
||||
const wrappedFetch = vi.fn().mockResolvedValue(new Response("ok"));
|
||||
const wrapped = wrapFetchWithClientAddress(wrappedFetch, resolver);
|
||||
|
||||
const request = new Request("https://example.test/api/auth/sign-in/email");
|
||||
await wrapped(request, fakeEnv("203.0.113.9"));
|
||||
|
||||
expect(wrappedFetch).toHaveBeenCalledTimes(1);
|
||||
const [forwardedRequest] = wrappedFetch.mock.calls[0] as [Request];
|
||||
expect(forwardedRequest.headers.get(TRUSTED_CLIENT_ADDRESS_HEADER)).toBe("203.0.113.9");
|
||||
});
|
||||
|
||||
it("overwrites — never appends to — a caller-supplied copy of the internal header", async () => {
|
||||
const resolver = createClientAddressResolver({ mode: "direct" });
|
||||
const wrappedFetch = vi.fn().mockResolvedValue(new Response("ok"));
|
||||
const wrapped = wrapFetchWithClientAddress(wrappedFetch, resolver);
|
||||
|
||||
const request = new Request("https://example.test/api/auth/sign-in/email", {
|
||||
headers: { [TRUSTED_CLIENT_ADDRESS_HEADER]: "203.0.113.250" },
|
||||
});
|
||||
await wrapped(request, fakeEnv("192.0.2.1"));
|
||||
|
||||
const [forwardedRequest] = wrappedFetch.mock.calls[0] as [Request];
|
||||
expect(forwardedRequest.headers.get(TRUSTED_CLIENT_ADDRESS_HEADER)).toBe("192.0.2.1");
|
||||
// A single value, never a comma-joined "caller-value, resolved-value" — set(), not append().
|
||||
expect(forwardedRequest.headers.get(TRUSTED_CLIENT_ADDRESS_HEADER)).not.toContain(",");
|
||||
});
|
||||
|
||||
it("returns a sanitized 503 and never invokes the wrapped callback when resolution fails", async () => {
|
||||
const resolver = createClientAddressResolver({ mode: "direct" });
|
||||
const wrappedFetch = vi.fn().mockResolvedValue(new Response("should not be reached"));
|
||||
const wrapped = wrapFetchWithClientAddress(wrappedFetch, resolver);
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
const response = await wrapped(
|
||||
new Request("https://example.test/api/auth/sign-in/email"),
|
||||
fakeEnv(undefined),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toEqual({ code: "CLIENT_ADDRESS_UNRESOLVED" });
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(wrappedFetch).not.toHaveBeenCalled();
|
||||
expect(errorSpy).toHaveBeenCalledWith(CLIENT_ADDRESS_UNRESOLVED_LOG_MESSAGE);
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("never logs the peer address, headers, or request path on a resolution failure", async () => {
|
||||
const resolver = createClientAddressResolver({ mode: "direct" });
|
||||
const wrapped = wrapFetchWithClientAddress(vi.fn(), resolver);
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
await wrapped(
|
||||
new Request("https://example.test/api/auth/sign-in/email", {
|
||||
headers: { cookie: "super-secret-session", "x-forwarded-for": "203.0.113.1" },
|
||||
}),
|
||||
fakeEnv("not-a-valid-ip"),
|
||||
);
|
||||
|
||||
const emitted = errorSpy.mock.calls.flat().map(String).join("\n");
|
||||
expect(emitted).not.toContain("super-secret-session");
|
||||
expect(emitted).not.toContain("203.0.113.1");
|
||||
expect(emitted).not.toContain("sign-in");
|
||||
expect(emitted).not.toContain("not-a-valid-ip");
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves the request method, body, and other headers unrelated to the internal address header", async () => {
|
||||
const resolver = createClientAddressResolver({ mode: "direct" });
|
||||
const wrappedFetch = vi.fn().mockResolvedValue(new Response("ok"));
|
||||
const wrapped = wrapFetchWithClientAddress(wrappedFetch, resolver);
|
||||
|
||||
const request = new Request("https://example.test/api/auth/sign-up/email", {
|
||||
body: JSON.stringify({ email: "[email protected]" }),
|
||||
headers: { "content-type": "application/json", origin: "https://dashboard.example.test" },
|
||||
method: "POST",
|
||||
});
|
||||
await wrapped(request, fakeEnv("192.0.2.1"));
|
||||
|
||||
const [forwardedRequest] = wrappedFetch.mock.calls[0] as [Request];
|
||||
expect(forwardedRequest.method).toBe("POST");
|
||||
expect(forwardedRequest.headers.get("origin")).toBe("https://dashboard.example.test");
|
||||
expect(await forwardedRequest.text()).toBe(JSON.stringify({ email: "[email protected]" }));
|
||||
});
|
||||
});
|
||||
@@ -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:[email protected]:5432/ekklesia?${parameter}`,
|
||||
},
|
||||
{ environment: "production" },
|
||||
);
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured, `expected ${parameter} to be rejected`).toMatchObject({
|
||||
code: "DATABASE_TLS_CONNECTION_STRING_CONFLICT",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("never leaks the database URL, credentials, host, or CA material through a transport error", () => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createApiRuntimeConfig(
|
||||
{
|
||||
...validInput(),
|
||||
databaseTransport: { certificateAuthority: "-----BEGIN CERTIFICATE-----SECRET" },
|
||||
databaseUrl:
|
||||
"postgresql://admin:[email protected]:5432/ekklesia?sslmode=disable",
|
||||
},
|
||||
{ environment: "production" },
|
||||
);
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
const publicText = `${String(captured)}\n${
|
||||
captured instanceof Error ? (captured.stack ?? "") : ""
|
||||
}\n${JSON.stringify(captured)}`;
|
||||
expect(publicText).not.toContain("hunter2");
|
||||
expect(publicText).not.toContain("db.internal.example");
|
||||
expect(publicText).not.toContain("SECRET");
|
||||
expect(publicText).not.toContain("sslmode");
|
||||
});
|
||||
});
|
||||
|
||||
it("never leaks the database URL, secret, or trusted origins through a thrown error", () => {
|
||||
const hostileUrl = "postgresql://admin:[email protected]:5432/ekklesia";
|
||||
let captured: unknown;
|
||||
|
||||
@@ -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<AuthorizationStore> = {}): 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<AuthorizationStore> = {}): 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<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (enqueued > CORE_MUTATION_MAX_BODY_BYTES) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
enqueued += chunk.length;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/grants", {
|
||||
body: stream,
|
||||
duplex: "half",
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
expect(store.createGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not read an oversized body from an untrusted Origin: the trusted-origin rejection wins first", async () => {
|
||||
const store = fakeStore();
|
||||
const { app } = buildApp({ store });
|
||||
const chunk = new Uint8Array(1024).fill(97);
|
||||
let pullCount = 0;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
pullCount += 1;
|
||||
if (pullCount > 3) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/grants", {
|
||||
body: stream,
|
||||
duplex: "half",
|
||||
headers: { "content-type": "application/json", origin: untrustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
expect(store.createGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("also bounds the revoke mutation body", async () => {
|
||||
const store = fakeStore();
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
|
||||
body: "a".repeat(CORE_MUTATION_MAX_BODY_BYTES + 1),
|
||||
headers: {
|
||||
"content-length": String(CORE_MUTATION_MAX_BODY_BYTES + 1),
|
||||
origin: trustedOrigin,
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(store.revokeGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Pass 3 request-contract regressions. Every case here asserts both the sanitized public response
|
||||
* *and* that the store was never reached, because the point of each fix is that a malformed or
|
||||
* unsupported request is decided at the request boundary rather than turning into database work or
|
||||
* an indistinguishable infrastructure failure.
|
||||
*/
|
||||
describe("explicit-grant request invariants", () => {
|
||||
const validExpiry = "2099-01-01T00:00:00.000Z";
|
||||
|
||||
async function post(app: Hono, body: unknown) {
|
||||
return await app.request("/api/v1/grants", {
|
||||
body: JSON.stringify(body),
|
||||
headers: { "content-type": "application/json", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
it("rejects a scope field in any form, before any store mutation", async () => {
|
||||
// Core keeps scoped-grant semantics for trusted internal callers, but no HTTP middleware
|
||||
// supplies a scope to the evaluator, so a scope accepted here could never authorize anything.
|
||||
for (const scope of ["calendar-event:123", "", " ", null, 42, {}, []]) {
|
||||
const { app, store } = buildApp({});
|
||||
const response = await post(app, { permission: registeredPermission, scope, subjectUserId });
|
||||
|
||||
expect(response.status, `scope ${JSON.stringify(scope)}`).toBe(400);
|
||||
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(store.createGrant).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts exactly one canonical expiration representation", async () => {
|
||||
const createdGrant = {
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
expiresAt: new Date(validExpiry),
|
||||
id: grantId,
|
||||
permission: registeredPermission,
|
||||
sourceKind: "explicit" as const,
|
||||
userId: subjectUserId,
|
||||
};
|
||||
const store = fakeStore({ createGrant: vi.fn().mockResolvedValue(createdGrant) });
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const response = await post(app, {
|
||||
expiresAt: validExpiry,
|
||||
permission: registeredPermission,
|
||||
subjectUserId,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(store.createGrant).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ expiresAt: new Date(validExpiry) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects normalized-impossible, locale, offset, imprecise, padded, and past expirations", async () => {
|
||||
const now = new Date("2026-07-26T12:00:00.000Z");
|
||||
for (const expiresAt of [
|
||||
// Normalized impossible date: `new Date` silently rewrote this to March 3rd.
|
||||
"2026-02-31T00:00:00.000Z",
|
||||
"2026-13-01T00:00:00.000Z",
|
||||
// Locale-shaped and date-only forms.
|
||||
"March 5, 2027",
|
||||
"2027-01-01",
|
||||
// Missing milliseconds, excess fractional precision, offsets, lowercase separators.
|
||||
"2027-01-01T00:00:00Z",
|
||||
"2027-01-01T00:00:00.123456Z",
|
||||
"2027-01-01T00:00:00.000+02:00",
|
||||
"2027-01-01t00:00:00.000z",
|
||||
"2027-01-01T00:00:00.000+00:00",
|
||||
// Whitespace padding and trailing text.
|
||||
" 2027-01-01T00:00:00.000Z",
|
||||
"2027-01-01T00:00:00.000Z ",
|
||||
"2027-01-01T00:00:00.000Z extra",
|
||||
// Past and exactly-now are both dead on arrival.
|
||||
"2020-01-01T00:00:00.000Z",
|
||||
"2026-07-26T12:00:00.000Z",
|
||||
// Non-strings.
|
||||
42,
|
||||
null,
|
||||
true,
|
||||
]) {
|
||||
const { app, store } = buildApp({ now: () => now });
|
||||
const response = await post(app, {
|
||||
expiresAt,
|
||||
permission: registeredPermission,
|
||||
subjectUserId,
|
||||
});
|
||||
|
||||
expect(response.status, `expiresAt ${JSON.stringify(expiresAt)}`).toBe(400);
|
||||
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
|
||||
expect(store.createGrant).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a sanitized 409 for an equivalent grant that is already effective", async () => {
|
||||
const store = fakeStore({
|
||||
createGrant: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_ALREADY_EXISTS")),
|
||||
});
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const response = await post(app, { permission: registeredPermission, subjectUserId });
|
||||
const raw = await response.text();
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
expect(JSON.parse(raw)).toEqual({ code: "AUTHORIZATION_GRANT_ALREADY_EXISTS" });
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
// The body is the stable code and nothing else — no existing grant id, actor, scope, source
|
||||
// reference, expiry, SQL, constraint name, or driver text.
|
||||
expect(Object.keys(JSON.parse(raw))).toEqual(["code"]);
|
||||
for (const leaked of [
|
||||
grantId,
|
||||
manager.userId,
|
||||
subjectUserId,
|
||||
"calendar-event",
|
||||
"constraint",
|
||||
"select ",
|
||||
"insert ",
|
||||
"ekklesia_core_grant",
|
||||
"Error",
|
||||
"at ",
|
||||
]) {
|
||||
expect(raw, `must not leak ${leaked}`).not.toContain(leaked);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a non-canonical grant id on revoke without ever calling the store", async () => {
|
||||
for (const raw of [
|
||||
"not-a-uuid",
|
||||
"0B36B1B6-9A29-4C4E-9E2D-4C0E4C5E6F70",
|
||||
"%200b36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f70",
|
||||
"0b36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f7",
|
||||
"0b36b1b6-9a29-4c4e-9e2d-4c0e4c5e6f700",
|
||||
"0b36b1b69a294c4e9e2d4c0e4c5e6f70",
|
||||
]) {
|
||||
const { app, store } = buildApp({});
|
||||
const response = await app.request(`/api/v1/grants/${raw}/revoke`, {
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status, `grantId ${raw}`).toBe(400);
|
||||
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(store.revokeGrant).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("still returns the sanitized 404 for a canonical but unknown grant id", async () => {
|
||||
const store = fakeStore({
|
||||
revokeGrant: vi
|
||||
.fn()
|
||||
.mockRejectedValue(new AuthorizationStoreError("AUTHORIZATION_GRANT_NOT_FOUND")),
|
||||
});
|
||||
const { app } = buildApp({ store });
|
||||
|
||||
const response = await app.request(`/api/v1/grants/${grantId}/revoke`, {
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toEqual({ code: "AUTHORIZATION_GRANT_NOT_FOUND" });
|
||||
expect(store.revokeGrant).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("explicit-grant listing pagination", () => {
|
||||
function grantAt(index: number, iso: string) {
|
||||
return {
|
||||
createdAt: new Date(iso),
|
||||
id: parseGrantId(`00000000-0000-4000-8000-${String(index).padStart(12, "0")}`),
|
||||
permission: registeredPermission,
|
||||
sourceKind: "explicit" as const,
|
||||
userId: subjectUserId,
|
||||
};
|
||||
}
|
||||
|
||||
it("applies the default limit of 50 and accepts the 1 and 100 boundaries", async () => {
|
||||
for (const [query, expected] of [
|
||||
["", 50],
|
||||
["&limit=1", 1],
|
||||
["&limit=50", 50],
|
||||
["&limit=100", 100],
|
||||
] as const) {
|
||||
const { app, store } = buildApp({});
|
||||
const response = await app.request(`/api/v1/grants?userId=${subjectUserId}${query}`);
|
||||
|
||||
expect(response.status, query).toBe(200);
|
||||
expect(store.listGrantsForUser).toHaveBeenCalledWith(subjectUserId, {
|
||||
limit: expected,
|
||||
sourceKind: "explicit",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects every non-canonical or out-of-range limit before any store work", async () => {
|
||||
for (const limit of [
|
||||
"0",
|
||||
"101",
|
||||
"-1",
|
||||
"+5",
|
||||
"1.0",
|
||||
"1.5",
|
||||
"01",
|
||||
"005",
|
||||
" 5",
|
||||
"5 ",
|
||||
"1e2",
|
||||
"",
|
||||
"abc",
|
||||
"0x10",
|
||||
"Infinity",
|
||||
"NaN",
|
||||
]) {
|
||||
const { app, store } = buildApp({});
|
||||
const response = await app.request(
|
||||
`/api/v1/grants?userId=${subjectUserId}&limit=${encodeURIComponent(limit)}`,
|
||||
);
|
||||
|
||||
expect(response.status, `limit ${JSON.stringify(limit)}`).toBe(400);
|
||||
expect(await response.json()).toEqual({ code: "INVALID_REQUEST" });
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(store.listGrantsForUser).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown and duplicated query keys", async () => {
|
||||
for (const query of [
|
||||
`userId=${subjectUserId}&sort=asc`,
|
||||
`userId=${subjectUserId}&offset=10`,
|
||||
`userId=${subjectUserId}&sourceKind=relationship`,
|
||||
`userId=${subjectUserId}&userId=${subjectUserId}`,
|
||||
`userId=${subjectUserId}&limit=5&limit=6`,
|
||||
`userId=${subjectUserId}&cursor=a&cursor=b`,
|
||||
`userId=${subjectUserId}&cursor=`,
|
||||
]) {
|
||||
const { app, store } = buildApp({});
|
||||
const response = await app.request(`/api/v1/grants?${query}`);
|
||||
|
||||
expect(response.status, query).toBe(400);
|
||||
expect(store.listGrantsForUser).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("emits a cursor only when another page exists, and traverses without duplicates or omissions", async () => {
|
||||
// Three rows sharing one `created_at`, which is exactly the case a timestamp-only keyset
|
||||
// boundary would skip or repeat.
|
||||
const sameInstant = "2026-01-01T00:00:00.000Z";
|
||||
const all = [grantAt(3, sameInstant), grantAt(2, sameInstant), grantAt(1, sameInstant)];
|
||||
|
||||
const seen: string[] = [];
|
||||
let cursor: string | null = null;
|
||||
let pages = 0;
|
||||
do {
|
||||
const store = fakeStore({
|
||||
listGrantsForUser: vi.fn().mockImplementation((_userId, listOptions) => {
|
||||
const startIndex =
|
||||
listOptions.after === undefined
|
||||
? 0
|
||||
: all.findIndex((grant) => grant.id === listOptions.after.id) + 1;
|
||||
const slice = all.slice(startIndex, startIndex + listOptions.limit);
|
||||
const more = startIndex + listOptions.limit < all.length;
|
||||
const last = slice.at(-1);
|
||||
return Promise.resolve({
|
||||
grants: slice,
|
||||
...(more && last ? { nextBoundary: { createdAt: last.createdAt, id: last.id } } : {}),
|
||||
});
|
||||
}),
|
||||
});
|
||||
const { app } = buildApp({ store });
|
||||
const url: string = `/api/v1/grants?userId=${subjectUserId}&limit=1${
|
||||
cursor === null ? "" : `&cursor=${encodeURIComponent(cursor)}`
|
||||
}`;
|
||||
const response = await app.request(url);
|
||||
const body = (await response.json()) as {
|
||||
grants: { id: string }[];
|
||||
nextCursor: string | null;
|
||||
};
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(body.grants).toHaveLength(1);
|
||||
seen.push(body.grants[0]?.id as string);
|
||||
cursor = body.nextCursor;
|
||||
pages += 1;
|
||||
} while (cursor !== null && pages < 10);
|
||||
|
||||
expect(pages).toBe(3);
|
||||
expect(seen).toEqual(all.map((grant) => grant.id));
|
||||
expect(new Set(seen).size).toBe(3);
|
||||
// The final page ends the traversal explicitly.
|
||||
expect(cursor).toBeNull();
|
||||
});
|
||||
|
||||
it("refuses a cursor issued for a different subject, and every malformed encoding", async () => {
|
||||
const boundary = { createdAt: new Date("2026-01-01T00:00:00.000Z"), id: grantId };
|
||||
const store = fakeStore({
|
||||
listGrantsForUser: vi.fn().mockResolvedValue({
|
||||
grants: [grantAt(1, boundary.createdAt.toISOString())],
|
||||
nextBoundary: boundary,
|
||||
}),
|
||||
});
|
||||
const { app: issuingApp } = buildApp({ store });
|
||||
const issued = (await (
|
||||
await issuingApp.request(`/api/v1/grants?userId=${subjectUserId}&limit=1`)
|
||||
).json()) as { nextCursor: string };
|
||||
expect(issued.nextCursor).toMatch(/^[A-Za-z0-9_-]+$/u);
|
||||
|
||||
const encode = (payload: unknown) =>
|
||||
Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
||||
const canonical = {
|
||||
g: grantId,
|
||||
t: "2026-01-01T00:00:00.000Z",
|
||||
u: subjectUserId,
|
||||
v: 1,
|
||||
};
|
||||
|
||||
for (const [label, cursor] of [
|
||||
// A cursor minted for a different subject must not be replayable against this one.
|
||||
["other subject", encode({ ...canonical, u: "someone_else_1" })],
|
||||
["wrong version", encode({ ...canonical, v: 2 })],
|
||||
["extra field", encode({ ...canonical, extra: "x" })],
|
||||
["missing field", encode({ g: canonical.g, t: canonical.t, u: canonical.u })],
|
||||
["invalid timestamp", encode({ ...canonical, t: "2026-01-01" })],
|
||||
["non-canonical timestamp", encode({ ...canonical, t: "2026-01-01T00:00:00Z" })],
|
||||
["invalid grant id", encode({ ...canonical, g: "not-a-uuid" })],
|
||||
["array payload", encode([canonical])],
|
||||
["not json", Buffer.from("nonsense", "utf8").toString("base64url")],
|
||||
["padded base64", `${Buffer.from(JSON.stringify(canonical), "utf8").toString("base64")}`],
|
||||
["malformed base64url", "!!!not-base64!!!"],
|
||||
["trailing data", `${encode(canonical)}AA`],
|
||||
] as const) {
|
||||
const { app, store: pagedStore } = buildApp({});
|
||||
const response = await app.request(
|
||||
`/api/v1/grants?userId=${subjectUserId}&cursor=${encodeURIComponent(cursor)}`,
|
||||
);
|
||||
const raw = await response.text();
|
||||
|
||||
expect(response.status, label).toBe(400);
|
||||
expect(JSON.parse(raw)).toEqual({ code: "INVALID_REQUEST" });
|
||||
// A rejected cursor reveals nothing it decoded, and does zero store/database work.
|
||||
expect(raw).not.toContain(grantId);
|
||||
expect(raw).not.toContain("someone_else_1");
|
||||
expect(pagedStore.listGrantsForUser).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
// The cursor the API itself issued is accepted.
|
||||
const { app: acceptingApp, store: acceptingStore } = buildApp({});
|
||||
const accepted = await acceptingApp.request(
|
||||
`/api/v1/grants?userId=${subjectUserId}&cursor=${encodeURIComponent(issued.nextCursor)}`,
|
||||
);
|
||||
expect(accepted.status).toBe(200);
|
||||
expect(acceptingStore.listGrantsForUser).toHaveBeenCalledWith(subjectUserId, {
|
||||
after: boundary,
|
||||
limit: 50,
|
||||
sourceKind: "explicit",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -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:[email protected]:5432/ekklesia",
|
||||
host: "127.0.0.1",
|
||||
identity: {
|
||||
baseUrl: "https://identity.example.test",
|
||||
publicSignupEnabled: true,
|
||||
secret: "correct-horse-battery-staple-0123456789",
|
||||
trustedOrigins: ["https://identity.example.test"],
|
||||
},
|
||||
port: 0,
|
||||
},
|
||||
{ environment: "production" },
|
||||
);
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toMatchObject({ code: "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION" });
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
expect(betterAuthMock).not.toHaveBeenCalled();
|
||||
expect(serveMock).not.toHaveBeenCalled();
|
||||
expect(poolEnd).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuses an unsafe production database transport before any pool, Better Auth instance, module runtime, or server exists", async () => {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
mockSuccessfulServe();
|
||||
|
||||
// Both an explicit production plaintext selection and a URL that would silently downgrade the
|
||||
// policy are rejected in `createApiRuntimeConfig` — the same boundary the executable entry
|
||||
// point calls before `startApi` — so no runtime resource is ever constructed to leak.
|
||||
for (const [label, input] of [
|
||||
[
|
||||
"explicit production plaintext",
|
||||
{
|
||||
databaseTransport: { mode: "disable" },
|
||||
databaseUrl: "postgresql://ekklesia:[email protected]:5432/ekklesia",
|
||||
},
|
||||
],
|
||||
[
|
||||
"connection-string TLS downgrade",
|
||||
{
|
||||
databaseUrl:
|
||||
"postgresql://ekklesia:[email protected]:5432/ekklesia?sslmode=disable",
|
||||
},
|
||||
],
|
||||
] as const) {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createApiRuntimeConfig(
|
||||
{
|
||||
host: "127.0.0.1",
|
||||
identity: {
|
||||
baseUrl: "https://identity.example.test",
|
||||
secret: "correct-horse-battery-staple-0123456789",
|
||||
trustedOrigins: ["https://identity.example.test"],
|
||||
},
|
||||
port: 0,
|
||||
...input,
|
||||
},
|
||||
{ environment: "production" },
|
||||
);
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured, `expected ${label} to be rejected`).toBeInstanceOf(Error);
|
||||
expect(poolConstructor).not.toHaveBeenCalled();
|
||||
expect(betterAuthMock).not.toHaveBeenCalled();
|
||||
expect(serveMock).not.toHaveBeenCalled();
|
||||
expect(poolEnd).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("closes an already-created database pool when the server fails to bind its listen address", async () => {
|
||||
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<typeof vi.fn>;
|
||||
write: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
}
|
||||
|
||||
async function startAndCaptureClientErrorHandler() {
|
||||
const poolEnd = vi.fn().mockResolvedValue(undefined);
|
||||
poolConstructor.mockImplementation(function fakePool() {
|
||||
return { end: poolEnd, on: vi.fn() };
|
||||
});
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
const { clientErrorHandler } = mockSuccessfulServe();
|
||||
const running = await startApi(validConfig());
|
||||
const handler = clientErrorHandler();
|
||||
if (!handler) {
|
||||
throw new Error("Expected startApi to register a clientError handler.");
|
||||
}
|
||||
return { handler, running };
|
||||
}
|
||||
|
||||
it("reproduces Node's own client-error responses byte-for-byte, since registering a listener suppresses them", async () => {
|
||||
const { handler, running } = await startAndCaptureClientErrorHandler();
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
for (const [code, expected] of [
|
||||
["ERR_HTTP_REQUEST_TIMEOUT", "HTTP/1.1 408 Request Timeout\r\nConnection: close\r\n\r\n"],
|
||||
[
|
||||
"HPE_HEADER_OVERFLOW",
|
||||
"HTTP/1.1 431 Request Header Fields Too Large\r\nConnection: close\r\n\r\n",
|
||||
],
|
||||
["HPE_INVALID_METHOD", "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"],
|
||||
[undefined, "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"],
|
||||
] as const) {
|
||||
const socket = fakeErrorSocket();
|
||||
const error = Object.assign(new Error("parser detail that must never be logged"), { code });
|
||||
handler(error, socket);
|
||||
|
||||
expect(socket.write).toHaveBeenCalledTimes(1);
|
||||
expect(socket.write).toHaveBeenCalledWith(expected);
|
||||
expect(socket.destroy).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
await running.shutdown();
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("never writes a second response when a handler already began responding, but still closes the socket", async () => {
|
||||
const { handler, running } = await startAndCaptureClientErrorHandler();
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
const socket = fakeErrorSocket({ headersSent: true });
|
||||
handler(Object.assign(new Error("timeout"), { code: "ERR_HTTP_REQUEST_TIMEOUT" }), socket);
|
||||
|
||||
expect(socket.write).not.toHaveBeenCalled();
|
||||
expect(socket.destroy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// An unwritable socket is likewise never written to, only destroyed.
|
||||
const closedSocket = fakeErrorSocket({ writable: false });
|
||||
handler(
|
||||
Object.assign(new Error("timeout"), { code: "ERR_HTTP_REQUEST_TIMEOUT" }),
|
||||
closedSocket,
|
||||
);
|
||||
expect(closedSocket.write).not.toHaveBeenCalled();
|
||||
expect(closedSocket.destroy).toHaveBeenCalledTimes(1);
|
||||
|
||||
await running.shutdown();
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("logs one fixed sanitized line per client error, never the caught error's own message", async () => {
|
||||
const { handler, running } = await startAndCaptureClientErrorHandler();
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
handler(
|
||||
Object.assign(new Error("Parse Error: /secret-path?token=abc123"), {
|
||||
code: "HPE_INVALID_METHOD",
|
||||
}),
|
||||
fakeErrorSocket(),
|
||||
);
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledTimes(1);
|
||||
const emitted = errorSpy.mock.calls.flat().map(String).join("\n");
|
||||
expect(emitted).not.toContain("secret-path");
|
||||
expect(emitted).not.toContain("abc123");
|
||||
expect(emitted).not.toContain("Parse Error");
|
||||
expect(emitted).not.toContain("HPE_");
|
||||
expect(emitted).not.toMatch(/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/u);
|
||||
|
||||
await running.shutdown();
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ekklesia API module permission composition at startup", () => {
|
||||
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());
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -219,6 +219,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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.
|
||||
|
||||
@@ -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<RegisteredModuleHttpRoute, "maxBodyBytes"> & { 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<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
maxBodyBytes: 0,
|
||||
method: "POST",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: writePermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/events", {
|
||||
body: "a",
|
||||
headers: { "content-length": "1", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a non-empty streamed body on a route declaring maxBodyBytes: 0", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
maxBodyBytes: 0,
|
||||
method: "POST",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: writePermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode("a"));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/events", {
|
||||
body: stream,
|
||||
duplex: "half",
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts a genuinely empty body on a route declaring maxBodyBytes: 0", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
maxBodyBytes: 0,
|
||||
method: "POST",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: writePermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/events", {
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("accepts a body exactly at an explicit maxBodyBytes limit and rejects one byte over", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
const limit = 32;
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
maxBodyBytes: limit,
|
||||
method: "POST",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: writePermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const atLimit = "a".repeat(limit);
|
||||
const atLimitResponse = await app.request("/api/v1/modules/calendar/events", {
|
||||
body: atLimit,
|
||||
headers: { "content-length": String(limit), origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
expect(atLimitResponse.status).toBe(200);
|
||||
|
||||
const overLimit = "a".repeat(limit + 1);
|
||||
const overLimitResponse = await app.request("/api/v1/modules/calendar/events", {
|
||||
body: overLimit,
|
||||
headers: { "content-length": String(limit + 1), origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
expect(overLimitResponse.status).toBe(413);
|
||||
expect(await overLimitResponse.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects an oversized streamed body on a route with an explicit limit, with no declared Content-Length", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
const limit = 64;
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
maxBodyBytes: limit,
|
||||
method: "POST",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: writePermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const chunk = new Uint8Array(16).fill(97);
|
||||
let enqueued = 0;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (enqueued > limit) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
enqueued += chunk.length;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/events", {
|
||||
body: stream,
|
||||
duplex: "half",
|
||||
headers: { origin: trustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("enforces the body limit before authorization runs, so an oversized denied request never reaches the evaluator", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
const evaluator: AuthorizationEvaluator = {
|
||||
authorize: vi.fn().mockResolvedValue({ allowed: false }),
|
||||
};
|
||||
const { app } = buildModuleApp({
|
||||
evaluator,
|
||||
registry: permissionRegistry([{ id: writePermission, baseline: "restricted" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
maxBodyBytes: 8,
|
||||
method: "POST",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: writePermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/events", {
|
||||
body: "a".repeat(9),
|
||||
headers: { "content-length": "9", origin: trustedOrigin },
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(413);
|
||||
expect(await response.json()).toEqual({ code: "REQUEST_BODY_TOO_LARGE" });
|
||||
expect(evaluator.authorize).not.toHaveBeenCalled();
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never reads an oversized body from an untrusted Origin: the trusted-origin rejection wins first", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: writePermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
maxBodyBytes: 8,
|
||||
method: "POST",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: writePermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const chunk = new Uint8Array(16).fill(97);
|
||||
let pullCount = 0;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
pullCount += 1;
|
||||
if (pullCount > 3) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/events", {
|
||||
body: stream,
|
||||
duplex: "half",
|
||||
headers: { origin: untrustedOrigin },
|
||||
method: "POST",
|
||||
} as RequestInit);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(await response.json()).toEqual({ code: "UNTRUSTED_ORIGIN" });
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies the route's own declared limit to a GET route too", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
maxBodyBytes: 0,
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/events",
|
||||
permission: readPermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/events");
|
||||
expect(response.status).toBe(200);
|
||||
expect(handler).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("module routes and the rest of the API", () => {
|
||||
it("does not invoke a handler for an unmatched module path", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => Response.json({ ok: true }));
|
||||
@@ -1192,3 +1455,153 @@ describe("module routes and the rest of the API", () => {
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Pass 4: the decoded-parameter boundary and the routing ambiguity that made it dangerous.
|
||||
*
|
||||
* These drive the pinned Hono matcher with real requests rather than asserting what a mock returns,
|
||||
* because the whole point is what the real router actually hands a module handler.
|
||||
*/
|
||||
describe("module route parameters are decoded, untrusted input", () => {
|
||||
function paramsApp() {
|
||||
const seen: Array<Readonly<Record<string, string>>> = [];
|
||||
const handler = vi.fn<ModuleRouteHandler>(async (_request, context) => {
|
||||
seen.push(context.params);
|
||||
return Response.json({ id: context.params.id });
|
||||
});
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/items/:id",
|
||||
permission: readPermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
return { app, handler, seen };
|
||||
}
|
||||
|
||||
it.each([
|
||||
// A single encoded segment can carry characters the declaration grammar forbids outright.
|
||||
["an encoded path separator and dot segments", "%2F..%2Fadmin", "/../admin"],
|
||||
["an encoded backslash", "a%5Cb", "a\\b"],
|
||||
["an embedded NUL", "a%00b", "a\u0000b"],
|
||||
["encoded CR/LF", "a%0D%0Ab", "a\r\nb"],
|
||||
["a percent sign", "100%25", "100%"],
|
||||
["non-ASCII text", "%C3%A9v%C3%A9nement", "événement"],
|
||||
["a statement separator", "1%3B%20drop", "1; drop"],
|
||||
])("delivers %s decoded, exactly as the router produced it", async (_label, encoded, decoded) => {
|
||||
const { app, seen } = paramsApp();
|
||||
|
||||
const response = await app.request(`/api/v1/modules/calendar/items/${encoded}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ id: decoded });
|
||||
// The declared path grammar (`/items/:id`) permits none of these characters — validating the
|
||||
// *declaration* says nothing about what a request may send, which is exactly why a handler
|
||||
// must validate the decoded value against its own domain grammar before using it.
|
||||
expect(seen[0]?.id).toBe(decoded);
|
||||
expect(Object.isFrozen(seen[0])).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps parameter delivery behind authorization", async () => {
|
||||
const handler = vi.fn<ModuleRouteHandler>(async () => new Response("never"));
|
||||
const { app, evaluator } = buildModuleApp({
|
||||
evaluator: { authorize: vi.fn().mockResolvedValue({ allowed: false }) },
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "restricted" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/items/:id",
|
||||
permission: readPermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const response = await app.request("/api/v1/modules/calendar/items/%2F..%2Fadmin");
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(evaluator.authorize).toHaveBeenCalled();
|
||||
// An unauthorized caller can never observe how the handler would treat a parameter, because
|
||||
// the handler never runs.
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets a handler reject a decoded value against its own domain grammar", async () => {
|
||||
// The platform supplies no generic parameter validator; this is the module's own check, which
|
||||
// is exactly the documented contract.
|
||||
const canonicalId = /^[0-9a-f]{8}$/u;
|
||||
const handler = vi.fn<ModuleRouteHandler>(async (_request, context) => {
|
||||
const id = context.params.id;
|
||||
if (id === undefined || !canonicalId.test(id)) {
|
||||
return Response.json({ code: "INVALID_REQUEST" }, { status: 400 });
|
||||
}
|
||||
return Response.json({ id });
|
||||
});
|
||||
const { app } = buildModuleApp({
|
||||
registry: permissionRegistry([{ id: readPermission, baseline: "public" }]),
|
||||
routes: [
|
||||
moduleRoute({
|
||||
handler,
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/items/:id",
|
||||
permission: readPermission,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect((await app.request("/api/v1/modules/calendar/items/deadbeef")).status).toBe(200);
|
||||
const rejected = await app.request("/api/v1/modules/calendar/items/%2F..%2Fadmin");
|
||||
expect(rejected.status).toBe(400);
|
||||
expect(await rejected.json()).toEqual({ code: "INVALID_REQUEST" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("module route overlap can never reach mounting", () => {
|
||||
it("would otherwise let registration order choose the governing permission", async () => {
|
||||
// Both orders are mounted directly here — bypassing the runtime rejection — to show precisely
|
||||
// what `MODULE_ROUTE_OVERLAP` prevents: the same request reaching a different handler *and* a
|
||||
// different permission depending only on the order the routes were registered in.
|
||||
const createPermission = parsePermissionId("calendar.events.create");
|
||||
const byId = vi.fn<ModuleRouteHandler>(async () => Response.json({ handler: "byId" }));
|
||||
const create = vi.fn<ModuleRouteHandler>(async () => Response.json({ handler: "new" }));
|
||||
const registry = permissionRegistry([
|
||||
{ id: readPermission, baseline: "public" },
|
||||
{ id: createPermission, baseline: "public" },
|
||||
]);
|
||||
const paramRoute = moduleRoute({
|
||||
handler: byId,
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/events/:id",
|
||||
permission: readPermission,
|
||||
});
|
||||
const staticRoute = moduleRoute({
|
||||
handler: create,
|
||||
method: "GET",
|
||||
moduleId: "calendar",
|
||||
path: "/events/new",
|
||||
permission: createPermission,
|
||||
});
|
||||
|
||||
const parameterFirst = buildModuleApp({ registry, routes: [paramRoute, staticRoute] });
|
||||
const staticFirst = buildModuleApp({ registry, routes: [staticRoute, paramRoute] });
|
||||
|
||||
expect(
|
||||
await (await parameterFirst.app.request("/api/v1/modules/calendar/events/new")).json(),
|
||||
).toEqual({ handler: "byId" });
|
||||
expect(
|
||||
await (await staticFirst.app.request("/api/v1/modules/calendar/events/new")).json(),
|
||||
).toEqual({ handler: "new" });
|
||||
|
||||
// Core rejects this pair during staged registration, so no such snapshot can ever be published
|
||||
// to `mountModuleHttpRoutes` — see `core/tests/module-runtime.test.ts`.
|
||||
expect(moduleRoutesOverlap(paramRoute, staticRoute)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE "ekklesia_core_platform_owner_bootstrap" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"consumed_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"consumed_for_user_id" text,
|
||||
CONSTRAINT "ekklesia_core_platform_owner_bootstrap_singleton_check" CHECK ("ekklesia_core_platform_owner_bootstrap"."id" = 'singleton')
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "ekklesia_core_authorization_audit" DROP CONSTRAINT "ekklesia_core_authorization_audit_subject_user_id_ekklesia_core_user_id_fk";
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "ekklesia_core_platform_owner_bootstrap" ("id")
|
||||
SELECT 'singleton'
|
||||
WHERE EXISTS (SELECT 1 FROM "ekklesia_core_platform_owner")
|
||||
ON CONFLICT ("id") DO NOTHING;
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,12 @@
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "0002_authorization_integrity",
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof authorizationSchema>;
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
|
||||
+296
-31
@@ -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<Record<AuthorizationStoreErrorCode, string>> = Object.freeze({
|
||||
AUTHORIZATION_GRANT_ALREADY_EXISTS:
|
||||
"An equivalent authorization grant is already effective for this user.",
|
||||
AUTHORIZATION_GRANT_CREATE_FAILED: "The authorization grant could not be created.",
|
||||
AUTHORIZATION_GRANT_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<Record<AuthorizationStoreErrorCode, string>>
|
||||
"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<GrantRecord>;
|
||||
revokeGrant(input: RevokeGrantInput): Promise<void>;
|
||||
/** 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<readonly GrantRecord[]>;
|
||||
/** 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<GrantPage>;
|
||||
grantPlatformOwner(userId: EkklesiaUserId, actor: AuthorizationActor): Promise<void>;
|
||||
revokePlatformOwner(userId: EkklesiaUserId, actor: AuthorizationActor): Promise<void>;
|
||||
}
|
||||
@@ -160,11 +196,141 @@ function toGrantRecord(row: GrantRow): GrantRecord {
|
||||
});
|
||||
}
|
||||
|
||||
export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): AuthorizationStore {
|
||||
/**
|
||||
* The five values that make two grant issuances the *same authorization source*: subject,
|
||||
* permission, scope, source kind, and source reference — with `NULL` equal only to `NULL`.
|
||||
* `expires_at`, creation metadata, actor metadata, and the grant id deliberately do **not**
|
||||
* distinguish them: re-issuing the identical authority with a different expiry or by a different
|
||||
* administrator is the same grant, not a second one.
|
||||
*/
|
||||
interface GrantEquivalence {
|
||||
readonly userId: string;
|
||||
readonly permissionId: string;
|
||||
readonly scope: string | null;
|
||||
readonly sourceKind: string;
|
||||
readonly sourceRef: string | null;
|
||||
}
|
||||
|
||||
function equivalenceOf(input: CreateGrantInput): GrantEquivalence {
|
||||
return {
|
||||
permissionId: input.permission,
|
||||
scope: input.scope ?? null,
|
||||
sourceKind: input.sourceKind,
|
||||
sourceRef: input.sourceRef ?? null,
|
||||
userId: input.userId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes every create/revoke touching one equivalence tuple against a transaction-scoped
|
||||
* PostgreSQL advisory lock, so concurrent callers cannot both observe "no effective equivalent"
|
||||
* and both insert. A process-local mutex would not do this — nothing constrains the number of API
|
||||
* processes — and neither would an unlocked check-then-insert.
|
||||
*
|
||||
* The key is a 64-bit hash of the tuple encoded as a JSON array, which keeps `NULL` and the empty
|
||||
* string distinct and makes separator forgery through a crafted scope or source reference
|
||||
* impossible. A hash collision is harmless by construction: it can only make two unrelated tuples
|
||||
* serialize against each other, never make a real conflict go unnoticed, because the authoritative
|
||||
* decision is always the exact SQL predicate below and never the hash.
|
||||
*
|
||||
* `pg_advisory_xact_lock(bigint)` uses PostgreSQL's *single-key* advisory-lock space, which is
|
||||
* verified to be disjoint from the two-key space used by the migration runner
|
||||
* (`core/src/migrations/postgres-database.ts`) and by platform-owner bootstrap. Grant serialization
|
||||
* therefore can never block, or be blocked by, migration or bootstrap locking. Being
|
||||
* transaction-scoped, it is released on COMMIT or ROLLBACK with no manual unlock.
|
||||
*/
|
||||
async function lockEquivalence(
|
||||
tx: Parameters<Parameters<AuthorizationDrizzleDatabase["transaction"]>[0]>[0],
|
||||
equivalence: GrantEquivalence,
|
||||
): Promise<void> {
|
||||
await tx.execute(
|
||||
sql`select pg_advisory_xact_lock(hashtextextended(jsonb_build_array(
|
||||
${equivalence.userId}::text,
|
||||
${equivalence.permissionId}::text,
|
||||
${equivalence.scope}::text,
|
||||
${equivalence.sourceKind}::text,
|
||||
${equivalence.sourceRef}::text
|
||||
)::text, 0))`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Matches exactly the rows sharing an equivalence tuple. `is not distinct from` is what gives
|
||||
* `NULL = NULL` for the two nullable members; plain `=` would silently match nothing. */
|
||||
function equivalenceCondition(equivalence: GrantEquivalence) {
|
||||
return and(
|
||||
eq(ekklesiaCoreGrant.userId, equivalence.userId),
|
||||
eq(ekklesiaCoreGrant.permissionId, equivalence.permissionId),
|
||||
sql`${ekklesiaCoreGrant.scope} is not distinct from ${equivalence.scope}::text`,
|
||||
eq(ekklesiaCoreGrant.sourceKind, equivalence.sourceKind),
|
||||
sql`${ekklesiaCoreGrant.sourceRef} is not distinct from ${equivalence.sourceRef}::text`,
|
||||
);
|
||||
}
|
||||
|
||||
/** A supplied expiration must be a real instant strictly later than the acceptance clock. A past
|
||||
* or equal-to-now expiration produces a grant that is dead the moment it is written, which is
|
||||
* never what a caller meant; rejecting it here means trusted internal callers get the same
|
||||
* guarantee the HTTP surface enforces, rather than each validating separately. */
|
||||
function assertValidExpiration(expiresAt: Date | undefined, now: Date): void {
|
||||
if (expiresAt === undefined) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!(expiresAt instanceof Date) ||
|
||||
Number.isNaN(expiresAt.getTime()) ||
|
||||
expiresAt.getTime() <= now.getTime()
|
||||
) {
|
||||
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_EXPIRATION_INVALID");
|
||||
}
|
||||
}
|
||||
|
||||
function assertValidLimit(limit: number): number {
|
||||
if (
|
||||
typeof limit !== "number" ||
|
||||
!Number.isSafeInteger(limit) ||
|
||||
limit < 1 ||
|
||||
limit > AUTHORIZATION_GRANT_LIST_MAX_LIMIT
|
||||
) {
|
||||
throw new AuthorizationStoreError("AUTHORIZATION_GRANT_LIMIT_INVALID");
|
||||
}
|
||||
return limit;
|
||||
}
|
||||
|
||||
export function createAuthorizationStore(
|
||||
db: AuthorizationDrizzleDatabase,
|
||||
options: CreateAuthorizationStoreOptions = {},
|
||||
): AuthorizationStore {
|
||||
const now = options.now ?? ((): Date => new Date());
|
||||
|
||||
return Object.freeze({
|
||||
createGrant: async (input: CreateGrantInput): Promise<GrantRecord> => {
|
||||
// Validated before any transaction opens, so a rejected expiration performs no database work
|
||||
// at all and can never leave a grant or an audit row behind.
|
||||
assertValidExpiration(input.expiresAt, now());
|
||||
const equivalence = equivalenceOf(input);
|
||||
try {
|
||||
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<void> => {
|
||||
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<readonly GrantRecord[]> => {
|
||||
listOptions: ListGrantsForUserOptions,
|
||||
): Promise<GrantPage> => {
|
||||
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");
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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<void> {
|
||||
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)) {
|
||||
|
||||
@@ -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<void> {
|
||||
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 });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<number> {
|
||||
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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<void> {
|
||||
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)) {
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./pool.js";
|
||||
export * from "./transport.js";
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import net from "node:net";
|
||||
import tls from "node:tls";
|
||||
import type { ConnectionOptions } from "node:tls";
|
||||
|
||||
export type DatabaseTransportEnvironment = "development" | "production";
|
||||
|
||||
/**
|
||||
* The two transport modes this release supports, named after the libpq/PostgreSQL vocabulary an
|
||||
* operator already knows:
|
||||
*
|
||||
* - `verify-full` — TLS is required, the server certificate must chain to a trusted CA, **and**
|
||||
* the certificate identity must match the configured database host. This is the production
|
||||
* default, and the only mode a production deployment may use.
|
||||
* - `disable` — no TLS at all. Available only for the disposable loopback PostgreSQL used by local
|
||||
* development and this repository's integration harness.
|
||||
*
|
||||
* There is deliberately no encryption-only mode (`require`/`verify-ca`) and no
|
||||
* `rejectUnauthorized: false` escape hatch: both authenticate nothing, and an unauthenticated
|
||||
* encrypted channel to an attacker-controlled endpoint is not a security property worth offering.
|
||||
*/
|
||||
export type DatabaseTlsMode = "disable" | "verify-full";
|
||||
|
||||
export type DatabaseTransportErrorCode =
|
||||
| "DATABASE_TLS_CONNECTION_STRING_CONFLICT"
|
||||
| "DATABASE_TLS_MODE_INVALID"
|
||||
| "DATABASE_TLS_REQUIRED_IN_PRODUCTION"
|
||||
| "DATABASE_URL_INVALID";
|
||||
|
||||
const errorMessages: Readonly<Record<DatabaseTransportErrorCode, string>> = Object.freeze({
|
||||
DATABASE_TLS_CONNECTION_STRING_CONFLICT:
|
||||
"The database URL must not carry TLS parameters; configure the database TLS mode explicitly.",
|
||||
DATABASE_TLS_MODE_INVALID: 'The database TLS mode must be exactly "verify-full" or "disable".',
|
||||
DATABASE_TLS_REQUIRED_IN_PRODUCTION:
|
||||
"A production database connection must use certificate-verified TLS.",
|
||||
DATABASE_URL_INVALID: "The database URL must be an absolute postgres:// or postgresql:// URL.",
|
||||
});
|
||||
|
||||
/** Never carries the connection string, host, port, database name, credentials, certificate, CA
|
||||
* material, or the offending mode value. */
|
||||
export class DatabaseTransportError extends Error {
|
||||
readonly code: DatabaseTransportErrorCode;
|
||||
|
||||
constructor(code: DatabaseTransportErrorCode) {
|
||||
super(errorMessages[code]);
|
||||
this.name = "DatabaseTransportError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
export interface DatabaseTransportPolicy {
|
||||
readonly mode: DatabaseTlsMode;
|
||||
/** PEM trust material for a private PostgreSQL CA. Optional: when omitted, Node's own trusted CA
|
||||
* set is used. Never logged, never echoed in an error. */
|
||||
readonly certificateAuthority?: string;
|
||||
}
|
||||
|
||||
export interface DatabaseTransportPolicyInput {
|
||||
readonly mode?: unknown;
|
||||
readonly certificateAuthority?: unknown;
|
||||
}
|
||||
|
||||
export interface CreateDatabaseTransportPolicyOptions {
|
||||
/** Defaults to production, so an omitted environment never silently relaxes transport security. */
|
||||
readonly environment?: DatabaseTransportEnvironment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every TLS-related parameter `[email protected]` recognizes in a PostgreSQL URL. These
|
||||
* are rejected outright rather than merged, because the pinned driver resolves them in a way that
|
||||
* silently defeats an explicit policy: `pg`'s own `ConnectionParameters` does
|
||||
* `Object.assign({}, config, parse(config.connectionString))`
|
||||
* (`pg/lib/connection-parameters.js`), so a URL parameter **overwrites** the `ssl` object a caller
|
||||
* passed alongside it. Verified directly against a live TLS PostgreSQL instance: a
|
||||
* `?sslmode=disable` URL combined with an explicit certificate-verifying `ssl` object connects in
|
||||
* plaintext.
|
||||
*
|
||||
* The pinned release additionally aliases `prefer`/`require`/`verify-ca` to full verification while
|
||||
* printing a deprecation warning that they will adopt weaker libpq semantics in `pg@9`, so even
|
||||
* a URL that looks safe today is not a durable guarantee. One typed policy is the whole contract.
|
||||
*/
|
||||
const CONNECTION_STRING_TLS_PARAMETERS = Object.freeze([
|
||||
"ssl",
|
||||
"sslcert",
|
||||
"sslkey",
|
||||
"sslmode",
|
||||
"sslnegotiation",
|
||||
"sslpassword",
|
||||
"sslrootcert",
|
||||
"uselibpqcompat",
|
||||
]);
|
||||
|
||||
function parseDatabaseUrl(databaseUrl: string): URL {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(databaseUrl);
|
||||
} catch {
|
||||
throw new DatabaseTransportError("DATABASE_URL_INVALID");
|
||||
}
|
||||
if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") {
|
||||
throw new DatabaseTransportError("DATABASE_URL_INVALID");
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/** The host a certificate's identity must match — the exact host the driver will connect to. */
|
||||
export function databaseHostFromUrl(databaseUrl: string): string {
|
||||
const url = parseDatabaseUrl(databaseUrl);
|
||||
// `URL.hostname` keeps IPv6 literals bracketed; TLS identity checking wants the bare address.
|
||||
const hostname = url.hostname;
|
||||
return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
|
||||
}
|
||||
|
||||
/** Whether the database endpoint is an unambiguous loopback literal. Used only to decide whether
|
||||
* the development plaintext mode is even permissible; it is never a production allowance. */
|
||||
function isLoopbackDatabaseHost(host: string): boolean {
|
||||
const normalized = host.toLowerCase();
|
||||
if (normalized === "localhost" || normalized.endsWith(".localhost")) {
|
||||
return true;
|
||||
}
|
||||
if (net.isIPv4(normalized)) {
|
||||
return normalized.startsWith("127.");
|
||||
}
|
||||
if (net.isIPv6(normalized)) {
|
||||
return normalized === "::1" || normalized === "::ffff:127.0.0.1";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the typed transport policy. Parsing is deliberately separate from environment policy,
|
||||
* matching the convention the public-signup gate already established: a malformed mode is reported
|
||||
* as malformed in every environment, never relabeled as a production failure.
|
||||
*/
|
||||
export function createDatabaseTransportPolicy(
|
||||
input: DatabaseTransportPolicyInput = {},
|
||||
options: CreateDatabaseTransportPolicyOptions = {},
|
||||
): DatabaseTransportPolicy {
|
||||
const environment = options.environment ?? "production";
|
||||
|
||||
let mode: DatabaseTlsMode;
|
||||
if (input.mode === undefined) {
|
||||
// Production fails safe to verified TLS; development keeps the plaintext disposable-PostgreSQL
|
||||
// workflow this repository's harness relies on.
|
||||
mode = environment === "production" ? "verify-full" : "disable";
|
||||
} else if (input.mode === "verify-full" || input.mode === "disable") {
|
||||
mode = input.mode;
|
||||
} else {
|
||||
// No coercion of booleans, `1`, `"TRUE"`, `" verify-full "`, or any other loose spelling.
|
||||
throw new DatabaseTransportError("DATABASE_TLS_MODE_INVALID");
|
||||
}
|
||||
|
||||
if (environment === "production" && mode !== "verify-full") {
|
||||
throw new DatabaseTransportError("DATABASE_TLS_REQUIRED_IN_PRODUCTION");
|
||||
}
|
||||
|
||||
if (
|
||||
input.certificateAuthority !== undefined &&
|
||||
(typeof input.certificateAuthority !== "string" ||
|
||||
input.certificateAuthority.trim().length === 0)
|
||||
) {
|
||||
throw new DatabaseTransportError("DATABASE_TLS_MODE_INVALID");
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
...(typeof input.certificateAuthority === "string"
|
||||
? { certificateAuthority: input.certificateAuthority }
|
||||
: {}),
|
||||
mode,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects any database URL carrying its own TLS parameters. Called before a pool or client is ever
|
||||
* constructed, so a URL can never silently downgrade or contradict the validated policy — there is
|
||||
* exactly one place transport security is decided.
|
||||
*/
|
||||
export function assertNoConnectionStringTlsParameters(databaseUrl: string): void {
|
||||
const url = parseDatabaseUrl(databaseUrl);
|
||||
for (const [name] of url.searchParams) {
|
||||
if (CONNECTION_STRING_TLS_PARAMETERS.includes(name.toLowerCase())) {
|
||||
throw new DatabaseTransportError("DATABASE_TLS_CONNECTION_STRING_CONFLICT");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates the validated policy into the exact pinned-`pg` options that achieve it, and asserts
|
||||
* the URL cannot override them.
|
||||
*
|
||||
* `verify-full` is **not** simply `ssl: true` or `{ rejectUnauthorized: true }`. Verified directly
|
||||
* against a live TLS PostgreSQL instance: those verify the certificate *chain*, but `pg` only sets
|
||||
* `options.servername` for non-IP hosts (`pg/lib/connection.js`) and passes `tls.connect` a bare
|
||||
* socket carrying no host, so for an IP endpoint Node has no identity to check and falls back to
|
||||
* the fixed name `localhost` — never the address actually dialled. A certificate issued for an
|
||||
* entirely different name is therefore accepted when connecting to an IP address. Supplying an
|
||||
* explicit `checkServerIdentity` pinned to the configured host closes that gap for DNS and IP hosts
|
||||
* alike (proven: a certificate valid only for `localhost` is rejected with
|
||||
* `ERR_TLS_CERT_ALTNAME_INVALID` when reached via `127.0.0.1`).
|
||||
*/
|
||||
export function databaseTlsConnectionOptions(
|
||||
databaseUrl: string,
|
||||
policy: DatabaseTransportPolicy,
|
||||
): false | ConnectionOptions {
|
||||
assertNoConnectionStringTlsParameters(databaseUrl);
|
||||
if (policy.mode === "disable") {
|
||||
return false;
|
||||
}
|
||||
const host = databaseHostFromUrl(databaseUrl);
|
||||
return {
|
||||
...(policy.certificateAuthority === undefined ? {} : { ca: policy.certificateAuthority }),
|
||||
// Chain verification. There is no configuration that can set this to `false`.
|
||||
rejectUnauthorized: true,
|
||||
// Identity verification against the host actually being connected to — never the SNI value pg
|
||||
// may or may not have set, and never skipped for an IP endpoint.
|
||||
checkServerIdentity: (_servername: string, certificate: tls.PeerCertificate) =>
|
||||
tls.checkServerIdentity(host, certificate),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The one production guard every executable database path shares: a production deployment must use
|
||||
* `verify-full`, and its URL must not carry TLS parameters. There is no production plaintext
|
||||
* exception — not for loopback, not for an RFC1918 address, not for a Docker service name. A
|
||||
* production process that genuinely runs beside its database still gets certificate-verified TLS,
|
||||
* which costs nothing and removes an entire class of "it was only internal" misconfiguration.
|
||||
*/
|
||||
export function assertProductionDatabaseTransport(
|
||||
databaseUrl: string,
|
||||
policy: DatabaseTransportPolicy,
|
||||
environment: DatabaseTransportEnvironment,
|
||||
): void {
|
||||
assertNoConnectionStringTlsParameters(databaseUrl);
|
||||
if (environment !== "production") {
|
||||
return;
|
||||
}
|
||||
if (policy.mode !== "verify-full") {
|
||||
throw new DatabaseTransportError("DATABASE_TLS_REQUIRED_IN_PRODUCTION");
|
||||
}
|
||||
}
|
||||
|
||||
/** A fixed, generic description of the effective transport for diagnostics. Never includes the
|
||||
* URL, host, port, database name, credentials, certificate, or CA path. */
|
||||
export function describeDatabaseTransport(policy: DatabaseTransportPolicy): string {
|
||||
return policy.mode === "verify-full"
|
||||
? `TLS required with certificate and hostname verification${
|
||||
policy.certificateAuthority === undefined ? "" : " (explicit private CA supplied)"
|
||||
}.`
|
||||
: "TLS disabled (development/test plaintext).";
|
||||
}
|
||||
|
||||
export { isLoopbackDatabaseHost };
|
||||
@@ -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"]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import net from "node:net";
|
||||
|
||||
export type ClientAddressMode = "direct" | "trusted-proxy";
|
||||
|
||||
export type ClientAddressConfigErrorCode =
|
||||
| "CLIENT_ADDRESS_DIRECT_MODE_CONTRADICTION"
|
||||
| "CLIENT_ADDRESS_HEADER_INVALID"
|
||||
| "CLIENT_ADDRESS_HEADER_MISSING"
|
||||
| "CLIENT_ADDRESS_MODE_INVALID"
|
||||
| "CLIENT_ADDRESS_TRUSTED_PROXIES_EMPTY"
|
||||
| "CLIENT_ADDRESS_TRUSTED_PROXY_INVALID";
|
||||
|
||||
const clientAddressConfigErrorMessages: Readonly<Record<ClientAddressConfigErrorCode, string>> =
|
||||
Object.freeze({
|
||||
CLIENT_ADDRESS_DIRECT_MODE_CONTRADICTION:
|
||||
"Direct client-address mode must not also configure a header or trusted proxies.",
|
||||
CLIENT_ADDRESS_HEADER_INVALID: "The client-address header must be a valid HTTP header name.",
|
||||
CLIENT_ADDRESS_HEADER_MISSING:
|
||||
"Trusted-proxy client-address mode requires an explicit header name.",
|
||||
CLIENT_ADDRESS_MODE_INVALID: 'The client-address mode must be "direct" or "trusted-proxy".',
|
||||
CLIENT_ADDRESS_TRUSTED_PROXIES_EMPTY:
|
||||
"Trusted-proxy client-address mode requires at least one trusted proxy address or range.",
|
||||
CLIENT_ADDRESS_TRUSTED_PROXY_INVALID:
|
||||
"Every trusted proxy entry must be a valid IPv4/IPv6 address or CIDR range.",
|
||||
});
|
||||
|
||||
/** Never carries the offending mode, header name, or trusted-proxy value. */
|
||||
export class ClientAddressConfigError extends Error {
|
||||
readonly code: ClientAddressConfigErrorCode;
|
||||
|
||||
constructor(code: ClientAddressConfigErrorCode) {
|
||||
super(clientAddressConfigErrorMessages[code]);
|
||||
this.name = "ClientAddressConfigError";
|
||||
this.code = code;
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
Object.freeze(this);
|
||||
}
|
||||
}
|
||||
|
||||
export interface DirectClientAddressConfig {
|
||||
readonly mode: "direct";
|
||||
}
|
||||
|
||||
/**
|
||||
* `header` is the one header name the reverse proxy is trusted to set — the platform transport
|
||||
* boundary (`apps/api/src/client-address.ts`) overwrites any externally supplied copy of it before
|
||||
* this policy ever sees the request, so it never needs to be `X-Forwarded-For` specifically, though
|
||||
* that remains the common choice. `trustedProxies` lists the proxy's own address(es)/range(s), never
|
||||
* a broad private range that also covers real clients.
|
||||
*/
|
||||
export interface TrustedProxyClientAddressConfig {
|
||||
readonly mode: "trusted-proxy";
|
||||
readonly header: string;
|
||||
readonly trustedProxies: readonly string[];
|
||||
}
|
||||
|
||||
export type ClientAddressConfig = DirectClientAddressConfig | TrustedProxyClientAddressConfig;
|
||||
|
||||
export interface ClientAddressConfigInput {
|
||||
readonly mode?: unknown;
|
||||
readonly header?: unknown;
|
||||
readonly trustedProxies?: unknown;
|
||||
}
|
||||
|
||||
const HEADER_TOKEN_PATTERN = /^[a-z0-9-]+$/u;
|
||||
const CIDR_PREFIX_PATTERN = /^\d+$/u;
|
||||
const IPV4_MAPPED_PATTERN = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/iu;
|
||||
|
||||
/**
|
||||
* Normalizes a single address token for consistent identity/comparison: plain IPv4 is returned
|
||||
* as-is, an IPv4-mapped IPv6 address (`::ffff:a.b.c.d` — the form Node's own `socket.remoteAddress`
|
||||
* and real reverse proxies actually emit for an IPv4 peer on a dual-stack listener) collapses to its
|
||||
* embedded IPv4 form, and any other valid IPv6 address is lowercased. A zone id (`%eth0`) is
|
||||
* rejected as ambiguous outside its own interface. Anything that is not a syntactically valid IPv4
|
||||
* or IPv6 address returns `undefined` rather than being guessed at.
|
||||
*/
|
||||
export function normalizeClientAddress(address: string): string | undefined {
|
||||
const trimmed = address.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (net.isIPv4(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
if (trimmed.includes("%")) {
|
||||
return undefined;
|
||||
}
|
||||
if (net.isIPv6(trimmed)) {
|
||||
const mapped = IPV4_MAPPED_PATTERN.exec(trimmed);
|
||||
if (mapped) {
|
||||
const embedded = mapped[1] as string;
|
||||
return net.isIPv4(embedded) ? embedded : undefined;
|
||||
}
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface ParsedTrustedProxyEntry {
|
||||
readonly network: string;
|
||||
readonly prefix: number;
|
||||
readonly family: "ipv4" | "ipv6";
|
||||
}
|
||||
|
||||
function parseTrustedProxyEntry(entry: string): ParsedTrustedProxyEntry | undefined {
|
||||
const slashIndex = entry.indexOf("/");
|
||||
const addressPart = slashIndex === -1 ? entry : entry.slice(0, slashIndex);
|
||||
const network = normalizeClientAddress(addressPart);
|
||||
if (!network) {
|
||||
return undefined;
|
||||
}
|
||||
const family: "ipv4" | "ipv6" = net.isIPv4(network) ? "ipv4" : "ipv6";
|
||||
const maxPrefix = family === "ipv4" ? 32 : 128;
|
||||
if (slashIndex === -1) {
|
||||
return { family, network, prefix: maxPrefix };
|
||||
}
|
||||
const prefixPart = entry.slice(slashIndex + 1);
|
||||
if (!CIDR_PREFIX_PATTERN.test(prefixPart)) {
|
||||
return undefined;
|
||||
}
|
||||
const prefix = Number(prefixPart);
|
||||
return prefix <= maxPrefix ? { family, network, prefix } : undefined;
|
||||
}
|
||||
|
||||
/** Trusted-proxy entries that are not a valid IPv4/IPv6 address or CIDR range — used both by config
|
||||
* validation and, if ever needed diagnostically, by Doctor. */
|
||||
export function findInvalidTrustedProxies(entries: readonly string[]): readonly string[] {
|
||||
return entries.filter((entry) => parseTrustedProxyEntry(entry) === undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates explicit, typed client-address configuration. The safe default (`mode: "direct"`) is
|
||||
* used whenever `mode` is entirely omitted; every other field is validated strictly, and an
|
||||
* incomplete or contradictory combination (proxy details without proxy mode, proxy mode without
|
||||
* proxy details) is rejected outright rather than guessed at. `internalHeaderName` is the one
|
||||
* platform-owned header (`TRUSTED_CLIENT_ADDRESS_HEADER`, `./runtime.js`) a caller must never be
|
||||
* able to configure as its own "trusted" source header.
|
||||
*/
|
||||
export function createClientAddressConfig(
|
||||
input: ClientAddressConfigInput = {},
|
||||
internalHeaderName?: string,
|
||||
): ClientAddressConfig {
|
||||
const mode = input.mode ?? "direct";
|
||||
if (mode !== "direct" && mode !== "trusted-proxy") {
|
||||
throw new ClientAddressConfigError("CLIENT_ADDRESS_MODE_INVALID");
|
||||
}
|
||||
|
||||
if (mode === "direct") {
|
||||
if (input.header !== undefined || input.trustedProxies !== undefined) {
|
||||
throw new ClientAddressConfigError("CLIENT_ADDRESS_DIRECT_MODE_CONTRADICTION");
|
||||
}
|
||||
return Object.freeze({ mode: "direct" });
|
||||
}
|
||||
|
||||
if (typeof input.header !== "string" || input.header.trim().length === 0) {
|
||||
throw new ClientAddressConfigError("CLIENT_ADDRESS_HEADER_MISSING");
|
||||
}
|
||||
const header = input.header.trim().toLowerCase();
|
||||
if (!HEADER_TOKEN_PATTERN.test(header)) {
|
||||
throw new ClientAddressConfigError("CLIENT_ADDRESS_HEADER_INVALID");
|
||||
}
|
||||
if (internalHeaderName !== undefined && header === internalHeaderName) {
|
||||
// The internal sanitized header is never a valid *source* header — trusting it as the
|
||||
// caller-forwarded value would let a caller set its own "resolved" address directly.
|
||||
throw new ClientAddressConfigError("CLIENT_ADDRESS_HEADER_INVALID");
|
||||
}
|
||||
|
||||
if (!Array.isArray(input.trustedProxies) || input.trustedProxies.length === 0) {
|
||||
throw new ClientAddressConfigError("CLIENT_ADDRESS_TRUSTED_PROXIES_EMPTY");
|
||||
}
|
||||
const trustedProxies = input.trustedProxies.map((entry) => {
|
||||
if (typeof entry !== "string" || entry.trim().length === 0) {
|
||||
throw new ClientAddressConfigError("CLIENT_ADDRESS_TRUSTED_PROXY_INVALID");
|
||||
}
|
||||
return entry.trim();
|
||||
});
|
||||
if (findInvalidTrustedProxies(trustedProxies).length > 0) {
|
||||
throw new ClientAddressConfigError("CLIENT_ADDRESS_TRUSTED_PROXY_INVALID");
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
header,
|
||||
mode: "trusted-proxy",
|
||||
trustedProxies: Object.freeze(trustedProxies),
|
||||
});
|
||||
}
|
||||
|
||||
export type ClientAddressFailureReason =
|
||||
| "FORWARDED_CHAIN_EXHAUSTED"
|
||||
| "FORWARDED_HEADER_MALFORMED"
|
||||
| "FORWARDED_HEADER_MISSING"
|
||||
| "PEER_ADDRESS_UNAVAILABLE";
|
||||
|
||||
export type ClientAddressResolution =
|
||||
| { readonly ok: true; readonly address: string }
|
||||
| { readonly ok: false; readonly reason: ClientAddressFailureReason };
|
||||
|
||||
export interface ClientAddressResolver {
|
||||
resolve(input: {
|
||||
readonly peerAddress: string | undefined;
|
||||
readonly headers: Headers;
|
||||
}): ClientAddressResolution;
|
||||
}
|
||||
|
||||
function buildTrustedProxyMatcher(trustedProxies: readonly string[]): (address: string) => boolean {
|
||||
const blockList = new net.BlockList();
|
||||
for (const entry of trustedProxies) {
|
||||
const parsed = parseTrustedProxyEntry(entry);
|
||||
if (!parsed) {
|
||||
// Config validation already rejects this at startup; a matcher is only ever built from an
|
||||
// already-validated config, so this can only mean the config was constructed unsafely.
|
||||
throw new ClientAddressConfigError("CLIENT_ADDRESS_TRUSTED_PROXY_INVALID");
|
||||
}
|
||||
blockList.addSubnet(parsed.network, parsed.prefix, parsed.family);
|
||||
}
|
||||
return (address: string): boolean =>
|
||||
blockList.check(address, net.isIPv4(address) ? "ipv4" : "ipv6");
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the one server-owned client-address resolution policy this API runtime uses for every
|
||||
* request, from already-validated configuration. Built once at startup (never per-request) so the
|
||||
* trusted-proxy `BlockList` is compiled a single time. Depends only on `node:net` and the standard
|
||||
* Fetch `Headers` type — never Hono or `@hono/node-server` — so it stays reusable by Core's own
|
||||
* identity composition without pulling an HTTP framework into Core; the Node/Hono transport glue
|
||||
* that reads the actual socket peer and rewrites the request lives in `apps/api` instead
|
||||
* (`apps/api/src/client-address.ts`).
|
||||
*
|
||||
* Direct mode trusts only the actual immediate socket peer — never any request header. Trusted-proxy
|
||||
* mode trusts the configured forwarded header's content only when the actual immediate socket peer
|
||||
* matches a configured trusted-proxy address/range; otherwise it falls back to direct-mode behavior
|
||||
* (the peer address itself), exactly as if no forwarded header existed at all. A forwarded chain is
|
||||
* walked right to left: trailing hops that match a trusted proxy are skipped, and the first hop from
|
||||
* the right that does not match is the resolved client address. A chain that is missing, empty,
|
||||
* contains any malformed token, or consists entirely of trusted-proxy hops (no genuine client
|
||||
* address left to resolve) fails closed rather than guessing — never a caller-chosen bucket, and
|
||||
* never a silent collapse onto the proxy's own address.
|
||||
*/
|
||||
export function createClientAddressResolver(config: ClientAddressConfig): ClientAddressResolver {
|
||||
const isTrustedProxy =
|
||||
config.mode === "trusted-proxy" ? buildTrustedProxyMatcher(config.trustedProxies) : undefined;
|
||||
|
||||
return {
|
||||
resolve({ peerAddress, headers }): ClientAddressResolution {
|
||||
const normalizedPeer =
|
||||
peerAddress === undefined ? undefined : normalizeClientAddress(peerAddress);
|
||||
if (!normalizedPeer) {
|
||||
return { ok: false, reason: "PEER_ADDRESS_UNAVAILABLE" };
|
||||
}
|
||||
|
||||
if (config.mode === "direct" || !isTrustedProxy?.(normalizedPeer)) {
|
||||
return { ok: true, address: normalizedPeer };
|
||||
}
|
||||
|
||||
const headerValue = headers.get(config.header);
|
||||
if (headerValue === null) {
|
||||
return { ok: false, reason: "FORWARDED_HEADER_MISSING" };
|
||||
}
|
||||
const tokens = headerValue.split(",").map((token) => token.trim());
|
||||
if (tokens.length === 0 || tokens.some((token) => token.length === 0)) {
|
||||
return { ok: false, reason: "FORWARDED_HEADER_MALFORMED" };
|
||||
}
|
||||
const normalizedTokens: string[] = [];
|
||||
for (const token of tokens) {
|
||||
const normalized = normalizeClientAddress(token);
|
||||
if (!normalized) {
|
||||
return { ok: false, reason: "FORWARDED_HEADER_MALFORMED" };
|
||||
}
|
||||
normalizedTokens.push(normalized);
|
||||
}
|
||||
for (let index = normalizedTokens.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = normalizedTokens[index];
|
||||
if (candidate !== undefined && !isTrustedProxy(candidate)) {
|
||||
return { ok: true, address: candidate };
|
||||
}
|
||||
}
|
||||
return { ok: false, reason: "FORWARDED_CHAIN_EXHAUSTED" };
|
||||
},
|
||||
};
|
||||
}
|
||||
+198
-21
@@ -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<string> = 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<string> = new Set(["0.0.0.0", "[::]"]);
|
||||
const LITERAL_LOOPBACK_HOSTNAMES: ReadonlySet<string> = new Set(["localhost"]);
|
||||
|
||||
/** Expands a bracketed IPv6 `URL.hostname` into its 8 canonical 16-bit groups, or `undefined` when
|
||||
* the value is not a bracketed IPv6 literal. Handles `::` zero-compression and the mixed
|
||||
* `::ffff:a.b.c.d` notation the WHATWG parser preserves. */
|
||||
function expandIpv6Hostname(hostname: string): readonly number[] | undefined {
|
||||
if (!hostname.startsWith("[") || !hostname.endsWith("]")) {
|
||||
return undefined;
|
||||
}
|
||||
let body = hostname.slice(1, -1);
|
||||
// Mixed notation: translate a trailing dotted-quad into two hex groups.
|
||||
const dotted = /^(.*:)(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/u.exec(body);
|
||||
if (dotted) {
|
||||
const octets = [dotted[2], dotted[3], dotted[4], dotted[5]].map((part) => Number(part));
|
||||
if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) {
|
||||
return undefined;
|
||||
}
|
||||
const high = ((octets[0] as number) << 8) | (octets[1] as number);
|
||||
const low = ((octets[2] as number) << 8) | (octets[3] as number);
|
||||
body = `${dotted[1]}${high.toString(16)}:${low.toString(16)}`;
|
||||
}
|
||||
|
||||
const [head, tail, ...extra] = body.split("::");
|
||||
if (extra.length > 0) {
|
||||
return undefined;
|
||||
}
|
||||
const parseGroups = (value: string | undefined): number[] | undefined => {
|
||||
if (value === undefined || value === "") {
|
||||
return [];
|
||||
}
|
||||
const groups: number[] = [];
|
||||
for (const part of value.split(":")) {
|
||||
if (!/^[0-9a-f]{1,4}$/u.test(part)) {
|
||||
return undefined;
|
||||
}
|
||||
groups.push(Number.parseInt(part, 16));
|
||||
}
|
||||
return groups;
|
||||
};
|
||||
const leading = parseGroups(head);
|
||||
const trailing = tail === undefined ? [] : parseGroups(tail);
|
||||
if (!leading || !trailing) {
|
||||
return undefined;
|
||||
}
|
||||
if (tail === undefined) {
|
||||
return leading.length === 8 ? leading : undefined;
|
||||
}
|
||||
const missing = 8 - leading.length - trailing.length;
|
||||
if (missing < 0) {
|
||||
return undefined;
|
||||
}
|
||||
return [...leading, ...Array.from({ length: missing }, () => 0), ...trailing];
|
||||
}
|
||||
|
||||
/** Whether a canonicalized `URL.hostname` is a loopback or unspecified listen address in any
|
||||
* spelling this repository accepts — literal name, reserved `.localhost` namespace, the full IPv4
|
||||
* `127.0.0.0/8` block, IPv6 `::1` compressed or expanded, IPv6 `::` unspecified, and IPv4-mapped
|
||||
* IPv6 forms of both in mixed (`::ffff:127.0.0.1`) and hexadecimal (`::ffff:7f00:1`) notation. */
|
||||
function isLoopbackOrUnspecifiedHostname(hostname: string): boolean {
|
||||
const normalized = hostname.toLowerCase();
|
||||
if (LITERAL_LOOPBACK_HOSTNAMES.has(normalized) || UNSPECIFIED_HOSTNAMES.has(normalized)) {
|
||||
return true;
|
||||
}
|
||||
// RFC 6761 reserves the entire `.localhost` namespace for loopback.
|
||||
if (normalized.endsWith(".localhost")) {
|
||||
return true;
|
||||
}
|
||||
if (IPV4_LOOPBACK_BLOCK.test(normalized)) {
|
||||
return true;
|
||||
}
|
||||
const groups = expandIpv6Hostname(normalized);
|
||||
if (!groups) {
|
||||
return false;
|
||||
}
|
||||
const isAllZero = groups.every((group) => group === 0);
|
||||
// `::` (unspecified) and `::1` (loopback).
|
||||
if (isAllZero || (groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1)) {
|
||||
return true;
|
||||
}
|
||||
// IPv4-mapped IPv6 (`::ffff:a.b.c.d`): classify the embedded IPv4 address.
|
||||
const isMapped = groups.slice(0, 5).every((group) => group === 0) && groups[5] === 0xffff;
|
||||
if (isMapped) {
|
||||
const high = groups[6] as number;
|
||||
const low = groups[7] as number;
|
||||
const embedded = `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`;
|
||||
return IPV4_LOOPBACK_BLOCK.test(embedded) || embedded === "0.0.0.0";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const errorMessages: Readonly<Record<IdentityConfigErrorCode, string>> = Object.freeze({
|
||||
BASE_URL_INSECURE_IN_PRODUCTION: "The identity base URL must use https in production.",
|
||||
@@ -29,6 +127,10 @@ const errorMessages: Readonly<Record<IdentityConfigErrorCode, string>> = Object.
|
||||
BASE_URL_LOOPBACK_IN_PRODUCTION:
|
||||
"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 });
|
||||
}
|
||||
|
||||
@@ -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<typeof identityAdapterSchema>;
|
||||
@@ -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 }),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./client-address.js";
|
||||
export * from "./config.js";
|
||||
export * from "./database.js";
|
||||
export * from "./runtime.js";
|
||||
|
||||
+193
-80
@@ -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<Record<IdentityRuntimeErrorCode, string>> =
|
||||
|
||||
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<T>(operation: Promise<T>, timeoutMs: number): Promise
|
||||
}
|
||||
}
|
||||
|
||||
async function probeIdentityDatabase(db: IdentityDrizzleDatabase): Promise<boolean> {
|
||||
/**
|
||||
* Whether the caller actually presented *the* configured Better Auth session cookie — not merely
|
||||
* some cookie. An arbitrary anonymous cookie (`Cookie: x=y`) is not evidence that Better Auth ever
|
||||
* attempted to resolve an Ekklesia session, so it must never trigger session-store work.
|
||||
*
|
||||
* Parsing deliberately mirrors RFC 6265's `cookie-string` grammar the same way Better Auth's own
|
||||
* parser does: split on `;`, take each pair's name as the text before its first `=`, and trim
|
||||
* surrounding whitespace. Matching is an exact, whole-name string comparison — never `includes`, a
|
||||
* substring test, or an unanchored regular expression — so `better-auth.session_token_x`,
|
||||
* `x_better-auth.session_token`, a differently-cased spelling, and a percent-encoded lookalike are
|
||||
* all correctly *not* the session cookie (cookie names are case-sensitive per RFC 6265 §4.1.1, and
|
||||
* cookie names are never percent-decoded).
|
||||
*
|
||||
* Ambiguity fails safe *towards* checking rather than away from it: a duplicated session-cookie
|
||||
* name still reports `true`, because reporting `false` is what would let a genuinely broken
|
||||
* session store be reported as an anonymous request. Returning `true` never grants access on its
|
||||
* own — it only means the readiness check runs and Better Auth's own result still governs.
|
||||
*/
|
||||
export function hasConfiguredSessionCookie(headers: Headers, cookieName: string): boolean {
|
||||
const cookieHeader = headers.get("cookie");
|
||||
if (cookieHeader === null) {
|
||||
return false;
|
||||
}
|
||||
for (const pair of cookieHeader.split(";")) {
|
||||
const separatorIndex = pair.indexOf("=");
|
||||
const name = (separatorIndex === -1 ? pair : pair.slice(0, separatorIndex)).trim();
|
||||
if (name === cookieName) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the actual Better Auth session store is usable — not merely whether PostgreSQL can
|
||||
* execute a trivial statement.
|
||||
*
|
||||
* The previous `select 1` probe proved only that *a* connection could run *a* query, so a missing
|
||||
* session relation, a drifted/absent column, or a revoked `SELECT` privilege all still looked
|
||||
* "reachable", and Better Auth's ambiguous `null` was then reported as a genuinely anonymous
|
||||
* request. This instead plans a real read of the exact session relation and the exact columns the
|
||||
* adapter depends on, under a `where false` predicate: PostgreSQL still resolves the relation,
|
||||
* every named column, and the caller's `SELECT` privilege at plan time, while guaranteeing zero
|
||||
* rows are matched, read, or returned — so no session row, token, or user id can ever be
|
||||
* enumerated, logged, or exposed by the check itself.
|
||||
*
|
||||
* Uses the injected Identity Drizzle view (the API's one shared pool); it never constructs another
|
||||
* pool, client, connection string, or Drizzle instance. The bounded `withTimeout` guarantees the
|
||||
* public response is never blocked by a hung query, and `clearTimeout` in its `finally` leaves no
|
||||
* timer behind.
|
||||
*/
|
||||
async function probeSessionStore(db: IdentityDrizzleDatabase): Promise<boolean> {
|
||||
try {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 `[email protected]`/`[email protected]` against a real PostgreSQL and a real
|
||||
* blackhole listener rather than by reading the merge and assuming.
|
||||
*
|
||||
* `ConnectionParameters` merges as `Object.assign({}, config, parse(config.connectionString))` and
|
||||
* `pg-connection-string` retains *arbitrary* query keys, so any of these lands in the driver's
|
||||
* configuration. What each one actually does differs, and only the first genuinely weakens policy:
|
||||
*
|
||||
* - `statement_timeout` — **wins outright.** `?statement_timeout=0` produced a server-reported
|
||||
* `statement_timeout` of `0` (disabled) and `?statement_timeout=900000` produced `15min`, against
|
||||
* an explicit `statement_timeout: 10000`. This is the one real downgrade.
|
||||
* - `query_timeout` — overrides the explicit field, but does not weaken the bound; it breaks the
|
||||
* client. `client.query` computes its read timer as
|
||||
* `queryConfig.query_timeout || connectionParameters.query_timeout`, and the parsed URL value is
|
||||
* the *string* `"0"` — truthy — so every query gets a 0 ms read timer and fails
|
||||
* nondeterministically with `Query read timeout`. Rejected so an operator gets a fixed
|
||||
* configuration error instead of intermittent, unexplained query failures.
|
||||
* - `connectionTimeoutMillis` — pollutes `ConnectionParameters.connect_timeout` (10 → `0` or `900`
|
||||
* seconds), but that field is only consumed by `getLibpqConnectionString()`, which the pure-JS
|
||||
* client never calls. Measured against a blackhole listener, the effective connect timer is
|
||||
* **unchanged**: an explicit 500 ms still expired at ~500 ms with `?connectionTimeoutMillis=0`
|
||||
* and with `=900000`. Rejected as unsupported and as defense in depth — no legitimate
|
||||
* `DATABASE_URL` carries a driver option, and the polluted value would take effect on the native
|
||||
* binding — not because it currently defeats the deadline.
|
||||
* - `connect_timeout` — no effect at all: `ConnectionParameters` recomputes it from
|
||||
* `connectionTimeoutMillis`, so `?connect_timeout=0` left it at the derived `10`. Rejected as
|
||||
* unsupported; earlier revisions of this comment wrongly claimed it overrode the explicit field.
|
||||
* - `options` carrying `statement_timeout` — measured **not** to override the named startup
|
||||
* parameter: `?options=-c statement_timeout=0`, `=900s`, and a backslash-escaped spelling all
|
||||
* still produced `10s`, as did an inherited `PGOPTIONS`. Rejected as unsupported rather than
|
||||
* because it wins.
|
||||
*
|
||||
* Deliberately narrow: unrelated parameters (`application_name`, and anything else that does not
|
||||
* name a timeout this boundary selects) are untouched. `PGCONNECT_TIMEOUT` needs no handling — the
|
||||
* driver consults it only when `connectionTimeoutMillis` is `undefined`, and this boundary always
|
||||
* sets it; an inherited `PGCONNECT_TIMEOUT=0` was measured to change nothing.
|
||||
*/
|
||||
const CONFLICTING_TIMEOUT_PARAMETERS = Object.freeze([
|
||||
"connect_timeout",
|
||||
"connectiontimeoutmillis",
|
||||
"query_timeout",
|
||||
"statement_timeout",
|
||||
]);
|
||||
|
||||
function assertNoConflictingTimeoutParameters(connectionString: string): void {
|
||||
let search: URLSearchParams;
|
||||
try {
|
||||
search = new URL(connectionString).searchParams;
|
||||
} catch {
|
||||
throw databaseError(
|
||||
"POSTGRES_MIGRATION_CONFIGURATION_INVALID",
|
||||
"The PostgreSQL database URL is invalid.",
|
||||
);
|
||||
}
|
||||
for (const [rawKey, rawValue] of search.entries()) {
|
||||
const key = rawKey.toLowerCase();
|
||||
if (CONFLICTING_TIMEOUT_PARAMETERS.includes(key)) {
|
||||
throw databaseError(
|
||||
"POSTGRES_MIGRATION_CONFIGURATION_INVALID",
|
||||
"The PostgreSQL database URL must not set a connection or statement timeout.",
|
||||
);
|
||||
}
|
||||
if (key === "options" && rawValue.toLowerCase().includes("statement_timeout")) {
|
||||
throw databaseError(
|
||||
"POSTGRES_MIGRATION_CONFIGURATION_INVALID",
|
||||
"The PostgreSQL database URL must not set a connection or statement timeout.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface PostgresMigrationDatabaseOptions {
|
||||
/** 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<MigrationTransaction>({
|
||||
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<void> {
|
||||
/**
|
||||
* Sends one migration's complete SQL to PostgreSQL in a single simple-query call — no values, no
|
||||
* prepared-statement name, no rewriting — so the server performs all SQL parsing and the packaged
|
||||
* bytes are what actually execute.
|
||||
*/
|
||||
async #executeMigrationSql(sql: string): Promise<void> {
|
||||
this.#assertTransactionActive("POSTGRES_MIGRATION_EXECUTION_FAILED");
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void>;
|
||||
executeStatement(sql: string): Promise<void>;
|
||||
/**
|
||||
* Executes one migration's **complete** SQL exactly as packaged, in a single call, inside the
|
||||
* caller's transaction. Never a fragment: the runner does not split migration SQL, so the
|
||||
* implementation must pass the string through to PostgreSQL unmodified.
|
||||
*/
|
||||
executeMigrationSql(sql: string): Promise<void>;
|
||||
insertAppliedMigration(migration: MigrationHistoryInsert): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
+134
-12
@@ -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<void>;
|
||||
}
|
||||
|
||||
export interface StartModuleRuntimeOptions extends ModuleDiscoveryOptions {
|
||||
export interface ModuleRuntimeTimeoutOptions {
|
||||
/** Test-only narrowing of `MODULE_STOP_HOOK_TIMEOUT_MS`, applied to both normal stop and startup
|
||||
* rollback, so a deadline can be proven in milliseconds instead of ten seconds. Production
|
||||
* composition never sets this. */
|
||||
readonly hookTimeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface StartModuleRuntimeOptions
|
||||
extends ModuleDiscoveryOptions,
|
||||
ModuleRuntimeTimeoutOptions {
|
||||
/** The same frozen services object every started module's `start`/`stop` hooks and (once
|
||||
* `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<void>,
|
||||
timeoutMs: number,
|
||||
): Promise<"completed" | "timed-out"> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
// Attached before the race so the rejection is considered handled even if the timeout wins.
|
||||
const observed = hook.then(
|
||||
() => "completed" as const,
|
||||
(error: unknown) => {
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
observed.catch(() => undefined);
|
||||
try {
|
||||
return await Promise.race([
|
||||
observed,
|
||||
new Promise<"timed-out">((resolve) => {
|
||||
timer = setTimeout(() => resolve("timed-out"), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops modules in reverse order, giving each hook a bounded slice of time and continuing to the
|
||||
* next module whatever the previous one did. Every applicable hook is invoked at most once.
|
||||
*
|
||||
* The whole sequence is finite because the module count is finite and fixed at discovery: its
|
||||
* worst case is `startedModuleCount * MODULE_STOP_HOOK_TIMEOUT_MS`, not one global ten-second
|
||||
* bound.
|
||||
*/
|
||||
async function stopModules(
|
||||
modules: readonly LoadedModule[],
|
||||
registry: StagedCapabilityRegistry,
|
||||
phase: "rollback" | "stop",
|
||||
services: ModuleRuntimeServices,
|
||||
options: StopModulesOptions = {},
|
||||
): Promise<readonly ModuleRuntimeIssue[]> {
|
||||
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<void> {
|
||||
const started: LoadedModule[] = [];
|
||||
// A module joins the rollback set the moment its `start` hook is *invoked*, not when it
|
||||
// completes. A hook that acquired a connection, registered a listener, or recorded partial
|
||||
// initialization before throwing still has cleanup to do, and previously only the modules that
|
||||
// had already finished were rolled back — leaving the failing module's own resources behind. Its
|
||||
// `stop` hook therefore runs first, ahead of the earlier modules in reverse order, which is why
|
||||
// module authors must make `stop` safe after a partially completed `start`.
|
||||
const rollbackCandidates: LoadedModule[] = [];
|
||||
for (const module of modules) {
|
||||
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<StartedModuleRuntime> {
|
||||
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<void> => {
|
||||
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<StartedModuleRuntime> {
|
||||
const discovered = await discoverModules(options);
|
||||
return startDiscoveredModuleRuntime(discovered, options.services);
|
||||
return startDiscoveredModuleRuntime(discovered, options.services, {
|
||||
...(options.hookTimeoutMs === undefined ? {} : { hookTimeoutMs: options.hookTimeoutMs }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,12 +3,13 @@ import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import 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<number> {
|
||||
const rows = await safeQuery<{ count: string }>(
|
||||
client,
|
||||
`select count(*)::text as count from "ekklesia_core_platform_owner_bootstrap"`,
|
||||
);
|
||||
return Number(rows[0]?.count ?? "0");
|
||||
}
|
||||
|
||||
it("records exactly one consumed row, one owner row, and one audit row in one transaction", async () => {
|
||||
const database = await createDisposableDatabase("durable");
|
||||
await applyCoreMigrations(database.url);
|
||||
const admin = await openAdministrativeClient(database.url);
|
||||
const { close, db } = createAuthorizationDrizzleDatabase(database.url);
|
||||
try {
|
||||
const ownerId = parseEkklesiaUserId("durable_owner");
|
||||
const laterId = parseEkklesiaUserId("durable_later");
|
||||
await seedUser(admin, ownerId, "[email protected]");
|
||||
await seedUser(admin, laterId, "[email protected]");
|
||||
|
||||
expect(await consumedCount(admin)).toBe(0);
|
||||
await bootstrapPlatformOwner(db, { userId: ownerId });
|
||||
|
||||
const consumed = await safeQuery<{ consumed_for_user_id: string | null; id: string }>(
|
||||
admin,
|
||||
`select id, consumed_for_user_id from "ekklesia_core_platform_owner_bootstrap"`,
|
||||
);
|
||||
expect(consumed).toHaveLength(1);
|
||||
expect(consumed[0]?.id).toBe("singleton");
|
||||
expect(consumed[0]?.consumed_for_user_id).toBe(ownerId);
|
||||
expect(await ownerRowCount(admin)).toBe(1);
|
||||
expect(
|
||||
await safeQuery(
|
||||
admin,
|
||||
`select id from "ekklesia_core_authorization_audit" where action = 'platform_owner.grant'`,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
|
||||
// Deleting the bootstrapped owner's user row removes the *live* owner row, exactly as
|
||||
// before — but no longer the consumed sentinel or the security history describing it.
|
||||
await safeQuery(admin, `delete from "ekklesia_core_user" where id = $1`, [ownerId]);
|
||||
expect(await ownerRowCount(admin)).toBe(0);
|
||||
expect(await consumedCount(admin)).toBe(1);
|
||||
const survivingAudit = await safeQuery<{ action: string; subject_user_id: string }>(
|
||||
admin,
|
||||
`select action, subject_user_id from "ekklesia_core_authorization_audit"`,
|
||||
);
|
||||
expect(survivingAudit).toHaveLength(1);
|
||||
expect(survivingAudit[0]).toMatchObject({
|
||||
action: "platform_owner.grant",
|
||||
subject_user_id: ownerId,
|
||||
});
|
||||
|
||||
// And bootstrap is still consumed — the operation that establishes a platform's first
|
||||
// administrator cannot be reopened by deleting a user.
|
||||
let captured: unknown;
|
||||
try {
|
||||
await bootstrapPlatformOwner(db, { userId: laterId });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED" });
|
||||
expect(await ownerRowCount(admin)).toBe(0);
|
||||
} finally {
|
||||
await close();
|
||||
await safeEnd(admin);
|
||||
await dropDatabase(database.name);
|
||||
}
|
||||
});
|
||||
|
||||
it("retains a grant subject's create and revoke audit rows after that user is deleted", async () => {
|
||||
const database = await createDisposableDatabase("audit_survives");
|
||||
await applyCoreMigrations(database.url);
|
||||
const admin = await openAdministrativeClient(database.url);
|
||||
const { close, db } = createAuthorizationDrizzleDatabase(database.url);
|
||||
try {
|
||||
const subjectId = parseEkklesiaUserId("audit_subject");
|
||||
const managerId = parseEkklesiaUserId("audit_manager");
|
||||
await seedUser(admin, subjectId, "[email protected]");
|
||||
await seedUser(admin, managerId, "[email protected]");
|
||||
|
||||
const store = createAuthorizationStore(db);
|
||||
const grant = await store.createGrant({
|
||||
actor: createUserActor(managerId),
|
||||
permission: parsePermissionId("calendar.events.read"),
|
||||
sourceKind: "explicit",
|
||||
userId: subjectId,
|
||||
});
|
||||
await store.revokeGrant({ actor: createUserActor(managerId), grantId: grant.id });
|
||||
|
||||
const before = await safeQuery<{ action: string }>(
|
||||
admin,
|
||||
`select action from "ekklesia_core_authorization_audit" where subject_user_id = $1 order by created_at`,
|
||||
[subjectId],
|
||||
);
|
||||
expect(before.map((row) => row.action)).toEqual(["grant.create", "grant.revoke"]);
|
||||
|
||||
await safeQuery(admin, `delete from "ekklesia_core_user" where id = $1`, [subjectId]);
|
||||
|
||||
// The live grant row still cascades with the user, as intended — the security *history*
|
||||
// does not.
|
||||
expect(
|
||||
await safeQuery(admin, `select id from "ekklesia_core_grant" where user_id = $1`, [
|
||||
subjectId,
|
||||
]),
|
||||
).toHaveLength(0);
|
||||
const after = await safeQuery<{ action: string; subject_user_id: string }>(
|
||||
admin,
|
||||
`select action, subject_user_id from "ekklesia_core_authorization_audit" where subject_user_id = $1 order by created_at`,
|
||||
[subjectId],
|
||||
);
|
||||
expect(after.map((row) => row.action)).toEqual(["grant.create", "grant.revoke"]);
|
||||
// The retained subject is a plain identifier snapshot; no profile data was ever copied here.
|
||||
expect(Object.keys(after[0] as object).sort()).toEqual(["action", "subject_user_id"]);
|
||||
} finally {
|
||||
await close();
|
||||
await safeEnd(admin);
|
||||
await dropDatabase(database.name);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not consume bootstrap when the transaction fails after the sentinel was claimed", async () => {
|
||||
const database = await createDisposableDatabase("failed_claim");
|
||||
await applyCoreMigrations(database.url);
|
||||
const admin = await openAdministrativeClient(database.url);
|
||||
const { close, db } = createAuthorizationDrizzleDatabase(database.url);
|
||||
try {
|
||||
const ownerId = parseEkklesiaUserId("failed_claim_owner");
|
||||
await seedUser(admin, ownerId, "[email protected]");
|
||||
|
||||
// A temporary trigger makes the audit insert — the last statement of the bootstrap
|
||||
// transaction, after the sentinel and owner row were already written — violate the audit
|
||||
// action CHECK constraint.
|
||||
await safeQuery(
|
||||
admin,
|
||||
`create function ekklesia_test_break_bootstrap() returns trigger as $$
|
||||
begin new.action := 'not.a.real.action'; return new; end;
|
||||
$$ language plpgsql`,
|
||||
);
|
||||
await safeQuery(
|
||||
admin,
|
||||
`create trigger ekklesia_test_break_bootstrap_trigger before insert on "ekklesia_core_authorization_audit"
|
||||
for each row execute function ekklesia_test_break_bootstrap()`,
|
||||
);
|
||||
try {
|
||||
let captured: unknown;
|
||||
try {
|
||||
await bootstrapPlatformOwner(db, { userId: ownerId });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "PLATFORM_OWNER_BOOTSTRAP_FAILED" });
|
||||
// The sentinel, the owner row, and the audit row all rolled back together, so the one
|
||||
// legitimate first use is still available.
|
||||
expect(await consumedCount(admin)).toBe(0);
|
||||
expect(await ownerRowCount(admin)).toBe(0);
|
||||
} finally {
|
||||
await safeQuery(
|
||||
admin,
|
||||
`drop trigger if exists ekklesia_test_break_bootstrap_trigger on "ekklesia_core_authorization_audit"`,
|
||||
);
|
||||
await safeQuery(admin, `drop function if exists ekklesia_test_break_bootstrap()`);
|
||||
}
|
||||
|
||||
// With the failure removed, bootstrap still works — it was never burned.
|
||||
await bootstrapPlatformOwner(db, { userId: ownerId });
|
||||
expect(await consumedCount(admin)).toBe(1);
|
||||
expect(await ownerRowCount(admin)).toBe(1);
|
||||
} finally {
|
||||
await close();
|
||||
await safeEnd(admin);
|
||||
await dropDatabase(database.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Upgrade path: an installation created before this migration must not gain a fresh first use.
|
||||
* These scenarios apply only 0000/0001, plant representative pre-migration state through the
|
||||
* test-only administrative client, then apply 0002 and assert the backfill's effect.
|
||||
*/
|
||||
describe.sequential("live Core migration 0002 — bootstrap-consumed backfill", () => {
|
||||
async function applyThrough(url: string, upTo: number): Promise<void> {
|
||||
const emptyModulesDirectory = await mkdtemp(
|
||||
path.join(tmpdir(), "ekklesia-upgrade-no-modules-"),
|
||||
);
|
||||
try {
|
||||
const aggregate = await discoverAllMigrations({
|
||||
coreMigrationsDirectory,
|
||||
moduleDiscovery: {
|
||||
moduleSdkVersion: "0.1.0",
|
||||
modulesDirectory: emptyModulesDirectory,
|
||||
platformVersion: "0.1.0",
|
||||
},
|
||||
});
|
||||
const selected = {
|
||||
...aggregate,
|
||||
migrations: aggregate.migrations.slice(0, upTo),
|
||||
};
|
||||
await applyModuleMigrations({
|
||||
aggregate: selected,
|
||||
database: createPostgresMigrationDatabase(url),
|
||||
});
|
||||
} finally {
|
||||
await rm(emptyModulesDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function consumedRows(client: Client) {
|
||||
return await safeQuery<{ consumed_for_user_id: string | null; id: string }>(
|
||||
client,
|
||||
`select id, consumed_for_user_id from "ekklesia_core_platform_owner_bootstrap"`,
|
||||
);
|
||||
}
|
||||
|
||||
it("backfills consumed state for an installation that already has an owner, including a revoked one", async () => {
|
||||
for (const [label, revoke] of [
|
||||
["active_owner", false],
|
||||
["revoked_owner", true],
|
||||
] as const) {
|
||||
const database = await createDisposableDatabase(label);
|
||||
const admin = await openAdministrativeClient(database.url);
|
||||
try {
|
||||
await applyThrough(database.url, 2);
|
||||
const ownerId = parseEkklesiaUserId(`upgrade_owner_${label}`);
|
||||
const nextId = parseEkklesiaUserId(`upgrade_next_${label}`);
|
||||
await seedUser(admin, ownerId, `upgrade-${label}@example.test`);
|
||||
await seedUser(admin, nextId, `upgrade-next-${label}@example.test`);
|
||||
await safeQuery(
|
||||
admin,
|
||||
`insert into "ekklesia_core_platform_owner" (user_id, granted_by_actor_type, granted_by_actor_id)
|
||||
values ($1, 'system', 'authorization-bootstrap-cli')`,
|
||||
[ownerId],
|
||||
);
|
||||
await safeQuery(
|
||||
admin,
|
||||
`insert into "ekklesia_core_authorization_audit" (action, actor_type, actor_id, subject_user_id)
|
||||
values ('platform_owner.grant', 'system', 'authorization-bootstrap-cli', $1)`,
|
||||
[ownerId],
|
||||
);
|
||||
if (revoke) {
|
||||
await safeQuery(
|
||||
admin,
|
||||
`update "ekklesia_core_platform_owner" set revoked_at = now(), revoked_by_actor_type = 'system' where user_id = $1`,
|
||||
[ownerId],
|
||||
);
|
||||
}
|
||||
|
||||
await applyThrough(database.url, 3);
|
||||
|
||||
const consumed = await consumedRows(admin);
|
||||
expect(consumed, label).toHaveLength(1);
|
||||
expect(consumed[0]?.id).toBe("singleton");
|
||||
// A backfill cannot know which of several historical owner rows the original single use
|
||||
// targeted, so it deliberately records no subject rather than fabricating one.
|
||||
expect(consumed[0]?.consumed_for_user_id).toBeNull();
|
||||
// Every pre-existing audit row survives the migration untouched.
|
||||
expect(
|
||||
await safeQuery(admin, `select id from "ekklesia_core_authorization_audit"`),
|
||||
).toHaveLength(1);
|
||||
|
||||
const { close, db } = createAuthorizationDrizzleDatabase(database.url);
|
||||
try {
|
||||
let captured: unknown;
|
||||
try {
|
||||
await bootstrapPlatformOwner(db, { userId: nextId });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured, label).toMatchObject({
|
||||
code: "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED",
|
||||
});
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
await dropDatabase(database.name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves bootstrap available exactly once for an installation that never had an owner", async () => {
|
||||
const database = await createDisposableDatabase("never_owned");
|
||||
const admin = await openAdministrativeClient(database.url);
|
||||
try {
|
||||
await applyThrough(database.url, 2);
|
||||
const userId = parseEkklesiaUserId("never_owned_user");
|
||||
await seedUser(admin, userId, "[email protected]");
|
||||
|
||||
await applyThrough(database.url, 3);
|
||||
|
||||
// A legitimately never-bootstrapped installation must retain its one first use.
|
||||
expect(await consumedRows(admin)).toHaveLength(0);
|
||||
|
||||
const { close, db } = createAuthorizationDrizzleDatabase(database.url);
|
||||
try {
|
||||
await bootstrapPlatformOwner(db, { userId });
|
||||
expect(await consumedRows(admin)).toHaveLength(1);
|
||||
let captured: unknown;
|
||||
try {
|
||||
await bootstrapPlatformOwner(db, { userId });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED" });
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
} finally {
|
||||
await safeEnd(admin);
|
||||
await dropDatabase(database.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
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<typeof createAuthorizationStore>;
|
||||
let close: () => Promise<void>;
|
||||
|
||||
const subject = parseEkklesiaUserId("equiv_subject");
|
||||
const other = parseEkklesiaUserId("equiv_other");
|
||||
const actor = createUserActor("equiv_other");
|
||||
const permission = parsePermissionId("calendar.events.read");
|
||||
|
||||
async function effectiveCount(userId: string, permissionId: string): Promise<number> {
|
||||
const rows = await safeQuery<{ n: string }>(
|
||||
admin,
|
||||
`select count(*)::int as n from "ekklesia_core_grant"
|
||||
where user_id = $1 and permission_id = $2 and revoked_at is null
|
||||
and (expires_at is null or expires_at > now())`,
|
||||
[userId, permissionId],
|
||||
);
|
||||
return Number(rows[0]?.n ?? 0);
|
||||
}
|
||||
|
||||
it("prepares a disposable database with the three Core migrations applied", async () => {
|
||||
database = await createDisposableDatabase("equivalence");
|
||||
await applyCoreMigrations(database.url);
|
||||
admin = await openAdministrativeClient(database.url);
|
||||
await seedUser(admin, subject, "[email protected]");
|
||||
await seedUser(admin, other, "[email protected]");
|
||||
const handle = createAuthorizationDrizzleDatabase(database.url);
|
||||
close = handle.close;
|
||||
store = createAuthorizationStore(handle.db);
|
||||
expect(await effectiveCount(subject, permission)).toBe(0);
|
||||
});
|
||||
|
||||
it("creates the first unscoped explicit grant and writes exactly one create audit row", async () => {
|
||||
const grant = await store.createGrant({
|
||||
actor,
|
||||
permission,
|
||||
sourceKind: "explicit",
|
||||
userId: subject,
|
||||
});
|
||||
|
||||
expect(grant.scope).toBeUndefined();
|
||||
const audit = await safeQuery<{ action: string }>(
|
||||
admin,
|
||||
`select action from "ekklesia_core_authorization_audit" where grant_id = $1`,
|
||||
[grant.id],
|
||||
);
|
||||
expect(audit.map((row) => row.action)).toEqual(["grant.create"]);
|
||||
});
|
||||
|
||||
it("refuses a sequential equivalent creation, leaving no second grant and no second audit row", async () => {
|
||||
const auditBefore = await safeQuery(
|
||||
admin,
|
||||
`select id from "ekklesia_core_authorization_audit" where subject_user_id = $1 and permission_id = $2`,
|
||||
[subject, permission],
|
||||
);
|
||||
|
||||
let captured: unknown;
|
||||
try {
|
||||
await store.createGrant({ actor, permission, sourceKind: "explicit", userId: subject });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(AuthorizationStoreError);
|
||||
expect(captured).toMatchObject({ code: "AUTHORIZATION_GRANT_ALREADY_EXISTS" });
|
||||
expect(await effectiveCount(subject, permission)).toBe(1);
|
||||
const auditAfter = await safeQuery(
|
||||
admin,
|
||||
`select id from "ekklesia_core_authorization_audit" where subject_user_id = $1 and permission_id = $2`,
|
||||
[subject, permission],
|
||||
);
|
||||
expect(auditAfter).toHaveLength(auditBefore.length);
|
||||
});
|
||||
|
||||
it("a differing expiration does not make an otherwise identical issuance distinct", async () => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
await store.createGrant({
|
||||
actor,
|
||||
expiresAt: new Date(Date.now() + 3_600_000),
|
||||
permission,
|
||||
sourceKind: "explicit",
|
||||
userId: subject,
|
||||
});
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "AUTHORIZATION_GRANT_ALREADY_EXISTS" });
|
||||
expect(await effectiveCount(subject, permission)).toBe(1);
|
||||
});
|
||||
|
||||
it("a different subject, permission, scope, source kind, or source reference is not equivalent", async () => {
|
||||
const distinct = [
|
||||
{ permission, sourceKind: "explicit" as const, userId: other },
|
||||
{
|
||||
permission: parsePermissionId("calendar.events.write"),
|
||||
sourceKind: "explicit" as const,
|
||||
userId: subject,
|
||||
},
|
||||
{
|
||||
permission,
|
||||
scope: parseAuthorizationScope("calendar-event:1"),
|
||||
sourceKind: "explicit" as const,
|
||||
userId: subject,
|
||||
},
|
||||
{ permission, sourceKind: "relationship" as const, userId: subject },
|
||||
{
|
||||
permission,
|
||||
sourceKind: "relationship" as const,
|
||||
sourceRef: "roster:7",
|
||||
userId: subject,
|
||||
},
|
||||
];
|
||||
for (const input of distinct) {
|
||||
const grant = await store.createGrant({ actor, ...input });
|
||||
expect(grant.id).toBeDefined();
|
||||
}
|
||||
// A second issuance of one of those distinct tuples is itself a duplicate.
|
||||
let captured: unknown;
|
||||
try {
|
||||
await store.createGrant({
|
||||
actor,
|
||||
permission,
|
||||
scope: parseAuthorizationScope("calendar-event:1"),
|
||||
sourceKind: "explicit",
|
||||
userId: subject,
|
||||
});
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "AUTHORIZATION_GRANT_ALREADY_EXISTS" });
|
||||
});
|
||||
|
||||
it("serializes concurrent equivalent creations to exactly one grant and one conflict", async () => {
|
||||
const concurrentPermission = parsePermissionId("calendar.events.concurrent");
|
||||
// Six genuinely concurrent transactions against the same tuple. The transaction-scoped
|
||||
// advisory lock is what makes this deterministic: no sleeps, no retries, no process-local
|
||||
// mutex — a second API process would be serialized identically.
|
||||
const results = await Promise.allSettled(
|
||||
Array.from({ length: 6 }, () =>
|
||||
store.createGrant({
|
||||
actor,
|
||||
permission: concurrentPermission,
|
||||
sourceKind: "explicit",
|
||||
userId: subject,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const fulfilled = results.filter((result) => result.status === "fulfilled");
|
||||
const rejected = results.filter((result) => result.status === "rejected");
|
||||
expect(fulfilled).toHaveLength(1);
|
||||
expect(rejected).toHaveLength(5);
|
||||
for (const result of rejected) {
|
||||
expect((result as PromiseRejectedResult).reason).toMatchObject({
|
||||
code: "AUTHORIZATION_GRANT_ALREADY_EXISTS",
|
||||
});
|
||||
}
|
||||
expect(await effectiveCount(subject, concurrentPermission)).toBe(1);
|
||||
const audit = await safeQuery(
|
||||
admin,
|
||||
`select id from "ekklesia_core_authorization_audit"
|
||||
where subject_user_id = $1 and permission_id = $2 and action = 'grant.create'`,
|
||||
[subject, concurrentPermission],
|
||||
);
|
||||
expect(audit).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("allows a fresh issuance once the previous equivalent is revoked, and again once one expires", async () => {
|
||||
const cyclePermission = parsePermissionId("calendar.events.cycle");
|
||||
const first = await store.createGrant({
|
||||
actor,
|
||||
permission: cyclePermission,
|
||||
sourceKind: "explicit",
|
||||
userId: subject,
|
||||
});
|
||||
await store.revokeGrant({ actor, grantId: first.id, sourceKind: "explicit" });
|
||||
|
||||
// Revoked equivalents do not block a new issuance.
|
||||
const second = await store.createGrant({
|
||||
actor,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
permission: cyclePermission,
|
||||
sourceKind: "explicit",
|
||||
userId: subject,
|
||||
});
|
||||
expect(second.id).not.toBe(first.id);
|
||||
|
||||
// Force the second grant past its expiry through the administrative client, which is the only
|
||||
// deterministic way to observe "expired equivalent" without sleeping.
|
||||
await safeQuery(
|
||||
admin,
|
||||
`update "ekklesia_core_grant" set expires_at = now() - interval '1 second' where id = $1`,
|
||||
[second.id],
|
||||
);
|
||||
const third = await store.createGrant({
|
||||
actor,
|
||||
permission: cyclePermission,
|
||||
sourceKind: "explicit",
|
||||
userId: subject,
|
||||
});
|
||||
expect(third.id).not.toBe(second.id);
|
||||
expect(await effectiveCount(subject, cyclePermission)).toBe(1);
|
||||
});
|
||||
|
||||
it("one revocation revokes every unrevoked equivalent, audits each by its own id, and leaves nothing authorizing", async () => {
|
||||
const legacyPermission = parsePermissionId("calendar.events.legacy");
|
||||
// Plant the exact state the old store could produce: several equivalent issuances, including
|
||||
// one already expired but never revoked, plus two deliberately non-equivalent neighbours.
|
||||
const legacyIds: string[] = [];
|
||||
for (const expires of ["null", "now() + interval '1 hour'", "now() - interval '1 hour'"]) {
|
||||
const rows = await safeQuery<{ id: string }>(
|
||||
admin,
|
||||
`insert into "ekklesia_core_grant"
|
||||
(user_id, permission_id, scope, source_kind, source_ref, created_by_actor_type, created_by_actor_id, expires_at)
|
||||
values ($1, $2, null, 'explicit', null, 'user', $3, ${expires}) returning id`,
|
||||
[subject, legacyPermission, other],
|
||||
);
|
||||
legacyIds.push(rows[0]?.id as string);
|
||||
}
|
||||
const scopedNeighbour = await safeQuery<{ id: string }>(
|
||||
admin,
|
||||
`insert into "ekklesia_core_grant"
|
||||
(user_id, permission_id, scope, source_kind, source_ref, created_by_actor_type, created_by_actor_id)
|
||||
values ($1, $2, 'calendar-event:9', 'explicit', null, 'user', $3) returning id`,
|
||||
[subject, legacyPermission, other],
|
||||
);
|
||||
// Deliberately on its own permission: an unscoped relationship grant for `legacyPermission`
|
||||
// would legitimately keep authorizing after this revocation (it is a different equivalence
|
||||
// tuple), which would make the "nothing authorizes" assertion below meaningless rather than
|
||||
// wrong. The scoped neighbour above stays on the same permission because a scoped grant never
|
||||
// satisfies an unscoped check.
|
||||
const relationshipNeighbour = await safeQuery<{ id: string }>(
|
||||
admin,
|
||||
`insert into "ekklesia_core_grant"
|
||||
(user_id, permission_id, scope, source_kind, source_ref, created_by_actor_type, created_by_actor_id)
|
||||
values ($1, 'calendar.events.legacyrel', null, 'relationship', 'roster:9', 'user', $2) returning id`,
|
||||
[subject, other],
|
||||
);
|
||||
expect(await effectiveCount(subject, legacyPermission)).toBe(3);
|
||||
|
||||
await store.revokeGrant({
|
||||
actor,
|
||||
grantId: parseGrantId(legacyIds[0] as string),
|
||||
sourceKind: "explicit",
|
||||
});
|
||||
|
||||
const revoked = await safeQuery<{ id: string; revoked_at: Date | null }>(
|
||||
admin,
|
||||
`select id, revoked_at from "ekklesia_core_grant" where id = any($1::uuid[])`,
|
||||
[legacyIds],
|
||||
);
|
||||
// Every unrevoked equivalent, including the expired-but-unrevoked one, is now revoked.
|
||||
expect(revoked.every((row) => row.revoked_at !== null)).toBe(true);
|
||||
|
||||
const auditRows = await safeQuery<{ grant_id: string }>(
|
||||
admin,
|
||||
`select grant_id from "ekklesia_core_authorization_audit"
|
||||
where action = 'grant.revoke' and grant_id = any($1::uuid[])`,
|
||||
[legacyIds],
|
||||
);
|
||||
// One audit row per grant actually revoked, each carrying that row's own id.
|
||||
expect(auditRows).toHaveLength(legacyIds.length);
|
||||
expect([...new Set(auditRows.map((row) => row.grant_id))].sort()).toEqual(
|
||||
[...legacyIds].sort(),
|
||||
);
|
||||
|
||||
// Differently scoped and differently sourced grants are untouched.
|
||||
const untouched = await safeQuery<{ revoked_at: Date | null }>(
|
||||
admin,
|
||||
`select revoked_at from "ekklesia_core_grant" where id = any($1::uuid[])`,
|
||||
[[scopedNeighbour[0]?.id, relationshipNeighbour[0]?.id]],
|
||||
);
|
||||
expect(untouched.every((row) => row.revoked_at === null)).toBe(true);
|
||||
|
||||
// No equivalent issuance continues authorizing the user.
|
||||
expect(
|
||||
await store.hasActiveGrant({
|
||||
now: new Date(),
|
||||
permission: legacyPermission,
|
||||
userId: subject,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("serializes a concurrent create and revoke on the same tuple without leaving an unexplained duplicate", async () => {
|
||||
const racePermission = parsePermissionId("calendar.events.race");
|
||||
const seed = await store.createGrant({
|
||||
actor,
|
||||
permission: racePermission,
|
||||
sourceKind: "explicit",
|
||||
userId: subject,
|
||||
});
|
||||
|
||||
const [revokeResult, createResult] = await Promise.allSettled([
|
||||
store.revokeGrant({ actor, grantId: seed.id, sourceKind: "explicit" }),
|
||||
store.createGrant({
|
||||
actor,
|
||||
permission: racePermission,
|
||||
sourceKind: "explicit",
|
||||
userId: subject,
|
||||
}),
|
||||
]);
|
||||
|
||||
// Whichever order the two transactions serialize in, the end state is consistent with that
|
||||
// order: either the create lost to an effective equivalent (0 effective), or it ran after the
|
||||
// revoke (1 effective). Two effective equivalents is the state this pass makes impossible.
|
||||
expect(revokeResult.status).toBe("fulfilled");
|
||||
const effective = await effectiveCount(subject, racePermission);
|
||||
expect(effective).toBeLessThanOrEqual(1);
|
||||
if (createResult.status === "rejected") {
|
||||
expect(createResult.reason).toMatchObject({ code: "AUTHORIZATION_GRANT_ALREADY_EXISTS" });
|
||||
expect(effective).toBe(0);
|
||||
} else {
|
||||
expect(effective).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("rolls back every grant and audit change when the multi-row revocation transaction fails", async () => {
|
||||
const doomedPermission = parsePermissionId("calendar.events.doomed");
|
||||
const rows = await safeQuery<{ id: string }>(
|
||||
admin,
|
||||
`insert into "ekklesia_core_grant"
|
||||
(user_id, permission_id, scope, source_kind, source_ref, created_by_actor_type, created_by_actor_id)
|
||||
values ($1, $2, null, 'explicit', null, 'user', $3), ($1, $2, null, 'explicit', null, 'user', $3)
|
||||
returning id`,
|
||||
[subject, doomedPermission, other],
|
||||
);
|
||||
// Force the audit insert inside the revoke transaction to violate a real constraint: the audit
|
||||
// action CHECK only permits the four known actions, so a temporary trigger that rewrites the
|
||||
// action makes the whole multi-row revocation fail after the grant updates were staged.
|
||||
await safeQuery(
|
||||
admin,
|
||||
`create function ekklesia_test_break_audit() returns trigger as $$
|
||||
begin new.action := 'not.a.real.action'; return new; end;
|
||||
$$ language plpgsql`,
|
||||
);
|
||||
await safeQuery(
|
||||
admin,
|
||||
`create trigger ekklesia_test_break_audit_trigger before insert on "ekklesia_core_authorization_audit"
|
||||
for each row execute function ekklesia_test_break_audit()`,
|
||||
);
|
||||
try {
|
||||
let captured: unknown;
|
||||
try {
|
||||
await store.revokeGrant({
|
||||
actor,
|
||||
grantId: parseGrantId(rows[0]?.id as string),
|
||||
sourceKind: "explicit",
|
||||
});
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "AUTHORIZATION_GRANT_REVOKE_FAILED" });
|
||||
|
||||
const after = await safeQuery<{ revoked_at: Date | null }>(
|
||||
admin,
|
||||
`select revoked_at from "ekklesia_core_grant" where id = any($1::uuid[])`,
|
||||
[rows.map((row) => row.id)],
|
||||
);
|
||||
expect(after.every((row) => row.revoked_at === null)).toBe(true);
|
||||
const audit = await safeQuery(
|
||||
admin,
|
||||
`select id from "ekklesia_core_authorization_audit" where permission_id = $1`,
|
||||
[doomedPermission],
|
||||
);
|
||||
expect(audit).toHaveLength(0);
|
||||
} finally {
|
||||
// Always restored, even when an assertion above fails.
|
||||
await safeQuery(
|
||||
admin,
|
||||
`drop trigger if exists ekklesia_test_break_audit_trigger on "ekklesia_core_authorization_audit"`,
|
||||
);
|
||||
await safeQuery(admin, `drop function if exists ekklesia_test_break_audit()`);
|
||||
}
|
||||
});
|
||||
|
||||
it("reports duplicate conflict, not-found, and infrastructure failure as three distinct sanitized outcomes", async () => {
|
||||
const codes: string[] = [];
|
||||
try {
|
||||
await store.createGrant({ actor, permission, sourceKind: "explicit", userId: subject });
|
||||
} catch (error) {
|
||||
codes.push((error as AuthorizationStoreError).code);
|
||||
}
|
||||
try {
|
||||
await store.revokeGrant({
|
||||
actor,
|
||||
grantId: parseGrantId("00000000-0000-4000-8000-000000000000"),
|
||||
sourceKind: "explicit",
|
||||
});
|
||||
} catch (error) {
|
||||
codes.push((error as AuthorizationStoreError).code);
|
||||
}
|
||||
await close();
|
||||
try {
|
||||
await store.hasActiveGrant({ now: new Date(), permission, userId: subject });
|
||||
} catch (error) {
|
||||
codes.push((error as AuthorizationStoreError).code);
|
||||
}
|
||||
|
||||
expect(codes).toEqual([
|
||||
"AUTHORIZATION_GRANT_ALREADY_EXISTS",
|
||||
"AUTHORIZATION_GRANT_NOT_FOUND",
|
||||
"AUTHORIZATION_GRANT_LOOKUP_FAILED",
|
||||
]);
|
||||
await safeEnd(admin);
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential("live Core authorization store — bounded keyset pagination", () => {
|
||||
let database: { name: string; url: string };
|
||||
let admin: Client;
|
||||
let store: ReturnType<typeof createAuthorizationStore>;
|
||||
let close: () => Promise<void>;
|
||||
|
||||
const subject = parseEkklesiaUserId("page_subject");
|
||||
|
||||
it("seeds explicit and relationship grants, including several sharing one created_at", async () => {
|
||||
database = await createDisposableDatabase("pagination");
|
||||
await applyCoreMigrations(database.url);
|
||||
admin = await openAdministrativeClient(database.url);
|
||||
await seedUser(admin, subject, "[email protected]");
|
||||
const handle = createAuthorizationDrizzleDatabase(database.url);
|
||||
close = handle.close;
|
||||
store = createAuthorizationStore(handle.db);
|
||||
|
||||
// Seven explicit grants all sharing one identical `created_at`, which is exactly the case a
|
||||
// timestamp-only keyset boundary would skip or repeat, plus relationship grants that must
|
||||
// never appear on any page.
|
||||
await safeQuery(
|
||||
admin,
|
||||
`insert into "ekklesia_core_grant"
|
||||
(user_id, permission_id, scope, source_kind, source_ref, created_by_actor_type, created_by_actor_id, created_at)
|
||||
select $1, 'calendar.events.p' || generation, null, 'explicit', null, 'user', $1,
|
||||
timestamptz '2026-01-01 00:00:00Z'
|
||||
from generate_series(1, 7) as generation`,
|
||||
[subject],
|
||||
);
|
||||
await safeQuery(
|
||||
admin,
|
||||
`insert into "ekklesia_core_grant"
|
||||
(user_id, permission_id, scope, source_kind, source_ref, created_by_actor_type, created_by_actor_id, created_at)
|
||||
select $1, 'calendar.events.r' || generation, null, 'relationship', 'roster:' || generation, 'user', $1,
|
||||
timestamptz '2026-01-01 00:00:00Z'
|
||||
from generate_series(1, 3) as generation`,
|
||||
[subject],
|
||||
);
|
||||
expect(
|
||||
await safeQuery(admin, `select id from "ekklesia_core_grant" where user_id = $1`, [subject]),
|
||||
).toHaveLength(10);
|
||||
});
|
||||
|
||||
it("traverses every explicit grant exactly once across pages, excluding relationship grants", async () => {
|
||||
const seen: string[] = [];
|
||||
let after: { createdAt: Date; id: GrantId } | undefined;
|
||||
let pages = 0;
|
||||
for (;;) {
|
||||
const page = await store.listGrantsForUser(subject, {
|
||||
...(after === undefined ? {} : { after }),
|
||||
limit: 3,
|
||||
sourceKind: "explicit",
|
||||
});
|
||||
pages += 1;
|
||||
seen.push(...page.grants.map((grant) => grant.id));
|
||||
expect(page.grants.every((grant) => grant.sourceKind === "explicit")).toBe(true);
|
||||
if (page.nextBoundary === undefined) {
|
||||
expect(page.grants.length).toBeLessThanOrEqual(3);
|
||||
break;
|
||||
}
|
||||
after = page.nextBoundary;
|
||||
expect(pages).toBeLessThan(10);
|
||||
}
|
||||
|
||||
// 7 explicit rows at limit 3 → pages of 3, 3, 1. No duplicates, no omissions, deterministic
|
||||
// despite every row sharing one created_at.
|
||||
expect(pages).toBe(3);
|
||||
expect(seen).toHaveLength(7);
|
||||
expect(new Set(seen).size).toBe(7);
|
||||
// Ordering is created_at DESC, id DESC — with equal timestamps that is a pure descending id
|
||||
// sort, which is exactly what makes traversal total and repeatable.
|
||||
expect([...seen].sort().reverse()).toEqual(seen);
|
||||
});
|
||||
|
||||
it("honours the 1 and 100 boundaries and rejects anything outside them", async () => {
|
||||
expect(
|
||||
(await store.listGrantsForUser(subject, { limit: 1, sourceKind: "explicit" })).grants,
|
||||
).toHaveLength(1);
|
||||
const all = await store.listGrantsForUser(subject, { limit: 100, sourceKind: "explicit" });
|
||||
expect(all.grants).toHaveLength(7);
|
||||
expect(all.nextBoundary).toBeUndefined();
|
||||
for (const limit of [0, 101, -1, 1.5]) {
|
||||
await expect(store.listGrantsForUser(subject, { limit })).rejects.toMatchObject({
|
||||
code: "AUTHORIZATION_GRANT_LIMIT_INVALID",
|
||||
});
|
||||
}
|
||||
await close();
|
||||
await safeEnd(admin);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A revocation must never succeed by revoking a *replacement* grant it never addressed.
|
||||
*
|
||||
* `revokeGrant` discovers the addressed grant's equivalence tuple before taking that tuple's
|
||||
* advisory lock, because the tuple columns are immutable once written. The all-equivalent update
|
||||
* that follows matches on the tuple, not on the addressed id — so without an under-lock
|
||||
* revalidation of the target itself, a revoker that paused between its target read and the lock
|
||||
* could wake up after the target was revoked and a fresh equivalent created, and then revoke that
|
||||
* replacement while reporting success. No serial order explains that outcome, and it contradicts
|
||||
* the rule that an already-revoked target keeps not-found behavior.
|
||||
*
|
||||
* Reproducing it needs the interleaving forced, not raced: an ordinary `Promise.all` would depend
|
||||
* on timing luck. The tuple advisory lock is cooperative, so a test-only connection can hold the
|
||||
* exact key the store computes — using the identical SQL expression, not a re-derived constant —
|
||||
* while ordinary `UPDATE`/`INSERT` statements proceed normally. That is the same test-only
|
||||
* administrative provisioning this suite already uses to seed legacy duplicates and force
|
||||
* expirations; no production test hook exists to pause the store.
|
||||
*/
|
||||
describe.sequential("live Core authorization store — stale revocation under the equivalence lock", () => {
|
||||
/** Bounded, condition-based wait — never an arbitrary sleep. Returns once a backend is genuinely
|
||||
* blocked on an advisory lock in this disposable database, which is the only way to know the
|
||||
* stale revocation has passed its target read and reached the lock. */
|
||||
async function awaitAdvisoryLockWait(client: Client): Promise<void> {
|
||||
const deadline = Date.now() + 20_000;
|
||||
while (Date.now() < deadline) {
|
||||
const rows = await safeQuery<{ waiting: number }>(
|
||||
client,
|
||||
`select count(*)::int as waiting
|
||||
from pg_locks l
|
||||
join pg_stat_activity a on a.pid = l.pid
|
||||
where l.locktype = 'advisory'
|
||||
and not l.granted
|
||||
and a.wait_event_type = 'Lock'
|
||||
and a.wait_event = 'advisory'
|
||||
and l.database = (select oid from pg_database where datname = current_database())`,
|
||||
);
|
||||
if (Number(rows[0]?.waiting ?? 0) > 0) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
throw harnessError("The stale revocation never reached the advisory-lock wait.");
|
||||
}
|
||||
|
||||
/** An independent outer deadline, so a regression can never hang the suite. The timer is always
|
||||
* cleared, including when the awaited promise rejects. */
|
||||
async function withDeadline<T>(operation: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(harnessError("The stale revocation did not settle before its deadline.")),
|
||||
timeoutMs,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
it("rejects a revocation whose addressed target was revoked and replaced while it waited for the tuple lock", async () => {
|
||||
const database = await createDisposableDatabase("stale_revoke");
|
||||
await applyCoreMigrations(database.url);
|
||||
const admin = await openAdministrativeClient(database.url);
|
||||
const lockHolder = await openAdministrativeClient(database.url);
|
||||
const handle = createAuthorizationDrizzleDatabase(database.url);
|
||||
let staleSettled: Promise<unknown> | undefined;
|
||||
let lockHeld = false;
|
||||
|
||||
try {
|
||||
const subject = parseEkklesiaUserId("stale_subject");
|
||||
const manager = parseEkklesiaUserId("stale_manager");
|
||||
await seedUser(admin, subject, "[email protected]");
|
||||
await seedUser(admin, manager, "[email protected]");
|
||||
const store = createAuthorizationStore(handle.db);
|
||||
const actor = createUserActor(manager);
|
||||
const permission = parsePermissionId("calendar.events.stale");
|
||||
|
||||
const firstGrant = await store.createGrant({
|
||||
actor,
|
||||
permission,
|
||||
sourceKind: "explicit",
|
||||
userId: subject,
|
||||
});
|
||||
|
||||
// Hold the exact tuple key the store derives, computed with the identical SQL expression.
|
||||
await safeQuery(lockHolder, "begin");
|
||||
await safeQuery(
|
||||
lockHolder,
|
||||
`select pg_advisory_xact_lock(hashtextextended(jsonb_build_array(
|
||||
$1::text, $2::text, null::text, 'explicit'::text, null::text
|
||||
)::text, 0))`,
|
||||
[subject, permission],
|
||||
);
|
||||
lockHeld = true;
|
||||
|
||||
// The stale revoker reads its target successfully, then blocks on the held lock.
|
||||
const stale = store.revokeGrant({ actor, grantId: firstGrant.id, sourceKind: "explicit" });
|
||||
staleSettled = stale.then(
|
||||
() => ({ status: "fulfilled" as const }),
|
||||
(error: unknown) => ({ error, status: "rejected" as const }),
|
||||
);
|
||||
await awaitAdvisoryLockWait(admin);
|
||||
|
||||
// Committed while the stale revoker waits: the target is revoked and audited, then a fresh
|
||||
// equivalent replacement is created — exactly what a concurrent revoker and creator produce.
|
||||
await safeQuery(
|
||||
admin,
|
||||
`update "ekklesia_core_grant"
|
||||
set revoked_at = now(), revoked_by_actor_type = 'user', revoked_by_actor_id = $2
|
||||
where id = $1`,
|
||||
[firstGrant.id, manager],
|
||||
);
|
||||
await safeQuery(
|
||||
admin,
|
||||
`insert into "ekklesia_core_authorization_audit"
|
||||
(action, actor_type, actor_id, subject_user_id, permission_id, grant_id)
|
||||
values ('grant.revoke', 'user', $1, $2, $3, $4)`,
|
||||
[manager, subject, permission, firstGrant.id],
|
||||
);
|
||||
const replacementRows = await safeQuery<{ id: string }>(
|
||||
admin,
|
||||
`insert into "ekklesia_core_grant"
|
||||
(user_id, permission_id, scope, source_kind, source_ref, created_by_actor_type, created_by_actor_id)
|
||||
values ($1, $2, null, 'explicit', null, 'user', $3)
|
||||
returning id`,
|
||||
[subject, permission, manager],
|
||||
);
|
||||
const replacementId = replacementRows[0]?.id as string;
|
||||
expect(replacementId).toBeDefined();
|
||||
|
||||
// Release the cooperative lock and let the stale revoker resume.
|
||||
await safeQuery(lockHolder, "commit");
|
||||
lockHeld = false;
|
||||
|
||||
const outcome = (await withDeadline(staleSettled, 30_000)) as {
|
||||
error?: unknown;
|
||||
status: "fulfilled" | "rejected";
|
||||
};
|
||||
|
||||
// The addressed target no longer exists in the state it was addressed in.
|
||||
expect(outcome.status).toBe("rejected");
|
||||
expect(outcome.error).toBeInstanceOf(AuthorizationStoreError);
|
||||
expect(outcome.error).toMatchObject({ code: "AUTHORIZATION_GRANT_NOT_FOUND" });
|
||||
|
||||
// The replacement it never addressed is untouched, and no audit row claims otherwise.
|
||||
const replacement = await safeQuery<{ revoked_at: Date | null }>(
|
||||
admin,
|
||||
`select revoked_at from "ekklesia_core_grant" where id = $1`,
|
||||
[replacementId],
|
||||
);
|
||||
expect(replacement[0]?.revoked_at).toBeNull();
|
||||
expect(
|
||||
await safeQuery(
|
||||
admin,
|
||||
`select id from "ekklesia_core_authorization_audit"
|
||||
where action = 'grant.revoke' and grant_id = $1`,
|
||||
[replacementId],
|
||||
),
|
||||
).toHaveLength(0);
|
||||
|
||||
// The end state is consistent with a serial order: revoke(G1), create(G2), reject(stale).
|
||||
expect(await store.hasActiveGrant({ now: new Date(), permission, userId: subject })).toBe(
|
||||
true,
|
||||
);
|
||||
const effective = await safeQuery<{ n: number }>(
|
||||
admin,
|
||||
`select count(*)::int as n from "ekklesia_core_grant"
|
||||
where user_id = $1 and permission_id = $2 and revoked_at is null`,
|
||||
[subject, permission],
|
||||
);
|
||||
expect(Number(effective[0]?.n)).toBe(1);
|
||||
const revokeAudit = await safeQuery<{ grant_id: string }>(
|
||||
admin,
|
||||
`select grant_id from "ekklesia_core_authorization_audit" where action = 'grant.revoke'`,
|
||||
);
|
||||
expect(revokeAudit.map((row) => row.grant_id)).toEqual([firstGrant.id]);
|
||||
} finally {
|
||||
// The lock is released first, so a failed assertion above can never leave the stale
|
||||
// revocation blocked forever; every client, pool, promise, and database is then cleaned up.
|
||||
if (lockHeld) {
|
||||
await safeQuery(lockHolder, "rollback").catch(() => undefined);
|
||||
}
|
||||
if (staleSettled) {
|
||||
await withDeadline(staleSettled, 30_000).catch(() => undefined);
|
||||
}
|
||||
await safeEnd(lockHolder).catch(() => undefined);
|
||||
await safeEnd(admin).catch(() => undefined);
|
||||
await handle.close().catch(() => undefined);
|
||||
await dropDatabase(database.name).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { Client } from "pg";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { databaseTlsConnectionOptions } from "../src/database/transport.js";
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Real TLS proof for the production transport policy, exercised through the pinned `pg` client
|
||||
* against a disposable, TLS-enabled PostgreSQL container.
|
||||
*
|
||||
* Every certificate, key, and CA used here is generated at test runtime into an isolated temporary
|
||||
* directory and deleted in `afterAll` — no private-key material is committed to this repository.
|
||||
* The container, the isolated network, and any published loopback port are likewise disposable and
|
||||
* removed in `afterAll` even when an assertion fails.
|
||||
*
|
||||
* ## Two supported topologies
|
||||
*
|
||||
* `EKKLESIA_TEST_JOB_CONTAINER` is the single topology signal, produced by
|
||||
* `compose_job_network_job_container` in `scripts/lib/compose-job-network.sh` and exported by
|
||||
* `scripts/test-migrations-integration.sh`. This harness never re-derives the topology itself.
|
||||
*
|
||||
* - **Direct host** (signal empty): the container publishes a random port bound to `127.0.0.1`
|
||||
* only, and the suite connects through `localhost`.
|
||||
* - **Containerized job** (signal set): the job executes inside a container and drives *sibling*
|
||||
* containers through the runner's Docker daemon. Two host assumptions break there, and both are
|
||||
* avoided rather than worked around:
|
||||
* 1. A port published by the daemon lands on the **runner host's** loopback, which is a
|
||||
* different network namespace from the job container's. Proven: a container published at
|
||||
* `127.0.0.1:32907` by the daemon is `ECONNREFUSED` at that address from inside the job
|
||||
* container. So no port is published at all; both containers join one isolated user-defined
|
||||
* network and talk over container DNS on the standard port.
|
||||
* 2. A bind-mount source path is resolved by the daemon against the **runner host's**
|
||||
* filesystem. A directory created in the job container's `/tmp` does not exist there, so the
|
||||
* daemon silently creates an empty one, the entrypoint's `cp` fails, the container exits,
|
||||
* and `docker port` then reports `No public port '5432' published`. So the certificate and
|
||||
* key are transferred with `docker cp` into the created-but-not-yet-started container, which
|
||||
* crosses the daemon boundary correctly in both topologies.
|
||||
*/
|
||||
const instanceId = `${process.pid}-${randomBytes(4).toString("hex")}`;
|
||||
/** Also the container's DNS name on the isolated network, hence the certificate identity there. */
|
||||
const containerName = `ekklesia-database-tls-${instanceId}`;
|
||||
const networkName = `ekklesia-database-tls-net-${instanceId}`;
|
||||
const jobContainer = (process.env.EKKLESIA_TEST_JOB_CONTAINER ?? "").trim();
|
||||
const containerizedJob = jobContainer !== "";
|
||||
|
||||
let certificateDirectory: string | undefined;
|
||||
let containerCreated = false;
|
||||
let networkCreated = false;
|
||||
let jobContainerAttached = false;
|
||||
/** The host the certificate is issued for, and the only host that may verify successfully. */
|
||||
let identityHost: string | undefined;
|
||||
/** A reachable address of the *same* server that the certificate deliberately does not cover. */
|
||||
let mismatchHost: string | undefined;
|
||||
let databasePort: number | undefined;
|
||||
|
||||
class TlsHarnessError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "TlsHarnessError";
|
||||
this.stack = `${this.name}: ${this.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function openssl(args: readonly string[], cwd: string): Promise<void> {
|
||||
try {
|
||||
await run("openssl", [...args], { cwd });
|
||||
} catch {
|
||||
// Never surface openssl's own stderr: it can echo paths from the harness environment.
|
||||
throw new TlsHarnessError("The disposable TLS material could not be generated.");
|
||||
}
|
||||
}
|
||||
|
||||
async function docker(args: readonly string[]): Promise<string> {
|
||||
const { stdout } = await run("docker", [...args]);
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* The identity Node checks when nothing supplies one. Measured against the pinned driver, not
|
||||
* assumed: for an IP host `pg` skips `options.servername` (`pg/lib/connection.js`) and hands
|
||||
* `tls.connect` a bare socket with no `host`, and `net.Socket._host` is `null` for an IP literal —
|
||||
* so Node falls back to the fixed string `localhost`. A certificate whose only SAN is
|
||||
* `DNS:example.invalid`, reached chain-only over `127.0.0.1`, is rejected with
|
||||
* `Host: localhost. is not in the cert's altnames` — proof that chain-only verification checks this
|
||||
* name rather than the endpoint.
|
||||
*/
|
||||
const NODE_FALLBACK_IDENTITY = "localhost";
|
||||
|
||||
/**
|
||||
* Generates a private CA, a server certificate signed by it, and an unrelated second CA.
|
||||
*
|
||||
* The certificate's SANs are exactly two DNS names and **no IP SAN**:
|
||||
*
|
||||
* - the host this run actually connects through, so the verify-full success case matches a real
|
||||
* identity rather than a coincidence;
|
||||
* - {@link NODE_FALLBACK_IDENTITY}, so the chain-only control below reproduces the same outcome in
|
||||
* both topologies.
|
||||
*
|
||||
* Neither covers the server's IP address, which is what keeps the hostname-mismatch assertion
|
||||
* meaningful: reaching the very same server through its address must fail under the production
|
||||
* policy, whose pinned `checkServerIdentity` demands an `IP:` SAN that is deliberately absent.
|
||||
* On a direct host the two names coincide and the SAN set is the single `DNS:localhost` entry this
|
||||
* suite has always used.
|
||||
*/
|
||||
async function generateCertificates(directory: string, host: string): Promise<void> {
|
||||
const subjectAltNames = [...new Set([host, NODE_FALLBACK_IDENTITY])]
|
||||
.map((name) => `DNS:${name}`)
|
||||
.join(",");
|
||||
await openssl(
|
||||
[
|
||||
"req",
|
||||
"-new",
|
||||
"-x509",
|
||||
"-days",
|
||||
"1",
|
||||
"-nodes",
|
||||
"-out",
|
||||
"ca.crt",
|
||||
"-keyout",
|
||||
"ca.key",
|
||||
"-subj",
|
||||
"/CN=Ekklesia Disposable Test CA",
|
||||
],
|
||||
directory,
|
||||
);
|
||||
await openssl(
|
||||
[
|
||||
"req",
|
||||
"-new",
|
||||
"-nodes",
|
||||
"-out",
|
||||
"server.csr",
|
||||
"-keyout",
|
||||
"server.key",
|
||||
"-subj",
|
||||
`/CN=${host}`,
|
||||
],
|
||||
directory,
|
||||
);
|
||||
await writeFile(path.join(directory, "san.cnf"), `subjectAltName=${subjectAltNames}\n`);
|
||||
await openssl(
|
||||
[
|
||||
"x509",
|
||||
"-req",
|
||||
"-in",
|
||||
"server.csr",
|
||||
"-CA",
|
||||
"ca.crt",
|
||||
"-CAkey",
|
||||
"ca.key",
|
||||
"-CAcreateserial",
|
||||
"-out",
|
||||
"server.crt",
|
||||
"-days",
|
||||
"1",
|
||||
"-extfile",
|
||||
"san.cnf",
|
||||
],
|
||||
directory,
|
||||
);
|
||||
await openssl(
|
||||
[
|
||||
"req",
|
||||
"-new",
|
||||
"-x509",
|
||||
"-days",
|
||||
"1",
|
||||
"-nodes",
|
||||
"-out",
|
||||
"other-ca.crt",
|
||||
"-keyout",
|
||||
"other-ca.key",
|
||||
"-subj",
|
||||
"/CN=Unrelated Test CA",
|
||||
],
|
||||
directory,
|
||||
);
|
||||
// Only the server leaf and its key are ever transferred into the container. Both CA private keys
|
||||
// stay in the harness temporary directory and are deleted with it.
|
||||
const material = path.join(directory, "server-material");
|
||||
await mkdir(material);
|
||||
for (const file of ["server.crt", "server.key"]) {
|
||||
await writeFile(path.join(material, file), await readFile(path.join(directory, file)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the container, transfers the runtime-generated material into it, and starts it. The
|
||||
* certificate/key are copied *inside* the container and re-owned before PostgreSQL starts:
|
||||
* PostgreSQL refuses a key file that is not owned by the database user or root, and `docker cp`
|
||||
* lands the files as root.
|
||||
*/
|
||||
async function startTlsPostgres(directory: string): Promise<void> {
|
||||
const publication = containerizedJob
|
||||
? // No published port: the runner host's loopback is not the job container's loopback.
|
||||
["--network", networkName]
|
||||
: // Docker's random-port form, pinned to loopback so PostgreSQL is never offered on any
|
||||
// other interface.
|
||||
["-p", "127.0.0.1::5432"];
|
||||
|
||||
await docker([
|
||||
"create",
|
||||
"--name",
|
||||
containerName,
|
||||
...publication,
|
||||
"-e",
|
||||
"POSTGRES_DB=ekklesia_tls",
|
||||
"-e",
|
||||
"POSTGRES_USER=ekklesia_tls",
|
||||
"-e",
|
||||
"POSTGRES_PASSWORD=ekklesia_tls",
|
||||
"--entrypoint",
|
||||
"/bin/sh",
|
||||
"postgres:18.4-alpine3.24",
|
||||
"-c",
|
||||
"mkdir -p /tls && cp /certs-src/server.crt /certs-src/server.key /tls/ && " +
|
||||
"chown postgres:postgres /tls/server.* && chmod 600 /tls/server.key && " +
|
||||
"exec docker-entrypoint.sh postgres -c ssl=on -c ssl_cert_file=/tls/server.crt " +
|
||||
"-c ssl_key_file=/tls/server.key",
|
||||
]);
|
||||
containerCreated = true;
|
||||
|
||||
// `docker cp` is executed by the daemon against the container filesystem, so it is correct
|
||||
// whether the source directory lives on the daemon host or only inside the job container. The
|
||||
// destination does not exist, so the source directory's *contents* become `/certs-src`.
|
||||
await docker(["cp", path.join(directory, "server-material"), `${containerName}:/certs-src`]);
|
||||
await docker(["start", containerName]);
|
||||
}
|
||||
|
||||
/** Resolves the endpoint this run connects through, plus a reachable address of the same server
|
||||
* that the certificate identity deliberately does not cover. */
|
||||
async function resolveEndpoint(): Promise<void> {
|
||||
if (!containerizedJob) {
|
||||
const published = await docker(["port", containerName, "5432"]);
|
||||
const match = /^127\.0\.0\.1:(\d+)$/u.exec(published.split("\n")[0]?.trim() ?? "");
|
||||
if (!match) {
|
||||
throw new TlsHarnessError(
|
||||
"The disposable TLS PostgreSQL port is not bound safely to loopback.",
|
||||
);
|
||||
}
|
||||
databasePort = Number(match[1]);
|
||||
mismatchHost = "127.0.0.1";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await connectJobContainerToNetwork())) {
|
||||
throw new TlsHarnessError("The TLS test job could not join the isolated PostgreSQL network.");
|
||||
}
|
||||
const address = await docker([
|
||||
"inspect",
|
||||
"--format",
|
||||
`{{ with index .NetworkSettings.Networks "${networkName}" }}{{ .IPAddress }}{{ end }}`,
|
||||
containerName,
|
||||
]);
|
||||
if (!net.isIPv4(address)) {
|
||||
throw new TlsHarnessError(
|
||||
"The disposable TLS PostgreSQL has no address on the isolated test network.",
|
||||
);
|
||||
}
|
||||
// The same server, reached by address instead of by name — a genuine identity mismatch, not a
|
||||
// weakened assertion.
|
||||
mismatchHost = address;
|
||||
databasePort = 5432;
|
||||
}
|
||||
|
||||
async function connectJobContainerToNetwork(): Promise<boolean> {
|
||||
jobContainerAttached = true;
|
||||
try {
|
||||
await docker(["network", "connect", networkName, jobContainer]);
|
||||
return true;
|
||||
} catch {
|
||||
await docker(["network", "disconnect", networkName, jobContainer]).catch(() => undefined);
|
||||
jobContainerAttached = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Bounded readiness polling — never an arbitrary sleep. Resolves once the server accepts a TLS
|
||||
* connection, or fails after the deadline. */
|
||||
async function waitForTlsReadiness(host: string, port: number, ca: string): Promise<void> {
|
||||
const deadline = Date.now() + 60_000;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
const client = new Client({
|
||||
connectionTimeoutMillis: 3_000,
|
||||
database: "ekklesia_tls",
|
||||
host,
|
||||
password: "ekklesia_tls",
|
||||
port,
|
||||
ssl: { ca, rejectUnauthorized: true },
|
||||
user: "ekklesia_tls",
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
await client.end();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
try {
|
||||
await client.end();
|
||||
} catch {
|
||||
// The failed client is already unusable; nothing to clean up beyond this.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
}
|
||||
throw new TlsHarnessError(
|
||||
`The disposable TLS PostgreSQL did not become ready: ${String(lastError)}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function connectWith(
|
||||
host: string,
|
||||
port: number,
|
||||
ssl: ReturnType<typeof databaseTlsConnectionOptions>,
|
||||
): Promise<{ encrypted: boolean }> {
|
||||
const client = new Client({
|
||||
connectionTimeoutMillis: 5_000,
|
||||
database: "ekklesia_tls",
|
||||
host,
|
||||
password: "ekklesia_tls",
|
||||
port,
|
||||
...(ssl === false ? {} : { ssl }),
|
||||
user: "ekklesia_tls",
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
const result = await client.query<{ encrypted: boolean }>(
|
||||
"SELECT ssl AS encrypted FROM pg_stat_ssl WHERE pid = pg_backend_pid()",
|
||||
);
|
||||
return { encrypted: result.rows[0]?.encrypted === true };
|
||||
} finally {
|
||||
try {
|
||||
await client.end();
|
||||
} catch {
|
||||
// Closing an already-failed client is not itself a test failure.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The trusted CA for the disposable server, read from the harness directory. */
|
||||
async function readCertificateAuthority(file: "ca.crt" | "other-ca.crt"): Promise<string> {
|
||||
if (certificateDirectory === undefined) {
|
||||
throw new TlsHarnessError("The disposable TLS material was never generated.");
|
||||
}
|
||||
return await readFile(path.join(certificateDirectory, file), "utf8");
|
||||
}
|
||||
|
||||
function requireEndpoint(): { identity: string; mismatch: string; port: number } {
|
||||
if (identityHost === undefined || mismatchHost === undefined || databasePort === undefined) {
|
||||
throw new TlsHarnessError("The disposable TLS PostgreSQL endpoint was never resolved.");
|
||||
}
|
||||
return { identity: identityHost, mismatch: mismatchHost, port: databasePort };
|
||||
}
|
||||
|
||||
// Deterministic teardown: every disposable resource this harness created is removed after both
|
||||
// successful and failed runs, container first so the network has no remaining endpoint.
|
||||
afterAll(async () => {
|
||||
if (containerCreated) {
|
||||
await docker(["rm", "-f", "-v", containerName]).catch(() => undefined);
|
||||
}
|
||||
if (jobContainerAttached) {
|
||||
await docker(["network", "disconnect", networkName, jobContainer]).catch(() => undefined);
|
||||
}
|
||||
if (networkCreated) {
|
||||
await docker(["network", "rm", networkName]).catch(() => undefined);
|
||||
}
|
||||
if (certificateDirectory) {
|
||||
// Removes the generated CA, server certificate, and every private key.
|
||||
await rm(certificateDirectory, { force: true, recursive: true }).catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
describe.sequential("production PostgreSQL TLS policy against a real TLS endpoint", () => {
|
||||
it("starts a disposable TLS-enabled PostgreSQL with runtime-generated certificates", async () => {
|
||||
certificateDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-tls-"));
|
||||
if (containerizedJob) {
|
||||
await docker(["network", "create", networkName]);
|
||||
networkCreated = true;
|
||||
}
|
||||
// The certificate identity is generated from the host this run will actually connect through.
|
||||
identityHost = containerizedJob ? containerName : "localhost";
|
||||
await generateCertificates(certificateDirectory, identityHost);
|
||||
await startTlsPostgres(certificateDirectory);
|
||||
await resolveEndpoint();
|
||||
|
||||
const endpoint = requireEndpoint();
|
||||
await waitForTlsReadiness(
|
||||
endpoint.identity,
|
||||
endpoint.port,
|
||||
await readCertificateAuthority("ca.crt"),
|
||||
);
|
||||
expect(endpoint.port).toBeGreaterThan(0);
|
||||
expect(endpoint.mismatch).not.toBe(endpoint.identity);
|
||||
});
|
||||
|
||||
it("connects successfully, and encrypted, when the certificate is trusted and its identity matches the host", async () => {
|
||||
const { identity, port } = requireEndpoint();
|
||||
const ca = await readCertificateAuthority("ca.crt");
|
||||
const ssl = databaseTlsConnectionOptions(
|
||||
`postgresql://ekklesia_tls@${identity}:${port}/ekklesia_tls`,
|
||||
{ certificateAuthority: ca, mode: "verify-full" },
|
||||
);
|
||||
|
||||
const { encrypted } = await connectWith(identity, port, ssl);
|
||||
expect(encrypted).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a certificate that does not chain to the configured CA", async () => {
|
||||
const { identity, port } = requireEndpoint();
|
||||
const otherCa = await readCertificateAuthority("other-ca.crt");
|
||||
const ssl = databaseTlsConnectionOptions(
|
||||
`postgresql://ekklesia_tls@${identity}:${port}/ekklesia_tls`,
|
||||
{ certificateAuthority: otherCa, mode: "verify-full" },
|
||||
);
|
||||
|
||||
await expect(connectWith(identity, port, ssl)).rejects.toMatchObject({
|
||||
code: "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a trusted certificate whose identity does not match the host being connected to", async () => {
|
||||
// The certificate carries no IP SAN, so reaching the identical server via its address is a
|
||||
// genuine hostname mismatch. This is the case the pinned driver does *not* catch on its own:
|
||||
// `pg` sets `servername` only for non-IP hosts, and with it unset Node checks
|
||||
// `NODE_FALLBACK_IDENTITY` instead of the endpoint — so `rejectUnauthorized: true` alone would
|
||||
// accept this connection, as the control below demonstrates.
|
||||
const { mismatch, port } = requireEndpoint();
|
||||
const ca = await readCertificateAuthority("ca.crt");
|
||||
const ssl = databaseTlsConnectionOptions(
|
||||
`postgresql://ekklesia_tls@${mismatch}:${port}/ekklesia_tls`,
|
||||
{ certificateAuthority: ca, mode: "verify-full" },
|
||||
);
|
||||
|
||||
await expect(connectWith(mismatch, port, ssl)).rejects.toMatchObject({
|
||||
code: "ERR_TLS_CERT_ALTNAME_INVALID",
|
||||
});
|
||||
});
|
||||
|
||||
it("proves the gap the policy closes: chain-only verification would accept that same mismatch", async () => {
|
||||
const { identity, mismatch, port } = requireEndpoint();
|
||||
const ca = await readCertificateAuthority("ca.crt");
|
||||
// Exactly what `ssl: { ca, rejectUnauthorized: true }` alone does — no pinned
|
||||
// `checkServerIdentity`. It connects to an address the certificate does not cover, because the
|
||||
// only identity Node had to check was `NODE_FALLBACK_IDENTITY`. The endpoint is never verified
|
||||
// at all, which is why the policy supplies its own check.
|
||||
expect(mismatch).not.toBe(identity);
|
||||
expect(net.isIP(mismatch)).toBeGreaterThan(0);
|
||||
const { encrypted } = await connectWith(mismatch, port, { ca, rejectUnauthorized: true });
|
||||
expect(encrypted).toBe(true);
|
||||
});
|
||||
|
||||
it("cannot satisfy verify-full against a plaintext-only PostgreSQL endpoint", async () => {
|
||||
// The disposable plaintext PostgreSQL the rest of the integration suite uses.
|
||||
const plaintextUrl = process.env.EKKLESIA_MIGRATION_TEST_DATABASE_URL;
|
||||
if (typeof plaintextUrl !== "string" || plaintextUrl.trim() === "") {
|
||||
throw new TlsHarnessError("The plaintext integration database is not configured.");
|
||||
}
|
||||
const parsed = new URL(plaintextUrl);
|
||||
const ca = await readCertificateAuthority("ca.crt");
|
||||
const ssl = databaseTlsConnectionOptions(plaintextUrl, {
|
||||
certificateAuthority: ca,
|
||||
mode: "verify-full",
|
||||
});
|
||||
|
||||
const client = new Client({
|
||||
connectionTimeoutMillis: 5_000,
|
||||
database: parsed.pathname.slice(1),
|
||||
host: parsed.hostname,
|
||||
password: decodeURIComponent(parsed.password),
|
||||
port: Number(parsed.port),
|
||||
...(ssl === false ? {} : { ssl }),
|
||||
user: decodeURIComponent(parsed.username),
|
||||
});
|
||||
try {
|
||||
await expect(client.connect()).rejects.toBeTruthy();
|
||||
} finally {
|
||||
try {
|
||||
await client.end();
|
||||
} catch {
|
||||
// Already unusable.
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,303 @@
|
||||
import tls from "node:tls";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { reportMigrationCommandError } from "../src/cli/migration-command-errors.js";
|
||||
import {
|
||||
assertNoConnectionStringTlsParameters,
|
||||
assertProductionDatabaseTransport,
|
||||
createDatabaseTransportPolicy,
|
||||
databaseHostFromUrl,
|
||||
DatabaseTransportError,
|
||||
databaseTlsConnectionOptions,
|
||||
describeDatabaseTransport,
|
||||
} from "../src/database/transport.js";
|
||||
|
||||
const productionUrl = "postgresql://ekklesia:[email protected]:5432/ekklesia";
|
||||
|
||||
describe("createDatabaseTransportPolicy", () => {
|
||||
it("defaults to certificate-verified TLS in production and plaintext in development", () => {
|
||||
expect(createDatabaseTransportPolicy()).toEqual({ mode: "verify-full" });
|
||||
expect(createDatabaseTransportPolicy({}, { environment: "production" })).toEqual({
|
||||
mode: "verify-full",
|
||||
});
|
||||
expect(createDatabaseTransportPolicy({}, { environment: "development" })).toEqual({
|
||||
mode: "disable",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses plaintext in production", () => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createDatabaseTransportPolicy({ mode: "disable" }, { environment: "production" });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toBeInstanceOf(DatabaseTransportError);
|
||||
expect(captured).toMatchObject({ code: "DATABASE_TLS_REQUIRED_IN_PRODUCTION" });
|
||||
});
|
||||
|
||||
it("rejects every loose, aliased, or coercible mode spelling in both environments", () => {
|
||||
for (const mode of [
|
||||
"VERIFY-FULL",
|
||||
"Verify-Full",
|
||||
" verify-full",
|
||||
"verify-full ",
|
||||
"require", // encryption-only: deliberately unsupported
|
||||
"verify-ca", // no hostname verification: deliberately unsupported
|
||||
"prefer",
|
||||
"no-verify",
|
||||
"allow",
|
||||
"true",
|
||||
true,
|
||||
false,
|
||||
1,
|
||||
0,
|
||||
"",
|
||||
null,
|
||||
]) {
|
||||
for (const environment of ["production", "development"] as const) {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createDatabaseTransportPolicy({ mode }, { environment });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured, `expected ${JSON.stringify(mode)} to be rejected`).toMatchObject({
|
||||
code: "DATABASE_TLS_MODE_INVALID",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts an optional private CA and rejects an empty one", () => {
|
||||
expect(
|
||||
createDatabaseTransportPolicy({ certificateAuthority: "-----BEGIN CERTIFICATE-----" })
|
||||
.certificateAuthority,
|
||||
).toBe("-----BEGIN CERTIFICATE-----");
|
||||
for (const certificateAuthority of ["", " ", 42]) {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createDatabaseTransportPolicy({ certificateAuthority });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toBeInstanceOf(DatabaseTransportError);
|
||||
}
|
||||
});
|
||||
|
||||
it("never carries the offending mode or CA material in the thrown error", () => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createDatabaseTransportPolicy({ certificateAuthority: "SUPER-SECRET-CA", mode: "sslmode=x" });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
const publicText = `${String(captured)}\n${
|
||||
captured instanceof Error ? (captured.stack ?? "") : ""
|
||||
}\n${JSON.stringify(captured)}`;
|
||||
expect(publicText).not.toContain("SUPER-SECRET-CA");
|
||||
expect(publicText).not.toContain("sslmode=x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertNoConnectionStringTlsParameters", () => {
|
||||
it("rejects every TLS parameter the pinned connection-string parser recognizes", () => {
|
||||
for (const parameter of [
|
||||
"ssl=true",
|
||||
"sslmode=disable",
|
||||
"sslmode=require",
|
||||
"sslmode=verify-full",
|
||||
"sslcert=/tmp/c.crt",
|
||||
"sslkey=/tmp/c.key",
|
||||
"sslrootcert=/tmp/ca.crt",
|
||||
"sslpassword=x",
|
||||
"sslnegotiation=direct",
|
||||
"uselibpqcompat=true",
|
||||
"SSLMODE=disable", // case-insensitive
|
||||
]) {
|
||||
let captured: unknown;
|
||||
try {
|
||||
assertNoConnectionStringTlsParameters(`${productionUrl}?${parameter}`);
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured, `expected ${parameter} to be rejected`).toMatchObject({
|
||||
code: "DATABASE_TLS_CONNECTION_STRING_CONFLICT",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("allows an unrelated parameter", () => {
|
||||
expect(() =>
|
||||
assertNoConnectionStringTlsParameters(`${productionUrl}?application_name=ekklesia`),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects a non-PostgreSQL or malformed URL", () => {
|
||||
for (const url of ["", "not-a-url", "mysql://db.example.test/x", "http://db.example.test"]) {
|
||||
let captured: unknown;
|
||||
try {
|
||||
assertNoConnectionStringTlsParameters(url);
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "DATABASE_URL_INVALID" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("databaseTlsConnectionOptions", () => {
|
||||
it("produces no TLS options at all when the policy disables TLS", () => {
|
||||
expect(
|
||||
databaseTlsConnectionOptions("postgresql://127.0.0.1:5432/ekklesia", { mode: "disable" }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("enables chain verification and pins hostname verification to the configured host", () => {
|
||||
const options = databaseTlsConnectionOptions(productionUrl, { mode: "verify-full" });
|
||||
expect(options).not.toBe(false);
|
||||
if (options === false) {
|
||||
throw new Error("expected TLS options");
|
||||
}
|
||||
// There is no configuration path that produces `rejectUnauthorized: false`.
|
||||
expect(options.rejectUnauthorized).toBe(true);
|
||||
expect(typeof options.checkServerIdentity).toBe("function");
|
||||
expect(options.ca).toBeUndefined();
|
||||
});
|
||||
|
||||
it("checks the certificate against the configured host, not against whatever SNI the driver set", () => {
|
||||
// The pinned driver sets `servername` only for non-IP hosts, and Node skips identity checking
|
||||
// entirely when it is unset — so the policy must pin the host itself. This asserts the
|
||||
// supplied callback ignores the passed servername and uses the configured host.
|
||||
const options = databaseTlsConnectionOptions(productionUrl, { mode: "verify-full" });
|
||||
if (options === false || !options.checkServerIdentity) {
|
||||
throw new Error("expected a checkServerIdentity callback");
|
||||
}
|
||||
const spy = vi.spyOn(tls, "checkServerIdentity").mockReturnValue(undefined);
|
||||
try {
|
||||
options.checkServerIdentity("attacker-supplied.example", {} as tls.PeerCertificate);
|
||||
expect(spy).toHaveBeenCalledWith("db.example.test", expect.anything());
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the bare address for an IPv6 host so identity checking sees a usable value", () => {
|
||||
expect(databaseHostFromUrl("postgresql://[2001:db8::1]:5432/ekklesia")).toBe("2001:db8::1");
|
||||
expect(databaseHostFromUrl("postgresql://db.example.test:5432/ekklesia")).toBe(
|
||||
"db.example.test",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes an explicit private CA through unchanged", () => {
|
||||
const certificateAuthority =
|
||||
"-----BEGIN CERTIFICATE-----\nprivate-ca\n-----END CERTIFICATE-----";
|
||||
const options = databaseTlsConnectionOptions(productionUrl, {
|
||||
certificateAuthority,
|
||||
mode: "verify-full",
|
||||
});
|
||||
if (options === false) {
|
||||
throw new Error("expected TLS options");
|
||||
}
|
||||
expect(options.ca).toBe(certificateAuthority);
|
||||
});
|
||||
|
||||
it("refuses to build options for a URL carrying its own TLS parameters", () => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
databaseTlsConnectionOptions(`${productionUrl}?sslmode=disable`, { mode: "verify-full" });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "DATABASE_TLS_CONNECTION_STRING_CONFLICT" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertProductionDatabaseTransport", () => {
|
||||
it("permits verified TLS in production and any validated policy in development", () => {
|
||||
expect(() =>
|
||||
assertProductionDatabaseTransport(productionUrl, { mode: "verify-full" }, "production"),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
assertProductionDatabaseTransport(
|
||||
"postgresql://127.0.0.1:5432/ekklesia",
|
||||
{ mode: "disable" },
|
||||
"development",
|
||||
),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("refuses production plaintext, including for a loopback database — there is no production exception", () => {
|
||||
for (const url of [
|
||||
productionUrl,
|
||||
"postgresql://127.0.0.1:5432/ekklesia",
|
||||
"postgresql://localhost:5432/ekklesia",
|
||||
"postgresql://[::1]:5432/ekklesia",
|
||||
]) {
|
||||
let captured: unknown;
|
||||
try {
|
||||
assertProductionDatabaseTransport(url, { mode: "disable" }, "production");
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured, `expected production plaintext to ${url} to be refused`).toMatchObject({
|
||||
code: "DATABASE_TLS_REQUIRED_IN_PRODUCTION",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("migration CLI transport reporting", () => {
|
||||
it("reports the stable transport code instead of a generic 'failed unexpectedly', and leaks nothing", () => {
|
||||
const captured: string[] = [];
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
|
||||
captured.push(args.map(String).join(" "));
|
||||
});
|
||||
try {
|
||||
for (const url of [
|
||||
`${productionUrl}?sslmode=disable`,
|
||||
"postgresql://ekklesia:[email protected]:5432/ekklesia?ssl=true",
|
||||
]) {
|
||||
try {
|
||||
databaseTlsConnectionOptions(url, { mode: "verify-full" });
|
||||
} catch (error) {
|
||||
reportMigrationCommandError(error);
|
||||
}
|
||||
}
|
||||
try {
|
||||
createDatabaseTransportPolicy({ mode: "disable" }, { environment: "production" });
|
||||
} catch (error) {
|
||||
reportMigrationCommandError(error);
|
||||
}
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
|
||||
const output = captured.join("\n");
|
||||
expect(output).toContain("[DATABASE_TLS_CONNECTION_STRING_CONFLICT]");
|
||||
expect(output).toContain("[DATABASE_TLS_REQUIRED_IN_PRODUCTION]");
|
||||
expect(output).not.toContain("failed unexpectedly");
|
||||
// The connection string, its credentials, and its host never reach operator output.
|
||||
for (const secret of ["hunter2", "db.example.test", "secret", "sslmode=disable"]) {
|
||||
expect(output).not.toContain(secret);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeDatabaseTransport", () => {
|
||||
it("describes the effective mode without revealing any connection detail", () => {
|
||||
expect(describeDatabaseTransport({ mode: "verify-full" })).toBe(
|
||||
"TLS required with certificate and hostname verification.",
|
||||
);
|
||||
expect(
|
||||
describeDatabaseTransport({ certificateAuthority: "SECRET-CA", mode: "verify-full" }),
|
||||
).toBe(
|
||||
"TLS required with certificate and hostname verification (explicit private CA supplied).",
|
||||
);
|
||||
expect(
|
||||
describeDatabaseTransport({ certificateAuthority: "SECRET-CA", mode: "verify-full" }),
|
||||
).not.toContain("SECRET-CA");
|
||||
expect(describeDatabaseTransport({ mode: "disable" })).toBe(
|
||||
"TLS disabled (development/test plaintext).",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
+215
-1
@@ -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:[email protected]:5432/ekklesia") },
|
||||
dependencies(),
|
||||
);
|
||||
|
||||
const transport = check(report, "database.transport");
|
||||
expect(transport.status).toBe("pass");
|
||||
expect(transport.summary).toBe("Database TLS verified");
|
||||
const rendered = JSON.stringify(transport);
|
||||
expect(rendered).not.toContain("hunter2");
|
||||
expect(rendered).not.toContain("db.internal.example");
|
||||
expect(rendered).not.toContain("postgresql://");
|
||||
});
|
||||
|
||||
it("blocks readiness when a production deployment would reach PostgreSQL without verified TLS", async () => {
|
||||
for (const [label, extra] of [
|
||||
["explicit production plaintext", { databaseTransport: { mode: "disable" } }],
|
||||
[
|
||||
"connection-string downgrade",
|
||||
{ databaseUrl: "postgresql://db.example.test/ekklesia?sslmode=disable" },
|
||||
],
|
||||
["malformed mode", { databaseTransport: { mode: "require" } }],
|
||||
] as const) {
|
||||
const report = await diagnoseEkklesia(
|
||||
{ ...options("postgresql://db.example.test/ekklesia"), ...extra },
|
||||
dependencies(),
|
||||
);
|
||||
|
||||
const transport = check(report, "database.transport");
|
||||
expect(transport.status, `expected ${label} to block readiness`).toBe("failure");
|
||||
expect(transport.summary).toBe("Database transport unsafe");
|
||||
expect(report.result).toBe("not-ready");
|
||||
expect(doctorExitCode(report)).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("passes development plaintext only when development is explicitly selected", async () => {
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
...options("postgresql://127.0.0.1:5432/ekklesia"),
|
||||
databaseEnvironment: "development",
|
||||
},
|
||||
dependencies(),
|
||||
);
|
||||
|
||||
const transport = check(report, "database.transport");
|
||||
expect(transport.status).toBe("pass");
|
||||
expect(transport.summary).toContain("disabled");
|
||||
});
|
||||
|
||||
it("passes client-address configuration with direct mode when nothing is configured", async () => {
|
||||
const report = await diagnoseEkklesia(options(), dependencies());
|
||||
|
||||
const clientAddress = check(report, "identity.client-address");
|
||||
expect(clientAddress.status).toBe("pass");
|
||||
expect(clientAddress.summary).toContain("Direct");
|
||||
expect(report.result).toBe("ready-with-warnings");
|
||||
});
|
||||
|
||||
it("passes client-address configuration with an explicitly configured trusted-proxy mode", async () => {
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
...options(),
|
||||
clientAddress: {
|
||||
header: "x-forwarded-for",
|
||||
mode: "trusted-proxy",
|
||||
trustedProxies: ["10.0.0.1", "10.0.0.2"],
|
||||
},
|
||||
},
|
||||
dependencies(),
|
||||
);
|
||||
|
||||
const clientAddress = check(report, "identity.client-address");
|
||||
expect(clientAddress.status).toBe("pass");
|
||||
expect(clientAddress.summary).toContain("Trusted-proxy");
|
||||
expect(clientAddress.details.some((detail) => detail.includes("x-forwarded-for"))).toBe(true);
|
||||
});
|
||||
|
||||
it("fails client-address configuration for an invalid trusted-proxy entry without leaking it", async () => {
|
||||
const hostileEntry = "not-an-ip-secret-value";
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
...options(),
|
||||
clientAddress: {
|
||||
header: "x-forwarded-for",
|
||||
mode: "trusted-proxy",
|
||||
trustedProxies: [hostileEntry],
|
||||
},
|
||||
},
|
||||
dependencies(),
|
||||
);
|
||||
|
||||
const clientAddress = check(report, "identity.client-address");
|
||||
expect(clientAddress.status).toBe("failure");
|
||||
expect(
|
||||
clientAddress.details.some((detail) =>
|
||||
detail.includes("CLIENT_ADDRESS_TRUSTED_PROXY_INVALID"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(JSON.stringify(report)).not.toContain(hostileEntry);
|
||||
expect(report.result).toBe("not-ready");
|
||||
});
|
||||
|
||||
it("fails client-address configuration for an incomplete trusted-proxy mode (missing header)", async () => {
|
||||
const report = await diagnoseEkklesia(
|
||||
{
|
||||
...options(),
|
||||
clientAddress: { mode: "trusted-proxy", trustedProxies: ["10.0.0.1"] },
|
||||
},
|
||||
dependencies(),
|
||||
);
|
||||
|
||||
const clientAddress = check(report, "identity.client-address");
|
||||
expect(clientAddress.status).toBe("failure");
|
||||
expect(report.result).toBe("not-ready");
|
||||
});
|
||||
|
||||
it("never constructs a migration database or Better Auth merely to check identity configuration", async () => {
|
||||
const services = dependencies();
|
||||
await diagnoseEkklesia(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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:[email protected]",
|
||||
"https://:[email protected]",
|
||||
" https://id.example.test ",
|
||||
"https://ID.EXAMPLE.TEST", // non-canonical hostname case
|
||||
"https://id.example.test:443", // redundant default port
|
||||
"http://id.example.test:80",
|
||||
"ftp://id.example.test",
|
||||
"id.example.test",
|
||||
]) {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createIdentityConfig({ ...validInput(), baseUrl }, { environment: "production" });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured, `expected ${JSON.stringify(baseUrl)} to be rejected`).toMatchObject({
|
||||
code: "BASE_URL_INVALID",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("stores the base URL and trusted origins as one identical canonical origin representation", () => {
|
||||
const config = createIdentityConfig(
|
||||
{
|
||||
...validInput(),
|
||||
baseUrl: "https://id.example.test",
|
||||
trustedOrigins: ["https://id.example.test", "https://dashboard.example.test:8443"],
|
||||
},
|
||||
{ environment: "production" },
|
||||
);
|
||||
expect(config.baseUrl).toBe("https://id.example.test");
|
||||
expect(config.trustedOrigins).toEqual([
|
||||
"https://id.example.test",
|
||||
"https://dashboard.example.test:8443",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects every loopback and unspecified-host spelling in production, for the base URL and trusted origins alike", () => {
|
||||
// Canonical spellings the WHATWG parser preserves — these must be rejected as production
|
||||
// policy failures, by the identical classifier on both fields.
|
||||
const canonicalLoopback = [
|
||||
"https://127.0.0.1",
|
||||
"https://127.0.0.2", // the full 127.0.0.0/8 block, not just .1
|
||||
"https://127.255.255.254",
|
||||
"https://[::1]", // IPv6 loopback, compressed
|
||||
"https://[::ffff:7f00:1]", // IPv4-mapped loopback, hexadecimal notation
|
||||
"https://localhost",
|
||||
"https://app.localhost", // RFC 6761 reserved namespace
|
||||
"https://0.0.0.0",
|
||||
"https://[::]", // IPv6 unspecified
|
||||
"https://[::ffff:0:0]", // IPv4-mapped unspecified
|
||||
];
|
||||
for (const value of canonicalLoopback) {
|
||||
let baseCaptured: unknown;
|
||||
try {
|
||||
createIdentityConfig({ ...validInput(), baseUrl: value }, { environment: "production" });
|
||||
} catch (error) {
|
||||
baseCaptured = error;
|
||||
}
|
||||
expect(baseCaptured, `expected base URL ${value} to be rejected`).toMatchObject({
|
||||
code: "BASE_URL_LOOPBACK_IN_PRODUCTION",
|
||||
});
|
||||
|
||||
let originCaptured: unknown;
|
||||
try {
|
||||
createIdentityConfig(
|
||||
{ ...validInput(), trustedOrigins: [value] },
|
||||
{ environment: "production" },
|
||||
);
|
||||
} catch (error) {
|
||||
originCaptured = error;
|
||||
}
|
||||
expect(originCaptured, `expected trusted origin ${value} to be rejected`).toMatchObject({
|
||||
code: "TRUSTED_ORIGIN_LOOPBACK_IN_PRODUCTION",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects non-canonical loopback spellings structurally, never as a misleading production-policy failure", () => {
|
||||
// WHATWG canonicalization folds some of these into `127.0.0.1`; others simply are not the
|
||||
// canonical origin text. Either way the honest answer is "this is not a canonical origin".
|
||||
for (const baseUrl of [
|
||||
"https://127.1",
|
||||
"https://2130706433",
|
||||
"https://0x7f000001",
|
||||
"https://[0:0:0:0:0:0:0:1]", // expanded IPv6 loopback
|
||||
"https://[::ffff:127.0.0.1]", // mixed IPv4-mapped notation
|
||||
"https://LOCALHOST",
|
||||
]) {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createIdentityConfig({ ...validInput(), baseUrl }, { environment: "production" });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured, `expected ${baseUrl} to be structurally rejected`).toMatchObject({
|
||||
code: "BASE_URL_INVALID",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("still accepts canonical loopback development origins", () => {
|
||||
const config = createIdentityConfig(
|
||||
{
|
||||
...validInput(),
|
||||
baseUrl: "http://localhost:3000",
|
||||
trustedOrigins: ["http://localhost:3000", "http://127.0.0.1:5173"],
|
||||
},
|
||||
{ environment: "development" },
|
||||
);
|
||||
expect(config.baseUrl).toBe("http://localhost:3000");
|
||||
expect(config.trustedOrigins).toEqual(["http://localhost:3000", "http://127.0.0.1:5173"]);
|
||||
});
|
||||
|
||||
it("defaults publicSignupEnabled to false when entirely omitted", () => {
|
||||
const config = createIdentityConfig(validInput(), { environment: "production" });
|
||||
expect(config.publicSignupEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts an explicit boolean publicSignupEnabled in development", () => {
|
||||
expect(
|
||||
createIdentityConfig(
|
||||
{ ...developmentInput(), publicSignupEnabled: true },
|
||||
{ environment: "development" },
|
||||
).publicSignupEnabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
createIdentityConfig(
|
||||
{ ...developmentInput(), publicSignupEnabled: false },
|
||||
{ environment: "development" },
|
||||
).publicSignupEnabled,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts the exact strings "true"/"false" in development (environment variables are always strings)', () => {
|
||||
expect(
|
||||
createIdentityConfig(
|
||||
{ ...developmentInput(), publicSignupEnabled: "true" },
|
||||
{ environment: "development" },
|
||||
).publicSignupEnabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
createIdentityConfig(
|
||||
{ ...developmentInput(), publicSignupEnabled: "false" },
|
||||
{ environment: "development" },
|
||||
).publicSignupEnabled,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts an explicitly disabled publicSignupEnabled in production", () => {
|
||||
for (const publicSignupEnabled of [false, "false"]) {
|
||||
expect(
|
||||
createIdentityConfig(
|
||||
{ ...validInput(), publicSignupEnabled },
|
||||
{ environment: "production" },
|
||||
).publicSignupEnabled,
|
||||
).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses to enable public signup in production until durable abuse controls exist", () => {
|
||||
// Ekklesia has no verified email, invitation requirement, CAPTCHA, or durable shared/edge
|
||||
// limiter yet, so a production deployment must not be able to open public registration at all
|
||||
// — not even deliberately. This is a fail-closed configuration error, never a warning.
|
||||
for (const publicSignupEnabled of [true, "true"]) {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createIdentityConfig(
|
||||
{ ...validInput(), publicSignupEnabled },
|
||||
{ environment: "production" },
|
||||
);
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toBeInstanceOf(IdentityConfigError);
|
||||
expect(captured).toMatchObject({ code: "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION" });
|
||||
}
|
||||
});
|
||||
|
||||
it("defaults to production validation for public signup when environment is not specified", () => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createIdentityConfig({ ...validInput(), publicSignupEnabled: "true" });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION" });
|
||||
});
|
||||
|
||||
it("reports a malformed public-signup value as malformed even in production, never as unsupported", () => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createIdentityConfig(
|
||||
{ ...validInput(), publicSignupEnabled: "TRUE" },
|
||||
{ environment: "production" },
|
||||
);
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "PUBLIC_SIGNUP_ENABLED_INVALID" });
|
||||
});
|
||||
|
||||
it("rejects a misspelled, loosely-truthy, or malformed publicSignupEnabled rather than silently defaulting", () => {
|
||||
for (const publicSignupEnabled of ["1", "0", "yes", "no", "TRUE", "True", " true", "", 1, 0]) {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createIdentityConfig({ ...validInput(), publicSignupEnabled });
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "PUBLIC_SIGNUP_ENABLED_INVALID" });
|
||||
}
|
||||
});
|
||||
|
||||
it("never leaks the secret, base URL, or trusted origins through a thrown error", () => {
|
||||
const hostileSecret = "s3cr3t-do-not-log-0123456789abcdef";
|
||||
let captured: unknown;
|
||||
|
||||
@@ -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> = {}): 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<typeof vi.fn> }> = {}) {
|
||||
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<string, unknown>) => {
|
||||
queries.push(`select:${Object.keys(columns).sort().join(",")}`);
|
||||
const builder = {
|
||||
from: vi.fn(() => builder),
|
||||
where: vi.fn(() =>
|
||||
options.sessionProbeError
|
||||
? Promise.reject(options.sessionProbeError)
|
||||
: Promise.resolve([] as unknown[]),
|
||||
),
|
||||
};
|
||||
return builder;
|
||||
});
|
||||
const db = { __fakeDb: true, execute, queries, select };
|
||||
// The runtime only ever uses the narrow surface faked above; the cast keeps the fake honest
|
||||
// about that instead of stubbing all of `NodePgDatabase`, while `queries` stays observable.
|
||||
return db as unknown as IdentityDrizzleDatabase & { readonly queries: string[] };
|
||||
}
|
||||
|
||||
describe("Core identity runtime construction boundary", () => {
|
||||
@@ -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<string, unknown>];
|
||||
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<string, unknown>];
|
||||
expect(options.advanced).toEqual({ disableCSRFCheck: false, disableOriginCheck: false });
|
||||
expect(options.advanced).toEqual({
|
||||
disableCSRFCheck: false,
|
||||
disableOriginCheck: false,
|
||||
ipAddress: { ipAddressHeaders: [TRUSTED_CLIENT_ADDRESS_HEADER] },
|
||||
});
|
||||
});
|
||||
|
||||
it("trusts exactly the one platform-owned client-address header, never Better Auth's own default", () => {
|
||||
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||
|
||||
createIdentityRuntime({ config: validConfig(), db: fakeDb() });
|
||||
|
||||
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
|
||||
const advanced = options.advanced as { ipAddress?: { ipAddressHeaders?: string[] } };
|
||||
expect(advanced.ipAddress?.ipAddressHeaders).toEqual([TRUSTED_CLIENT_ADDRESS_HEADER]);
|
||||
expect(advanced.ipAddress?.ipAddressHeaders).not.toContain("x-forwarded-for");
|
||||
});
|
||||
|
||||
it("configures onAPIError.throw so an unexpected failure escapes Better Auth's own unsanitized fallback logger instead of being swallowed internally", () => {
|
||||
@@ -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: "[email protected]", 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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 [email protected] merges as `Object.assign({}, config, parse(connectionString))`, so the
|
||||
// URL wins over the explicit values above — verified against the real driver, where an explicit
|
||||
// `statement_timeout: 10000` alongside `?statement_timeout=0` produced a server-reported `0`.
|
||||
const constructionsBefore = clientConstructor.mock.calls.length;
|
||||
let captured: unknown;
|
||||
try {
|
||||
createPostgresMigrationDatabase(databaseUrl);
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(PostgresMigrationDatabaseError);
|
||||
expect(captured).toMatchObject({ code: "POSTGRES_MIGRATION_CONFIGURATION_INVALID" });
|
||||
// The rejection never echoes the URL, the offending parameter, or its value.
|
||||
const publicText = `${String(captured)}\n${
|
||||
captured instanceof Error ? (captured.stack ?? "") : ""
|
||||
}`;
|
||||
expect(publicText).not.toContain("example.test");
|
||||
expect(publicText).not.toContain("statement_timeout=");
|
||||
expect(publicText).not.toContain("900000");
|
||||
// Rejected before any client is constructed, so no connection is ever attempted.
|
||||
expect(clientConstructor.mock.calls.length).toBe(constructionsBefore);
|
||||
});
|
||||
|
||||
it("leaves unrelated connection-string parameters alone", async () => {
|
||||
mockClient();
|
||||
|
||||
const database = createPostgresMigrationDatabase(
|
||||
"postgresql://example.test/ekklesia?application_name=ekklesia&lock_timeout=5000",
|
||||
);
|
||||
const session = await database.openSession();
|
||||
|
||||
expect(clientConstructor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ statement_timeout: MIGRATION_OPERATION_TIMEOUT_MS }),
|
||||
);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Pass 4 correction: `pg-connection-string` retains *arbitrary* query keys, so the driver's own
|
||||
* configuration key `connectionTimeoutMillis` reaches `ConnectionParameters` from a URL. It was not
|
||||
* on the rejected list. These pin the corrected boundary; the measured effect of each key on the
|
||||
* real driver and a real server lives in `core/tests/postgres-migration.integration.ts`.
|
||||
*/
|
||||
describe("PostgreSQL migration timeout precedence", () => {
|
||||
it.each([
|
||||
"postgresql://example.test/ekklesia?connectionTimeoutMillis=0",
|
||||
"postgresql://example.test/ekklesia?connectionTimeoutMillis=900000",
|
||||
"postgresql://example.test/ekklesia?connectionTimeoutMillis=",
|
||||
"postgresql://example.test/ekklesia?connectionTimeoutMillis=-1",
|
||||
"postgresql://example.test/ekklesia?connectionTimeoutMillis=abc",
|
||||
"postgresql://example.test/ekklesia?CONNECTIONTIMEOUTMILLIS=0",
|
||||
"postgresql://example.test/ekklesia?connectiontimeoutmillis=0",
|
||||
])("refuses the driver's own connectionTimeoutMillis key in a URL (%s)", (databaseUrl) => {
|
||||
const constructionsBefore = clientConstructor.mock.calls.length;
|
||||
let captured: unknown;
|
||||
try {
|
||||
createPostgresMigrationDatabase(databaseUrl);
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(PostgresMigrationDatabaseError);
|
||||
expect(captured).toMatchObject({ code: "POSTGRES_MIGRATION_CONFIGURATION_INVALID" });
|
||||
// Rejected before any client exists, so no connection is ever attempted.
|
||||
expect(clientConstructor.mock.calls.length).toBe(constructionsBefore);
|
||||
const publicText = `${String(captured)}\n${
|
||||
captured instanceof Error ? (captured.stack ?? "") : ""
|
||||
}`;
|
||||
expect(publicText).not.toContain("example.test");
|
||||
expect(publicText).not.toContain("connectionTimeoutMillis");
|
||||
expect(publicText).not.toContain("900000");
|
||||
});
|
||||
|
||||
it("also refuses it alongside a narrower explicit test override", () => {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createPostgresMigrationDatabase(
|
||||
"postgresql://example.test/ekklesia?connectionTimeoutMillis=0",
|
||||
{ connectionTimeoutMs: 300, operationTimeoutMs: 300 },
|
||||
);
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "POSTGRES_MIGRATION_CONFIGURATION_INVALID" });
|
||||
});
|
||||
|
||||
it("still accepts unrelated parameters and produces the exact expected client configuration", async () => {
|
||||
const client = {
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
end: vi.fn().mockResolvedValue(undefined),
|
||||
on: vi.fn(),
|
||||
};
|
||||
clientConstructor.mockImplementation(
|
||||
class {
|
||||
readonly connect = client.connect;
|
||||
readonly end = client.end;
|
||||
readonly on = client.on;
|
||||
} as unknown as () => typeof client,
|
||||
);
|
||||
|
||||
const url = "postgresql://example.test/ekklesia?application_name=ekklesia&lock_timeout=5000";
|
||||
const session = await createPostgresMigrationDatabase(url).openSession();
|
||||
|
||||
expect(clientConstructor).toHaveBeenLastCalledWith({
|
||||
connectionString: url,
|
||||
connectionTimeoutMillis: MIGRATION_CONNECTION_TIMEOUT_MS,
|
||||
query_timeout: MIGRATION_OPERATION_TIMEOUT_MS,
|
||||
statement_timeout: MIGRATION_OPERATION_TIMEOUT_MS,
|
||||
});
|
||||
|
||||
await session.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import 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<void>((resolve, reject) => {
|
||||
listener.once("error", reject);
|
||||
listener.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
const address = listener.address();
|
||||
if (!address || typeof address !== "object") {
|
||||
throw harnessError("The blackhole listener has no bound address.");
|
||||
}
|
||||
|
||||
// A short, test-only override; production keeps MIGRATION_CONNECTION_TIMEOUT_MS unchanged.
|
||||
const blackholeDatabase = createPostgresMigrationDatabase(
|
||||
`postgresql://ekklesia:[email protected]:${address.port}/ekklesia`,
|
||||
{ connectionTimeoutMs: 300, operationTimeoutMs: 300 },
|
||||
);
|
||||
|
||||
const startedAt = Date.now();
|
||||
let captured: unknown;
|
||||
try {
|
||||
await blackholeDatabase.openSession();
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
|
||||
expect(captured).toBeInstanceOf(PostgresMigrationDatabaseError);
|
||||
expect(captured).toMatchObject({ code: "POSTGRES_MIGRATION_CONNECTION_FAILED" });
|
||||
// Finite, and nowhere near an unbounded wait.
|
||||
expect(Date.now() - startedAt).toBeLessThan(10_000);
|
||||
const publicText = `${String(captured)}\n${(captured as Error).stack ?? ""}`;
|
||||
expect(publicText).not.toContain("127.0.0.1");
|
||||
expect(publicText).not.toContain("ekklesia:ekklesia");
|
||||
expect(publicText).not.toContain(String(address.port));
|
||||
} finally {
|
||||
for (const socket of sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
listener.close(() => resolve());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("aborts a migration that exceeds the operation deadline and commits no history row", async () => {
|
||||
await withIsolatedDatabase("operation_timeout", async (database) => {
|
||||
const slow = migration("people", 0, {
|
||||
sql: "SELECT pg_sleep(5);",
|
||||
tag: "0000_slow",
|
||||
});
|
||||
// The operation deadline is the behavior under test, so a bounded server-side delay is the
|
||||
// only way to exercise it.
|
||||
const migrationDatabase = createPostgresMigrationDatabase(database.url, {
|
||||
connectionTimeoutMs: 5_000,
|
||||
operationTimeoutMs: 250,
|
||||
});
|
||||
|
||||
const error = await captureRunnerError(
|
||||
applyModuleMigrations({
|
||||
aggregate: aggregate({ id: "people", migrations: [slow] }),
|
||||
database: migrationDatabase,
|
||||
}),
|
||||
"MIGRATION_EXECUTION_FAILED",
|
||||
);
|
||||
|
||||
expect(error.migrationIdentity).toBe(slow.identity);
|
||||
const publicText = `${String(error)}\n${error.stack ?? ""}`;
|
||||
expect(publicText).not.toContain("pg_sleep");
|
||||
expect(publicText).not.toContain(database.name);
|
||||
|
||||
// Nothing was committed: the transaction rolled back with its history insert.
|
||||
const history = await safeQuery<{ identity: string }>(
|
||||
database.client,
|
||||
`select identity from public.ekklesia_core_migrations`,
|
||||
).catch(() => undefined);
|
||||
expect(history?.rows ?? []).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("executes marker bytes inside literals, dollar-quoted bodies, and comments intact", async () => {
|
||||
await withIsolatedDatabase("intact_sql", async (database) => {
|
||||
const marker = "--> statement-breakpoint";
|
||||
const sql = [
|
||||
`CREATE TABLE "people_notes" ("id" integer primary key, "body" text not null);`,
|
||||
marker,
|
||||
`INSERT INTO "people_notes" ("id", "body") VALUES (1, 'keep this: ${marker} intact');`,
|
||||
marker,
|
||||
`CREATE FUNCTION people_touch() RETURNS integer AS $$`,
|
||||
`BEGIN`,
|
||||
` -- ${marker}`,
|
||||
` PERFORM 1;`,
|
||||
` RETURN 42;`,
|
||||
`END;`,
|
||||
`$$ LANGUAGE plpgsql;`,
|
||||
marker,
|
||||
`/* block comment with ${marker} */`,
|
||||
`CREATE TABLE "odd${marker}name" ("id" integer);`,
|
||||
].join("\n");
|
||||
const intact = migration("people", 0, { sql, tag: "0000_intact" });
|
||||
|
||||
const result = await applyModuleMigrations({
|
||||
aggregate: aggregate({ id: "people", migrations: [intact] }),
|
||||
database: createPostgresMigrationDatabase(database.url),
|
||||
});
|
||||
expect(result.appliedCount).toBe(1);
|
||||
|
||||
// The literal survived byte-for-byte, so the marker was never treated as a split point.
|
||||
const stored = await safeQuery<{ body: string }>(
|
||||
database.client,
|
||||
`select body from "people_notes" where id = 1`,
|
||||
);
|
||||
expect(stored.rows[0]?.body).toBe(`keep this: ${marker} intact`);
|
||||
|
||||
// The dollar-quoted body executed as one function, not as fragments.
|
||||
const called = await safeQuery<{ result: number }>(
|
||||
database.client,
|
||||
`select people_touch() as result`,
|
||||
);
|
||||
expect(Number(called.rows[0]?.result)).toBe(42);
|
||||
|
||||
// The quoted identifier kept the marker in its name.
|
||||
const relations = await safeQuery<{ table_name: string }>(
|
||||
database.client,
|
||||
`select table_name from information_schema.tables
|
||||
where table_schema = 'public' and table_name like 'odd%'`,
|
||||
);
|
||||
expect(relations.rows.map((row) => row.table_name)).toEqual([`odd${marker}name`]);
|
||||
});
|
||||
});
|
||||
|
||||
it("rolls back the whole multi-statement migration when a later statement fails", async () => {
|
||||
await withIsolatedDatabase("intact_rollback", async (database) => {
|
||||
const failing = migration("people", 0, {
|
||||
sql: [
|
||||
`CREATE TABLE "people_partial" ("id" integer primary key);`,
|
||||
`--> statement-breakpoint`,
|
||||
`INSERT INTO "people_partial" ("id") VALUES (1);`,
|
||||
`--> statement-breakpoint`,
|
||||
`INSERT INTO "people_partial" ("id") VALUES (1);`,
|
||||
].join("\n"),
|
||||
tag: "0000_rollback",
|
||||
});
|
||||
|
||||
const error = await captureRunnerError(
|
||||
applyModuleMigrations({
|
||||
aggregate: aggregate({ id: "people", migrations: [failing] }),
|
||||
database: createPostgresMigrationDatabase(database.url),
|
||||
}),
|
||||
"MIGRATION_EXECUTION_FAILED",
|
||||
);
|
||||
expect(error.migrationIdentity).toBe(failing.identity);
|
||||
|
||||
// The earlier statements in the same batch rolled back with it.
|
||||
const relations = await safeQuery(
|
||||
database.client,
|
||||
`select table_name from information_schema.tables
|
||||
where table_schema = 'public' and table_name = 'people_partial'`,
|
||||
);
|
||||
expect(relations.rows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Pass 4 correction: what each timeout-shaped connection-string key and inherited environment
|
||||
* variable *actually* does to the pinned `[email protected]`, measured rather than inferred from its merge.
|
||||
*
|
||||
* The distinction matters: the boundary rejects several keys, but only one of them genuinely
|
||||
* weakens the selected policy. Documenting a key as "overrides the explicit field" when it does not
|
||||
* is the kind of claim this suite exists to prevent.
|
||||
*
|
||||
* Every listener, socket, client, and disposable database is closed in `finally`.
|
||||
*/
|
||||
describe.sequential("live PostgreSQL migration timeout precedence", () => {
|
||||
const productionClientFields = Object.freeze({
|
||||
connectionTimeoutMillis: 10_000,
|
||||
query_timeout: 10_000,
|
||||
statement_timeout: 10_000,
|
||||
});
|
||||
|
||||
it("does not let a URL weaken the effective connect timer, whichever spelling is used", async () => {
|
||||
const sockets: net.Socket[] = [];
|
||||
const listener = net.createServer((socket) => {
|
||||
sockets.push(socket);
|
||||
});
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
listener.once("error", reject);
|
||||
listener.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
const address = listener.address();
|
||||
if (!address || typeof address !== "object") {
|
||||
throw harnessError("The blackhole listener has no bound address.");
|
||||
}
|
||||
const base = `postgresql://ekklesia:[email protected]:${address.port}/ekklesia`;
|
||||
|
||||
// A short explicit deadline stands in for the production 10 s so the measurement is fast.
|
||||
for (const query of [
|
||||
"",
|
||||
"?connectionTimeoutMillis=0",
|
||||
"?connectionTimeoutMillis=900000",
|
||||
"?connect_timeout=0",
|
||||
]) {
|
||||
const client = new Client({
|
||||
connectionString: `${base}${query}`,
|
||||
connectionTimeoutMillis: 500,
|
||||
});
|
||||
client.on("error", () => undefined);
|
||||
const startedAt = Date.now();
|
||||
let settled = false;
|
||||
try {
|
||||
await client.connect();
|
||||
} catch {
|
||||
settled = true;
|
||||
}
|
||||
const elapsed = Date.now() - startedAt;
|
||||
try {
|
||||
await client.end();
|
||||
} catch {
|
||||
// Already unusable.
|
||||
}
|
||||
|
||||
// The pure-JS client reads `connectionTimeoutMillis` from the object handed to its
|
||||
// constructor, before any connection-string merge; the merged value only reaches
|
||||
// `ConnectionParameters.connect_timeout`, which solely feeds `getLibpqConnectionString()`.
|
||||
expect(settled, `expected the connect timer to fire for ${JSON.stringify(query)}`).toBe(
|
||||
true,
|
||||
);
|
||||
expect(elapsed, `unexpected timer for ${JSON.stringify(query)}`).toBeLessThan(3_000);
|
||||
}
|
||||
|
||||
// ...but the driver-visible field really is polluted, which is why the boundary rejects the
|
||||
// key rather than relying on the pure-JS path staying this way. `connectionParameters` is
|
||||
// real on the pinned client but absent from its published types, so it is read through a
|
||||
// narrow structural view rather than asserted away.
|
||||
const connectTimeoutOf = (connectionString: string): string => {
|
||||
const client = new Client({ connectionString, ...productionClientFields }) as unknown as {
|
||||
readonly connectionParameters: { readonly connect_timeout: unknown };
|
||||
};
|
||||
(client as unknown as Client).on("error", () => undefined);
|
||||
return String(client.connectionParameters.connect_timeout);
|
||||
};
|
||||
expect(connectTimeoutOf(`${base}?connectionTimeoutMillis=0`)).toBe("0");
|
||||
expect(connectTimeoutOf(base)).toBe("10");
|
||||
} finally {
|
||||
for (const socket of sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
listener.close(() => resolve());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("records what each timeout source does to the real server's statement_timeout", async () => {
|
||||
await withIsolatedDatabase("timeout_sources", async (database) => {
|
||||
async function effectiveStatementTimeout(
|
||||
query: string,
|
||||
environment: Readonly<Record<string, string>> = {},
|
||||
): Promise<string> {
|
||||
const saved = new Map<string, string | undefined>();
|
||||
for (const [key, value] of Object.entries(environment)) {
|
||||
saved.set(key, process.env[key]);
|
||||
process.env[key] = value;
|
||||
}
|
||||
const client = new Client({
|
||||
connectionString: `${database.url}${query}`,
|
||||
...productionClientFields,
|
||||
});
|
||||
client.on("error", () => undefined);
|
||||
try {
|
||||
await client.connect();
|
||||
const result = await client.query<{ statement_timeout: string }>(
|
||||
"show statement_timeout",
|
||||
);
|
||||
return result.rows[0]?.statement_timeout ?? "unknown";
|
||||
} catch {
|
||||
return "connection failed";
|
||||
} finally {
|
||||
try {
|
||||
await client.end();
|
||||
} catch {
|
||||
// Already unusable.
|
||||
}
|
||||
for (const [key, value] of saved) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The baseline the policy selects.
|
||||
expect(await effectiveStatementTimeout("")).toBe("10s");
|
||||
|
||||
// The one key that genuinely wins, and the reason it must stay rejected.
|
||||
expect(await effectiveStatementTimeout("?statement_timeout=0")).toBe("0");
|
||||
expect(await effectiveStatementTimeout("?statement_timeout=900000")).toBe("15min");
|
||||
|
||||
// `query_timeout` from the URL *does* override the explicit field, but it does not weaken the
|
||||
// bound — it breaks the client. `client.query` computes its read timer as
|
||||
// `queryConfig.query_timeout || connectionParameters.query_timeout`, and the parsed URL value
|
||||
// is the *string* `"0"`, which is truthy, so every query gets a 0 ms read timer and fails
|
||||
// nondeterministically ("Query read timeout") depending on which wins the race. The
|
||||
// deterministic, assertable fact is the parameter the URL installed.
|
||||
const racyClient = new Client({
|
||||
connectionString: `${database.url}?query_timeout=0`,
|
||||
...productionClientFields,
|
||||
}) as unknown as { readonly connectionParameters: { readonly query_timeout: unknown } };
|
||||
(racyClient as unknown as Client).on("error", () => undefined);
|
||||
expect(racyClient.connectionParameters.query_timeout).toBe("0");
|
||||
|
||||
// `options` does *not* override the named startup parameter — including a backslash-escaped
|
||||
// spelling — so rejecting it is unsupported-configuration hygiene, not a precedence fix.
|
||||
expect(
|
||||
await effectiveStatementTimeout(`?options=${encodeURIComponent("-c statement_timeout=0")}`),
|
||||
).toBe("10s");
|
||||
expect(
|
||||
await effectiveStatementTimeout(
|
||||
`?options=${encodeURIComponent("-c statement_timeout=900s")}`,
|
||||
),
|
||||
).toBe("10s");
|
||||
expect(
|
||||
await effectiveStatementTimeout(
|
||||
`?options=${encodeURIComponent("-c statement_timeout\\=0")}`,
|
||||
),
|
||||
).toBe("10s");
|
||||
|
||||
// Inherited environment cannot reach the selected policy either.
|
||||
expect(await effectiveStatementTimeout("", { PGOPTIONS: "-c statement_timeout=0" })).toBe(
|
||||
"10s",
|
||||
);
|
||||
expect(await effectiveStatementTimeout("", { PGCONNECT_TIMEOUT: "0" })).toBe("10s");
|
||||
|
||||
// An unrelated parameter changes nothing.
|
||||
expect(await effectiveStatementTimeout("?application_name=ekklesia")).toBe("10s");
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses every rejected key at the shared boundary before a client is constructed", async () => {
|
||||
await withIsolatedDatabase("timeout_boundary", async (database) => {
|
||||
for (const query of [
|
||||
"?connectionTimeoutMillis=0",
|
||||
"?connectionTimeoutMillis=900000",
|
||||
"?connect_timeout=0",
|
||||
"?query_timeout=0",
|
||||
"?statement_timeout=0",
|
||||
`?options=${encodeURIComponent("-c statement_timeout=0")}`,
|
||||
]) {
|
||||
let captured: unknown;
|
||||
try {
|
||||
createPostgresMigrationDatabase(`${database.url}${query}`);
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured, `expected ${query} rejected`).toBeInstanceOf(
|
||||
PostgresMigrationDatabaseError,
|
||||
);
|
||||
expect(captured).toMatchObject({ code: "POSTGRES_MIGRATION_CONFIGURATION_INVALID" });
|
||||
const publicText = `${String(captured)}\n${(captured as Error).stack ?? ""}`;
|
||||
expect(publicText).not.toContain(database.name);
|
||||
expect(publicText).not.toContain("127.0.0.1");
|
||||
}
|
||||
|
||||
// The clean URL the commands actually use still works, end to end, with the real defaults.
|
||||
const applied = await applyModuleMigrations({
|
||||
aggregate: aggregate({
|
||||
id: "people",
|
||||
migrations: [migration("people", 0, { sql: "SELECT 1;", tag: "0000_ok" })],
|
||||
}),
|
||||
database: createPostgresMigrationDatabase(database.url),
|
||||
});
|
||||
expect(applied.appliedCount).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,6 +90,16 @@ aggregate order, and its history row is inserted only after that migration succe
|
||||
failure rolls back every schema change and history insert from the current invocation. Migrations
|
||||
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;
|
||||
|
||||
@@ -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 <id>
|
||||
--confirm-user-id <same-id>` (`core/src/cli/bootstrap-platform-owner.ts`,
|
||||
|
||||
+9
-1
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
+217
@@ -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.
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
# 0019: Durable authorization history and grant management
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-07-26
|
||||
- Amends: [0013](0013-core-authorization-and-owner-bootstrap.md)
|
||||
- Extends: [0017](0017-public-request-perimeter-hardening.md), [0018](0018-identity-session-resolution-and-database-transport.md)
|
||||
|
||||
## Context
|
||||
|
||||
Phase 5D passes 1 and 2 closed the public request perimeter and the identity/database transport
|
||||
behind it. Pass 3 addresses the authorization layer those protect: whether security history
|
||||
survives, whether the one-time bootstrap that creates a platform's first administrator is really
|
||||
one-time, and whether explicit-grant creation, revocation, validation, and listing are unambiguous
|
||||
and bounded. Ten defects were reproduced against the accepted pass-2 tree before anything changed.
|
||||
|
||||
**Security history did not survive.** `ekklesia_core_authorization_audit.subject_user_id` carried a
|
||||
cascading foreign key to the live Better Auth user row. Deleting a user with two audit rows left
|
||||
zero: the record of what that user had been granted, by whom, and when it was revoked disappeared
|
||||
at precisely the moment it mattered most.
|
||||
|
||||
**Bootstrap's single use was not durable.** "Already consumed" meant "a row exists in
|
||||
`ekklesia_core_platform_owner`" — and that table also cascades on user deletion. Deleting the
|
||||
bootstrapped owner's user row removed the owner row *and* its audit evidence, after which
|
||||
`bootstrapPlatformOwner` succeeded again for a different user. There was no state independent of
|
||||
user lifecycle recording that bootstrap had ever run.
|
||||
|
||||
**Equivalent grants multiplied silently.** Two identical `createGrant` calls produced two grants;
|
||||
five concurrent identical calls produced five. Revoking one returned `success` while an equivalent
|
||||
issuance kept authorizing the same user — an operator could believe they had removed access that
|
||||
was still live.
|
||||
|
||||
**The request boundary leaked into the database.** The generic HTTP API accepted a `scope` even
|
||||
though no route middleware supplies one to the evaluator, so a scoped grant persisted, appeared in
|
||||
listings, and could authorize nothing. `GrantId` accepted any 1–128-character string, so a
|
||||
malformed path parameter reached PostgreSQL's own UUID comparison and returned an indistinguishable
|
||||
`503`. `expiresAt` went through bare `new Date()`, which silently normalized `2026-02-31` to March
|
||||
3rd and accepted `"March 5, 2027"`, date-only, offset, and millisecond-free forms; a past
|
||||
expiration was persisted as a grant that was dead on arrival. Listing had no bound at all and
|
||||
returned a user's entire authorization history in one response.
|
||||
|
||||
## Decision
|
||||
|
||||
### Security history is retained, not referential
|
||||
|
||||
`ekklesia_core_authorization_audit.subject_user_id` stays a non-null `text` column and loses its
|
||||
foreign key to `ekklesia_core_user`. It is now a **historical snapshot**: an identifier that may
|
||||
name a user row which no longer exists. No name, email, or other profile data is copied into
|
||||
authorization tables — a deleted user's audit history says what happened, not who they were. The
|
||||
subject index is preserved, and the Drizzle relation to the user table is deliberately removed
|
||||
because modelling a retained snapshot as a live relation would misdescribe the data.
|
||||
|
||||
Live rows still cascade on user deletion, which is intended: deleting a user removes their grants
|
||||
and their platform-owner row, so nothing continues authorizing a principal that no longer exists.
|
||||
Only the *history* is exempt.
|
||||
|
||||
**Append-only remains an application/store convention, not a database guarantee.** Core's
|
||||
authorization store exposes no update or delete path for audit rows. Nothing in PostgreSQL enforces
|
||||
that: no role, trigger, or rule prevents a database administrator, a future migration, or anything
|
||||
else holding the same credentials from modifying them. This ADR claims exactly the property that
|
||||
exists.
|
||||
|
||||
### Bootstrap consumption is its own durable record
|
||||
|
||||
`ekklesia_core_platform_owner_bootstrap` is a dedicated single-row table meaning "platform-owner
|
||||
bootstrap was successfully consumed." Its `id` is pinned by a check constraint to the literal
|
||||
`'singleton'`, so the primary key makes a second row structurally impossible rather than merely
|
||||
unwritten by convention. It has **no foreign key to any user**, and Core never deletes from it, so
|
||||
it survives owner revocation and user deletion alike. An optional `consumed_for_user_id` records
|
||||
which user the original bootstrap made an owner, in the same retained-identifier style as the audit
|
||||
subject.
|
||||
|
||||
`bootstrapPlatformOwner` consults and claims that record — never the continued existence of a
|
||||
platform-owner row. The claim, the target-user check, the owner insert, and the audit entry are one
|
||||
transaction, serialized by the existing transaction-scoped advisory lock, so exactly one of
|
||||
concurrent first attempts succeeds and the other receives
|
||||
`PLATFORM_OWNER_BOOTSTRAP_ALREADY_CONSUMED`. Consumption is tied to *success*: a missing target
|
||||
user (`PLATFORM_OWNER_BOOTSTRAP_USER_NOT_FOUND`), a constraint violation, or any other failure
|
||||
rolls the sentinel back with everything else, leaving the one legitimate first use available.
|
||||
|
||||
There is deliberately no reset command, deletion path, HTTP endpoint, environment override, force
|
||||
flag, or second bootstrap mechanism. The operational consequence is explicit: **deleting the last
|
||||
platform owner does not reactivate bootstrap.** Re-establishing an owner after that is a
|
||||
separately designed owner-management flow that does not exist yet.
|
||||
|
||||
Core migration `0002_authorization_integrity` backfills the sentinel for any installation that
|
||||
already holds a platform-owner row — active or revoked — so an existing deployment cannot gain a
|
||||
fresh first use by upgrading. It deliberately leaves `consumed_for_user_id` null, because a backfill
|
||||
cannot know which of several historical owner rows the original single use targeted and guessing
|
||||
would fabricate history. An installation that never had an owner is left untouched, because a
|
||||
legitimately never-bootstrapped deployment must retain its one first use.
|
||||
|
||||
**One upgrade limitation is irrecoverable and is stated rather than papered over.** If an operator
|
||||
manually deleted the bootstrapped owner's user row *before* this migration, the old cascade already
|
||||
erased both the owner row and its audit evidence, and the migration has nothing left to detect. Such
|
||||
an installation backfills as never-bootstrapped and retains one bootstrap use. At the accepted
|
||||
checkpoint no supported Better Auth or API user-deletion path exists that could produce this state;
|
||||
it requires direct database access. The migration cannot reconstruct an event for which no durable
|
||||
evidence remains, and does not pretend to.
|
||||
|
||||
### Grant equivalence is exactly five values
|
||||
|
||||
Two grant issuances are the same authorization source when all five are equal, with `NULL` equal
|
||||
only to `NULL`:
|
||||
|
||||
1. `user_id`
|
||||
2. `permission_id`
|
||||
3. `scope`
|
||||
4. `source_kind`
|
||||
5. `source_ref`
|
||||
|
||||
`expires_at`, creation metadata, actor metadata, and the grant id deliberately do **not**
|
||||
distinguish them. Re-issuing the identical authority with a different expiry, or by a different
|
||||
administrator, is the same grant — not a second one.
|
||||
|
||||
Creation fails with the stable `AUTHORIZATION_GRANT_ALREADY_EXISTS` when an equivalent grant is
|
||||
currently effective (`revoked_at IS NULL` and `expires_at IS NULL OR expires_at > now()`). A revoked
|
||||
or already-expired equivalent does not block a fresh issuance. A rejected duplicate creates no grant
|
||||
and no audit row.
|
||||
|
||||
**Conflict, not idempotent return.** Returning the existing grant would silently answer a different
|
||||
question than the caller asked — the caller wanted a new authorization recorded with their own
|
||||
actor and expiry, and telling them "done" while recording neither is the ambiguity this pass exists
|
||||
to remove. An explicit 409 lets an operator see that the authority already exists and decide.
|
||||
|
||||
### Serialization is database-backed, not process-local
|
||||
|
||||
Every create and revoke touching one equivalence tuple takes a transaction-scoped
|
||||
`pg_advisory_xact_lock` keyed by a 64-bit hash of that tuple encoded as a JSON array — which keeps
|
||||
`NULL` distinct from the empty string and makes separator forgery through a crafted scope or source
|
||||
reference impossible. A hash collision is harmless by construction: it can only make two unrelated
|
||||
tuples serialize against each other, never make a real conflict go unnoticed, because the
|
||||
authoritative decision is always an exact SQL predicate and never the hash.
|
||||
|
||||
A process-local mutex would not do this — nothing constrains the number of API processes — and
|
||||
neither would an unlocked check-then-insert. `pg_advisory_xact_lock(bigint)` uses PostgreSQL's
|
||||
single-key advisory-lock space, verified disjoint from the two-key space the migration runner and
|
||||
platform-owner bootstrap use, so grant serialization can never block or be blocked by either.
|
||||
|
||||
A **time-dependent partial unique index** (`... WHERE revoked_at IS NULL AND expires_at > now()`)
|
||||
was rejected outright: `now()` is not immutable, so PostgreSQL cannot index on it, and any
|
||||
approximation would either over-constrain legitimate re-issuance after expiry or silently stop
|
||||
enforcing.
|
||||
|
||||
### Revocation resolves the whole equivalence class
|
||||
|
||||
Revocation is still addressed by one grant id and retains the explicit-versus-relationship source
|
||||
guard. Once the target is found, one successful revocation revokes **every** currently unrevoked
|
||||
issuance sharing its equivalence tuple — including expired-but-unrevoked legacy equivalents, so the
|
||||
history is left in one unambiguous state — and inserts one `grant.revoke` audit row per grant
|
||||
actually revoked, each carrying that row's own grant id. All updates and audit inserts are one
|
||||
transaction. Unknown, already-revoked, and wrong-source ids keep the existing not-found behavior,
|
||||
and the public success response remains `200 {"success":true}`.
|
||||
|
||||
Historical duplicates are **not** silently revoked by migration. The store now prevents creating
|
||||
another effective equivalent, and revocation removes all equivalents when an operator explicitly
|
||||
revokes one. Choosing a historical "winner" or changing live authorization inside a data migration,
|
||||
with no operator action, is not something this pass will do.
|
||||
|
||||
### The generic HTTP surface creates unscoped explicit grants only
|
||||
|
||||
`POST /api/v1/grants` rejects the presence of a `scope` field — including `null`, empty, malformed,
|
||||
and otherwise valid scope strings — with `400 {"code":"INVALID_REQUEST"}` before any store mutation.
|
||||
Core's `AuthorizationScope`, the evaluator's exact-scope semantics, and the store's scope column are
|
||||
all unchanged and remain available to trusted internal callers and to future server-owned scope
|
||||
work; listing and serialization still show a scope on historical or internally created grants.
|
||||
|
||||
No route-parameter-to-scope resolver is added in this pass, and no module route declaration or
|
||||
handler context changes. Accepting a scope that nothing can evaluate would be offering a switch that
|
||||
is not connected to anything.
|
||||
|
||||
### Canonical identifiers and future-only expiration
|
||||
|
||||
`GrantId` accepts exactly the canonical lowercase hyphenated UUID PostgreSQL emits (`8-4-4-4-12`
|
||||
lowercase hexadecimal), with no trimming. Uppercase, whitespace-padded, shortened, overlong, and
|
||||
non-UUID values are rejected rather than repaired, so exactly one spelling of any grant is valid and
|
||||
a malformed id is a `400` decided after authorization but strictly before any store or database
|
||||
call. A canonical id that does not exist still returns the sanitized `404`.
|
||||
|
||||
`expiresAt` accepts exactly `YYYY-MM-DDTHH:mm:ss.sssZ`, byte-for-byte equal to the parsed `Date`'s
|
||||
own `toISOString()`, and must be strictly later than the authoritative acceptance clock — equal-to-
|
||||
now and past are invalid. The round-trip comparison is what rejects normalized impossible dates.
|
||||
The Core store independently rejects an invalid or non-future `Date` before any SQL, using the
|
||||
stable internal `AUTHORIZATION_GRANT_EXPIRATION_INVALID`, so trusted internal callers get the same
|
||||
guarantee; the public HTTP response stays exactly `400 {"code":"INVALID_REQUEST"}` with
|
||||
`Cache-Control: no-store`. A rejected expiration creates no grant and no audit row. A deterministic
|
||||
clock is injectable only at the narrow Core store and API route factory seams needed for tests;
|
||||
production leaves it unset. No maximum lifetime, renewal workflow, scheduler, or background
|
||||
expiration job is introduced.
|
||||
|
||||
### Listing is bounded keyset pagination with an opaque, unsigned cursor
|
||||
|
||||
`GET /api/v1/grants?userId=<id>&limit=<1..100>&cursor=<opaque>` returns
|
||||
`{"grants": [...], "nextCursor": string | null}`. `limit` defaults to 50 and must be a canonical
|
||||
base-10 integer; unknown query keys, duplicated keys, empty values, non-canonical numbers, and
|
||||
malformed cursors are rejected with `400 {"code":"INVALID_REQUEST"}` before any store or database
|
||||
work. Ordering is `created_at DESC, id DESC` with a strict row-value keyset boundary on **both**
|
||||
columns, so rows sharing a `created_at` — which concurrent creation makes routine — can neither be
|
||||
skipped nor repeated. At most `limit + 1` rows are read and at most `limit` returned.
|
||||
`sourceKind: "explicit"` filtering stays server-side on every page. Offset pagination was rejected
|
||||
because it does exactly the skipping and repeating this ordering avoids.
|
||||
|
||||
The cursor is a small versioned payload containing only the subject user id, boundary creation
|
||||
timestamp, and boundary grant id, encoded as canonical unpadded base64url. Decoding is strict in
|
||||
every dimension — exact version, exact keys, matching requested subject, canonical `toISOString()`
|
||||
timestamp, canonical `GrantId`, and a re-encoding that reproduces the supplied string byte-for-byte
|
||||
— and a rejected cursor reveals nothing it decoded.
|
||||
|
||||
**The cursor is a pagination position, not an authorization token.** It is deliberately unsigned:
|
||||
the permission check and the server-side subject filter remain authoritative on every page, so a
|
||||
forged cursor can at most name a different position within grants the caller is already entitled to
|
||||
list. It carries no permission, scope, source reference, actor data, credential, or secret, and it
|
||||
is not a consistency snapshot — rows created or revoked between pages are simply seen or not seen
|
||||
according to the ordering.
|
||||
|
||||
Core's list method requires and validates a finite bound; no internal call site retains an unbounded
|
||||
fallback. No total count, offset pagination, search, sorting options, or filtering beyond the
|
||||
existing subject and explicit-source restriction is added.
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Keeping the audit foreign key and adding `ON DELETE SET NULL`** was rejected: a null subject is
|
||||
not history, and the column's whole purpose is naming who the security event concerned.
|
||||
- **Copying the user's name or email into the audit row so history stays readable** was rejected: it
|
||||
converts an authorization table into a shadow profile store and would survive a deletion the user
|
||||
may have requested.
|
||||
- **Treating "an owner row exists" as consumed, but blocking the cascade instead** was rejected: an
|
||||
owner row is live authorization state that an operator may legitimately want removed, while the
|
||||
fact that bootstrap ran is a historical event. Conflating the two is what produced the defect.
|
||||
- **A `--force`/reset path for bootstrap** was rejected as directly reintroducing the vulnerability
|
||||
under a different name.
|
||||
- **Guessing "consumed" for every upgraded database** was rejected: it would burn the one legitimate
|
||||
first use of a never-bootstrapped installation.
|
||||
- **Idempotently returning the existing grant on duplicate creation** — see above.
|
||||
- **A partial unique index over the equivalence tuple** was rejected for the `now()` immutability
|
||||
reason above; the advisory lock covers the complete tuple with no time dependence.
|
||||
- **Revoking only the addressed grant and reporting how many equivalents remain** was rejected: it
|
||||
puts the burden of resolving an ambiguous state on the operator, in a response that would have to
|
||||
disclose other grants' existence.
|
||||
- **Silently revoking historical duplicates in the migration** was rejected as changing live
|
||||
authorization without operator action.
|
||||
- **Adding a route-parameter-to-scope resolver in this pass** was rejected as scope creep with
|
||||
security consequences; rejecting scoped creation is the honest interim state.
|
||||
- **Signing the pagination cursor** was rejected: it would imply the cursor carries authority it does
|
||||
not have, and invite treating it as a capability rather than a position.
|
||||
|
||||
## Consequences
|
||||
|
||||
Authorization audit history survives user deletion; live grants and platform-owner rows still
|
||||
cascade, as intended. Bootstrap's single use is durable across owner revocation and user deletion,
|
||||
backfilled for existing installations with one documented, irrecoverable exception, and permanently
|
||||
unavailable once consumed — including after the last owner is deleted. Two equivalent grants can no
|
||||
longer exist effectively, sequentially or concurrently, and one revocation leaves no equivalent
|
||||
issuance authorizing the user. Malformed grant ids and expirations are request-validation results
|
||||
decided before any database call, never infrastructure failures. Explicit-grant listing is bounded
|
||||
and deterministically traversable.
|
||||
|
||||
Core gains one table and one forward migration (`0002_authorization_integrity`), classified
|
||||
`{"mode":"none"}`: it creates a table, drops one referential constraint specifically to *retain*
|
||||
data, and inserts a backfill row. It drops no table, column, or row. The `DROP CONSTRAINT` text is
|
||||
not itself a reason to classify it approval-required — the production CLI has no trusted external
|
||||
approval provider, so an approval-blocked security fix would be undeployable. Migration 0002 must be
|
||||
applied before the new code starts; migrations 0000 and 0001 are byte-identical with unchanged
|
||||
checksums.
|
||||
|
||||
No roles, groups, teams, tenants, service accounts, API tokens, scope resolver, user-deletion
|
||||
endpoint, owner-recovery workflow, or module-facing change is introduced, and the trusted compiled-
|
||||
backend module model is unchanged. Audit append-only behavior remains an application convention, not
|
||||
a cryptographic or database-role guarantee. **Phase 5D is not complete** — passes 4 (module/runtime/
|
||||
migration correctness) and 5 (deployment/supply chain plus an independent whole-repository re-audit)
|
||||
remain outstanding, and Phase 6 remains blocked.
|
||||
@@ -0,0 +1,233 @@
|
||||
# 0020: Module runtime and migration execution hardening
|
||||
|
||||
- Status: Accepted
|
||||
- Date: 2026-07-27
|
||||
- Amends: [0009](0009-transactional-module-migrations.md), [0015](0015-static-module-http-routing-and-runtime-lifecycle.md)
|
||||
- Extends: [0017](0017-public-request-perimeter-hardening.md), [0019](0019-durable-authorization-history-and-grant-management.md)
|
||||
|
||||
## Context
|
||||
|
||||
Phase 5D passes 1–3 hardened the public perimeter, the identity/database transport, and
|
||||
authorization integrity. Pass 4 addresses the module runtime and the migration commands underneath
|
||||
them. Seven defects were reproduced against the accepted pass-3 tree before anything changed.
|
||||
|
||||
**Registration order decided which permission governed a request.** A module may register both
|
||||
`GET /events/:id` and `GET /events/new`. Driven through the pinned router, `/events/new` reached the
|
||||
parameter handler with `demo.events.read` when the parameter route registered first, and the static
|
||||
handler with `demo.events.create` when it registered second. Nothing rejected the pair. A permission
|
||||
policy was being decided by array order.
|
||||
|
||||
**Decoded parameters looked safer than they are.** The declaration grammar admits only lowercase
|
||||
kebab-case literals and `:name` segments, but that constrains declarations, not requests. Verified
|
||||
through real requests: `%2F..%2Fadmin` arrives as `/../admin`, `a%5Cb` as `a\b`, `a%00b` with an
|
||||
embedded NUL, `a%0D%0Ab` with CR/LF, `1%3B%20drop` as `1; drop`, and `%C3%A9v%C3%A9nement` as
|
||||
`événement`.
|
||||
|
||||
**A module that failed to start was never cleaned up.** `startModules` appended to its rollback set
|
||||
only *after* `start()` resolved, so a hook that acquired a resource and then threw had its own
|
||||
`stop()` skipped while every previously completed module was rolled back.
|
||||
|
||||
**Nothing bounded shutdown.** `RunningApi.shutdown()` awaited `server.close()` with no deadline, so
|
||||
one open connection could hold it forever, and each module `stop()`/rollback hook was awaited
|
||||
without a deadline, so a hook that never settled prevented pool closure indefinitely.
|
||||
|
||||
**Migration commands had no timeouts.** `migrations:plan` and `migrations:apply` constructed their
|
||||
connection with neither a connection deadline nor an operation deadline; a real server confirmed
|
||||
`statement_timeout = 0` on the migration connection.
|
||||
|
||||
**Migration SQL was split on literal bytes.** The runner did
|
||||
`migration.sql.split("--> statement-breakpoint")`. Those bytes inside a string literal, a
|
||||
dollar-quoted body, a quoted identifier, or a comment split the migration into fragments that were
|
||||
no longer valid SQL — each verified to produce two fragments where one was required.
|
||||
|
||||
## Decision
|
||||
|
||||
### Overlapping same-module routes are rejected, not ranked
|
||||
|
||||
Two routes of one module overlap when they share an HTTP method and a segment count, and at every
|
||||
position either both segments are the same static literal or at least one is a parameter. Under
|
||||
this grammar — static literals or `:name` parameters, with no wildcard, regex, optional, or
|
||||
catch-all segment — that condition is exactly "some concrete request path matches both".
|
||||
|
||||
`moduleRoutesOverlap` (`packages/contracts/src/module-http.ts`) implements it, kept framework-neutral
|
||||
because path semantics already live in that package and Hono stays confined to `apps/api`. Core's
|
||||
staged registration checks it after the existing duplicate check, so identical shapes — including
|
||||
paths differing only in parameter name — keep returning `MODULE_ROUTE_DUPLICATE`, and a genuine
|
||||
non-duplicate intersection returns the new `MODULE_ROUTE_OVERLAP`. Detection happens during the
|
||||
registering module's staged registration, so the module publishes **none** of its staged routes or
|
||||
capabilities, no start hook runs, and the HTTP listener never binds.
|
||||
|
||||
Rejection rather than specificity ordering is the point. A specificity rule ("static beats
|
||||
parameter") would silently pick a winner, and the thing being picked is which permission guards the
|
||||
path — that is a policy decision, not a routing preference, and it must not be inferred. Disjoint
|
||||
routes keep their registration and publication order untouched; different methods, different
|
||||
modules (each has its own platform-owned mount prefix), and different segment counts remain legal.
|
||||
No priority, specificity, wildcard, regex, catch-all, optional-segment, mount-prefix, or manifest
|
||||
field was added.
|
||||
|
||||
### Decoded route parameters are untrusted, module-owned input
|
||||
|
||||
`context.params` continues to carry the router's decoded string values in a frozen context.
|
||||
Decoding is not validation, and validating a *declaration* says nothing about what a *request* may
|
||||
send. Every handler must validate each parameter it uses against its own exact domain grammar —
|
||||
UUID, slug, date, numeric id, or whatever that module owns — before any database, filesystem,
|
||||
command, identifier, or other side-effecting use, and reject missing, repeated, malformed, or
|
||||
semantically invalid values with its own sanitized response. Re-encoding or a decode/re-encode round
|
||||
trip is not a substitute.
|
||||
|
||||
No generic parameter grammar, validator service, path-normalization layer, or new route field was
|
||||
added: module parameters are module-owned domain types, and one platform-wide pattern would either
|
||||
reject legitimate values or approve meaningless ones. The platform keeps owning authentication,
|
||||
authorization, `Origin`, CORS, body limits, cache behavior, and uncaught-handler sanitization, and
|
||||
permission enforcement always completes before a handler — and therefore any parameter validation —
|
||||
can run.
|
||||
|
||||
### A module that fails to start rolls itself back first
|
||||
|
||||
A module joins the rollback set the moment its `start` hook is *invoked*. Its own `stop` hook runs
|
||||
first, then previously completed modules in reverse order. The original start issue stays first in
|
||||
`ModuleRuntimeError.issues`; a failure or timeout stopping the failing module is appended and never
|
||||
prevents earlier modules from being rolled back. Every applicable hook is invoked at most once, a
|
||||
runtime that fails to start is never returned, and every hook receives the same frozen
|
||||
`ModuleRuntimeServices` identity its start hook received. A module with no stop hook gets no
|
||||
synthetic callback.
|
||||
|
||||
The consequence for module authors is explicit: **`stop()` may run after a partially completed
|
||||
`start()`** and must be safe and idempotent about resources it may or may not have acquired. The
|
||||
platform cannot undo arbitrary side effects a trusted module performed outside its stop hook, and
|
||||
does not claim to.
|
||||
|
||||
### Fixed, finite deadlines for hooks and for HTTP drain
|
||||
|
||||
- Per-module `stop`/rollback hook: `MODULE_STOP_HOOK_TIMEOUT_MS` = **10 000 ms**.
|
||||
- Graceful HTTP drain: `HTTP_DRAIN_TIMEOUT_MS` = **30 000 ms**.
|
||||
|
||||
Neither is configurable in this pass — no environment variable, manifest field, runtime service, or
|
||||
flag. Narrow test-only overrides exist so tests need not wait out production durations.
|
||||
|
||||
A hook that reaches its deadline records `MODULE_STOP_TIMEOUT` or `MODULE_ROLLBACK_TIMEOUT` and the
|
||||
sequence continues with the next module in reverse order. The whole module-stop sequence is finite
|
||||
because the module count is fixed at discovery: its worst case is
|
||||
`startedModuleCount × 10 000 ms`, **not** one global ten-second bound. Timers that lose the race are
|
||||
cleared, the losing promise gets a permanent rejection handler so a late throw can never become an
|
||||
unhandled rejection, and a timed-out hook is never awaited again. `stop()` remains at-most-once and
|
||||
memoizes one shared promise for concurrent and later callers.
|
||||
|
||||
Shutdown stops accepting connections with `server.close()`, waits up to the drain deadline, and at
|
||||
the deadline closes idle and then active connections through Node's own public
|
||||
`closeIdleConnections`/`closeAllConnections` — after `server.close()`, never touching a private
|
||||
socket or server field — and stops waiting for the close callback. It then runs the bounded module
|
||||
stop sequence and closes the one shared pool. The accepted order
|
||||
`HTTP drain/forced close → bounded module stop → pool close` is unchanged, and a drain timeout or
|
||||
module-stop failure never skips later cleanup. Both outcomes are reported together as
|
||||
`ApiShutdownError`, carrying fixed booleans and the already-sanitized module error — never a
|
||||
request, socket, URL, credential, SQL, or stack detail. A forced drain is a shutdown *failure*, not
|
||||
a success; signal shutdown exits `0` only after a clean shutdown and `1` otherwise, after cleanup
|
||||
has been attempted.
|
||||
|
||||
**A deadline bounds waiting, not execution.** An in-process promise cannot be canceled: a timed-out
|
||||
hook keeps running and may settle later, and force-closing a connection ends the transport without
|
||||
proving that arbitrary handler JavaScript was canceled. What is guaranteed is that shutdown no
|
||||
longer waits, and that the pool closes afterwards, so late database work fails. Graceful ordering
|
||||
holds until the drain deadline; after it, the platform closes connections and proceeds so process
|
||||
shutdown stays bounded.
|
||||
|
||||
### Finite migration connection and operation defaults, enforced against driver precedence
|
||||
|
||||
`createPostgresMigrationDatabase` — the one construction boundary every migration command shares —
|
||||
now defaults to `MIGRATION_CONNECTION_TIMEOUT_MS` = **10 000 ms** (`connectionTimeoutMillis`) and
|
||||
`MIGRATION_OPERATION_TIMEOUT_MS` = **10 000 ms** for both `query_timeout` (client-side) and
|
||||
`statement_timeout` (server-side). Defaults live there so a future CLI call site cannot silently
|
||||
omit them; explicit test overrides may still narrow them. History reads, advisory-lock queries,
|
||||
transaction control, migration SQL, and history writes are all covered. The existing migration
|
||||
lock-wait deadline and error codes, and the Pass 2 TLS/hostname/certificate policy, are unchanged,
|
||||
as are Doctor's and the bootstrap CLI's own timeouts. No timeout environment variable or flag was
|
||||
added.
|
||||
|
||||
A connection string can reach those values, because `[email protected]` builds parameters as
|
||||
`Object.assign({}, config, parse(config.connectionString))` and `pg-connection-string` retains
|
||||
*arbitrary* query keys. What each key actually does was measured against the installed driver, a
|
||||
real blackhole listener, and a real PostgreSQL rather than inferred from that merge, and only one of
|
||||
them genuinely weakens the policy:
|
||||
|
||||
| Source | Measured effect on the selected policy |
|
||||
| --- | --- |
|
||||
| `?statement_timeout=0` / `=900000` | **Wins.** Server reported `0` (disabled) and `15min` against an explicit `statement_timeout: 10000`. The one real downgrade. |
|
||||
| `?query_timeout=0` | Overrides the explicit field, but breaks the client rather than loosening it: the parsed value is the string `"0"`, which is truthy, so every query gets a 0 ms read timer and fails nondeterministically with `Query read timeout`. |
|
||||
| `?connectionTimeoutMillis=0` / `=900000` | Pollutes `ConnectionParameters.connect_timeout` (10 → `0`/`900` s), but that field feeds only `getLibpqConnectionString()`, which the pure-JS client never calls. The **effective** connect timer is unchanged — an explicit 500 ms still expired at ~500 ms with either value. |
|
||||
| `?connect_timeout=0` | No effect: `ConnectionParameters` recomputes it from `connectionTimeoutMillis`. |
|
||||
| `?options=-c statement_timeout=…` (incl. a backslash-escaped spelling) | No effect: the named startup parameter wins. Server still reported `10s`. |
|
||||
| inherited `PGOPTIONS=-c statement_timeout=0` | No effect. Server still reported `10s`. |
|
||||
| inherited `PGCONNECT_TIMEOUT=0` | No effect: consulted only when `connectionTimeoutMillis` is `undefined`, which this boundary never leaves unset. |
|
||||
| unrelated parameters (`application_name`, …) | No effect; deliberately untouched. |
|
||||
|
||||
A `DATABASE_URL` carrying `statement_timeout`, `query_timeout`, `connectionTimeoutMillis`,
|
||||
`connect_timeout`, or an `options` value naming `statement_timeout` is refused with the existing
|
||||
fixed `POSTGRES_MIGRATION_CONFIGURATION_INVALID`, before any client is constructed and without
|
||||
echoing the URL, parameter, or value. Only the first is a precedence fix; the rest are rejected as
|
||||
**unsupported configuration and defense in depth** — no legitimate `DATABASE_URL` carries a driver
|
||||
option, `connectionTimeoutMillis` would take effect on the native binding, and `query_timeout`
|
||||
produces intermittent failures an operator would struggle to diagnose. Stating that honestly matters:
|
||||
an earlier revision of this ADR claimed `connect_timeout` overrode the explicit field, which
|
||||
measurement disproved.
|
||||
|
||||
### Each migration is one PostgreSQL-parsed batch
|
||||
|
||||
The runner no longer splits migration SQL. Each pending migration's exact string is passed once to
|
||||
the client — no values, no prepared-statement name, no trimming, normalizing, or rewriting — inside
|
||||
the existing transaction, and its history row is inserted only after that batch succeeds. One
|
||||
transaction still spans all pending migrations and their history records, and advisory locking,
|
||||
checksum validation, approval preflight, ordering, rollback, cleanup, and sanitized error identity
|
||||
are unchanged. The transaction port method was renamed `executeStatement` → `executeMigrationSql` so
|
||||
the contract states what it actually receives.
|
||||
|
||||
PostgreSQL was chosen as the parser because it is the only correct one. A homegrown splitter would
|
||||
need to track single quotes, dollar quoting with arbitrary tags, quoted identifiers, line comments,
|
||||
block-comment nesting, and escape syntax — reimplementing a SQL lexer to answer a question the
|
||||
server already answers — and a parser dependency would add supply-chain surface for the same
|
||||
reason. Drizzle's `--> statement-breakpoint` lines are ordinary `--` comments when the batch is sent
|
||||
whole, so the same bytes inside a literal, dollar-quoted body, quoted identifier, or comment retain
|
||||
their ordinary PostgreSQL meaning. **No custom SQL parser exists in this repository.**
|
||||
|
||||
## Alternatives
|
||||
|
||||
- **Ordering static routes ahead of parameter routes** was rejected: it silently resolves an
|
||||
ambiguity whose resolution is a permission decision, and it would make the governing permission a
|
||||
function of an implementation detail rather than of a declaration.
|
||||
- **A platform-wide route-parameter grammar** was rejected: parameters are module-owned domain
|
||||
types, so any single pattern is simultaneously too strict and too permissive.
|
||||
- **Normalizing or re-encoding decoded parameters** was rejected: it produces a value the module
|
||||
never declared and still does not answer whether the value is a valid domain identifier.
|
||||
- **A second, dedicated rollback hook** was rejected: it would double the lifecycle surface to
|
||||
express something `stop` already means, and every module would have to implement both.
|
||||
- **Making the deadlines configurable** was rejected for this pass: a configurable shutdown bound
|
||||
invites setting it to infinity, which is the defect being fixed.
|
||||
- **Claiming a timed-out hook is terminated** was rejected as untrue; the honest guarantee is that
|
||||
the platform stops waiting.
|
||||
- **Trusting explicit `pg` client fields over the connection string** was rejected once the pinned
|
||||
driver was shown to do the opposite.
|
||||
- **A timeout environment variable** was rejected as out of scope; a fixed default that cannot be
|
||||
weakened is the safer starting point.
|
||||
- **Writing a SQL statement splitter, or adding a SQL parser dependency** — see above.
|
||||
|
||||
## Consequences
|
||||
|
||||
An ambiguous same-method route pair fails module registration with a stable code instead of
|
||||
resolving by array order, and no request can reach a match whose permission depended on registration
|
||||
sequence. Decoded parameters are documented as untrusted input that handlers must validate, with no
|
||||
platform grammar implied. A module whose `start` fails is cleaned up through its own `stop` hook
|
||||
before earlier modules. Shutdown is finite in every stage, observable when it is not clean, and
|
||||
always reaches pool closure. Migration commands have finite connection, query, and statement
|
||||
deadlines that a connection string cannot weaken, and each migration reaches PostgreSQL byte-for-byte
|
||||
in a single parsed batch.
|
||||
|
||||
This pass adds no production schema, migration, manifest field, `manifestVersion` change,
|
||||
`ModuleRuntimeServices` field, `ModuleDatabase` operation, route-declaration field, handler-context
|
||||
field, second runtime, route registry, or runtime pool; the three accepted Core migrations and their
|
||||
checksums are byte-identical, one API runtime still owns one physical pool, migrations remain
|
||||
explicit operator work that never runs at application startup, and Hono remains confined to
|
||||
`apps/api`. Modules remain trusted compiled backend code: they are **not** isolated by process,
|
||||
PostgreSQL role, schema, or tenant, and these deadlines do not sandbox them.
|
||||
|
||||
**Phase 5D is not complete.** Pass 5 — deployment/supply-chain work and an independent
|
||||
whole-repository re-audit — remains outstanding, and Phase 6 remains blocked.
|
||||
@@ -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
|
||||
|
||||
@@ -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/<module-id>`, 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/<module-id>`, 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.
|
||||
|
||||
+249
-23
@@ -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/<module-id>/*` | Installed modules | Every route an installed module registered during its `register` hook, gated by that route's own declared permission. See [Module HTTP routes](#module-http-routes). |
|
||||
|
||||
Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a
|
||||
@@ -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/<module-id>`, 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
|
||||
|
||||
@@ -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=<id>` | No | Lists the subject's explicit grants only (`sourceKind: "explicit"` filter applied server-side). |
|
||||
| `POST /api/v1/grants` | Yes | Creates a new explicit grant. |
|
||||
| `POST /api/v1/grants/:grantId/revoke` | Yes | Revokes an explicit grant without deleting it. |
|
||||
| `GET /api/v1/grants?userId=<id>&limit=<1..100>&cursor=<opaque>` | No | One bounded page of the subject's explicit grants only (`sourceKind: "explicit"` filter applied server-side on every page). |
|
||||
| `POST /api/v1/grants` | Yes | Creates a new **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": <string|null>}`.
|
||||
|
||||
### Listing is bounded keyset pagination
|
||||
|
||||
```text
|
||||
GET /api/v1/grants?userId=<id>&limit=<1..100>&cursor=<opaque>
|
||||
```
|
||||
|
||||
| Parameter | Required | Notes |
|
||||
| --- | --- | --- |
|
||||
| `userId` | Yes | The subject whose explicit grants to list. |
|
||||
| `limit` | No | Canonical base-10 integer, 1 through 100. Defaults to 50. |
|
||||
| `cursor` | No | An opaque position string returned as a previous page's `nextCursor`. |
|
||||
|
||||
Unknown query keys, duplicated `userId`/`limit`/`cursor` keys, empty values, non-canonical numbers
|
||||
(`0`, `101`, `-1`, `+5`, `01`, `1.5`, `1e2`, whitespace-padded), and malformed cursors are all
|
||||
rejected with `400 {"code":"INVALID_REQUEST"}` before any store or database work. Permission
|
||||
enforcement completes first, so query validation never discloses a validation distinction — or the
|
||||
existence of any subject or grant — to an unauthorized caller.
|
||||
|
||||
Rows are ordered `created_at DESC, id DESC`, with a strict keyset boundary on **both** values, so
|
||||
grants sharing a `created_at` can neither be skipped nor repeated across pages. At most `limit + 1`
|
||||
rows are read internally and at most `limit` returned. The final page returns `"nextCursor": null`.
|
||||
Relationship grants never appear on any page. There is no total count, offset pagination, search, or
|
||||
sorting option.
|
||||
|
||||
The cursor is a small versioned payload containing only the subject user id, boundary creation
|
||||
timestamp, and boundary grant id, encoded as canonical unpadded base64url. Decoding is strict —
|
||||
exact version, exact keys, a subject matching the *requested* one, a canonical `toISOString()`
|
||||
timestamp, a canonical `GrantId`, and a re-encoding that reproduces the supplied string
|
||||
byte-for-byte — and a rejected cursor reveals nothing it decoded.
|
||||
|
||||
**It is a pagination position, not an authorization token.** It is deliberately unsigned: the
|
||||
permission check and the server-side subject filter remain authoritative on every page, so a forged
|
||||
cursor can at most name a different position within grants the caller may already list. It carries
|
||||
no permission, scope, source reference, actor data, credential, or secret, and it is not a
|
||||
consistency snapshot — rows created or revoked between pages are seen or not seen according to the
|
||||
ordering.
|
||||
|
||||
### Status-code mapping
|
||||
|
||||
| Status | 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.
|
||||
|
||||
@@ -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<Response>` (delegates directly to Better Auth's handler; its
|
||||
normal responses, including its own error responses, pass through unmodified) and
|
||||
`resolvePrincipal(headers): Promise<EkklesiaPrincipal>` (never silently downgrades a database
|
||||
outage to an anonymous result — see ADR 0011 for exactly how). Neither method, nor anything else
|
||||
outage to an anonymous result — see ADR 0011 and [session resolution](#how-a-principal-is-resolved)
|
||||
below). Neither method, nor anything else
|
||||
exported from `@ekklesia/core/identity`, exposes `auth.api`, Better Auth's session/user types, the
|
||||
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
|
||||
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
# Security model and deployment trust boundaries
|
||||
|
||||
The one canonical statement of what Ekklesia's boundaries actually protect against today, and what
|
||||
they do not. Read it before installing a module, exposing the API, or reasoning about any other
|
||||
document's guarantees.
|
||||
|
||||
Related: [ADR 0006](../architecture/decisions/0006-versioned-capabilities-and-compiled-module-runtime.md)
|
||||
(compiled module runtime), [ADR 0016](../architecture/decisions/0016-module-database-access.md)
|
||||
(module database access), [ADR 0020](../architecture/decisions/0020-module-runtime-and-migration-execution-hardening.md)
|
||||
(module routing/lifecycle and migration execution), and
|
||||
[Module development](../module-development/README.md).
|
||||
|
||||
## Installed modules are trusted compiled backend code
|
||||
|
||||
**Installing a module is equivalent to deploying trusted server code.** It is not equivalent to
|
||||
installing a browser extension, a plugin from a marketplace, or anything else with a security
|
||||
boundary around it. There is no such boundary here.
|
||||
|
||||
A module's `dist/server` entrypoint is imported into, and runs arbitrary JavaScript inside, the
|
||||
`apps/api` process. Within the operating-system account that process runs as, a module can:
|
||||
|
||||
- read and write any file the account can reach, including `.env`, TLS material, and the source tree;
|
||||
- read `process.env`, `process.argv`, and any other process state;
|
||||
- open outbound network connections and bind listeners;
|
||||
- import any installed dependency, including ones Ekklesia never intended it to use;
|
||||
- monkey-patch Core's own exports, Node built-ins, `globalThis`, or another module's objects;
|
||||
- keep running work after its lifecycle hooks return.
|
||||
|
||||
It also **shares the API's single PostgreSQL role and connection pool**. `ModuleDatabase.execute`
|
||||
accepts arbitrary SQL, so a module has exactly the database authority the API has — every table in
|
||||
the database, including `ekklesia_core_user`, `ekklesia_core_grant`,
|
||||
`ekklesia_core_platform_owner`, `ekklesia_core_platform_owner_bootstrap`,
|
||||
`ekklesia_core_authorization_audit`, and every other module's tables.
|
||||
|
||||
## What the module-facing mechanisms actually are
|
||||
|
||||
These are **correctness and coordination boundaries**. Every one of them assumes the module is
|
||||
cooperative. None of them constrains a module that is not:
|
||||
|
||||
| Mechanism | What it actually provides |
|
||||
| --- | --- |
|
||||
| `ekklesia_*` table prefixes and schema discovery | Ownership clarity and collision avoidance between cooperating modules |
|
||||
| TypeScript types, branded ids, contracts | Compile-time correctness; erased at runtime |
|
||||
| `ekklesia.module.json` manifest and `manifestVersion` | Declared compatibility and dependency ordering |
|
||||
| Permission declarations and the composed registry | Which permissions *this* module's own routes may name, checked when Core mounts them |
|
||||
| Route validation, overlap rejection, body limits | Deterministic, unambiguous HTTP routing and request bounds |
|
||||
| `Object.freeze` on contexts and services | Prevents accidental mutation; a module can still reach the underlying objects |
|
||||
| The `ModuleDatabase` facade | A typed, ergonomic surface — not a privilege reduction, since `execute` is arbitrary SQL |
|
||||
| Capability registry | Explicit inter-module wiring, not access control |
|
||||
| Bounded stop/rollback deadlines (ADR 0020) | The platform stops *waiting*; module code is not cancelled or terminated |
|
||||
|
||||
**There is no process, container, VM, seccomp/syscall, filesystem, network, PostgreSQL role,
|
||||
schema-permission, table-ACL, row-level-security, tenant, or resource sandbox between a module and
|
||||
Core.** A module is not isolated from Core, from other modules, or from the host.
|
||||
|
||||
## What this platform must not claim
|
||||
|
||||
Ekklesia today has **none** of the following, and no document, README, or marketing surface may
|
||||
imply otherwise:
|
||||
|
||||
- module signing, provenance attestation, or verified publishers;
|
||||
- a marketplace, registry, or review process for third-party modules;
|
||||
- sandboxed or hot-installed modules;
|
||||
- per-module database roles, schemas, or credentials;
|
||||
- tenant isolation;
|
||||
- runtime resource limits (CPU, memory, file descriptors, connections) per module.
|
||||
|
||||
## Operator rule
|
||||
|
||||
**Only install modules whose source, provenance, and build output you have reviewed and whose
|
||||
authors you trust with your production server and database.** Treat a module the way you would
|
||||
treat a direct commit to `apps/api`: read the code, verify what artifact was built from it, pin it,
|
||||
and re-review on upgrade. Do not install a module because a manifest looks well-formed — every
|
||||
validation Ekklesia performs on a manifest is satisfied trivially by hostile code.
|
||||
|
||||
Installing, removing, or editing a module requires an API restart; there is no hot install path,
|
||||
and none should be added while this model holds.
|
||||
|
||||
## What would have to change before accepting untrusted modules
|
||||
|
||||
Accepting third-party modules that have not been reviewed is an architectural change, not a
|
||||
configuration change. At minimum it would require:
|
||||
|
||||
- process or container isolation per module, with the API as a supervisor;
|
||||
- capability-based RPC across that boundary instead of in-process object passing;
|
||||
- a separate PostgreSQL role and schema per module, with no shared superuser-equivalent pool;
|
||||
- restricted filesystem and network access per module;
|
||||
- CPU/memory/connection limits and enforced timeouts that can actually terminate work;
|
||||
- signed, verifiable module provenance and a defined review/revocation process.
|
||||
|
||||
Until that exists, the trusted-code model above is the security model.
|
||||
|
||||
## Current deployment state
|
||||
|
||||
As of this branch, **Ekklesia is not deployed**. There is no production API instance, no reverse
|
||||
proxy, no public listener, and no deployment automation of any kind in this repository — no deploy
|
||||
script, no CI deployment step, no container publish, no orchestration manifest. No `.env` has ever
|
||||
been committed; only `.env.example`.
|
||||
|
||||
Two production defaults are relevant but are **not** by themselves a deployment guarantee:
|
||||
|
||||
- `apps/api` defaults its listen address to loopback (`127.0.0.1`), so it is not reachable off-host
|
||||
unless an operator explicitly changes it;
|
||||
- production configuration refuses to enable public signup at all
|
||||
(`PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION`).
|
||||
|
||||
A loopback default is not a firewall, and a config default is not a verified boundary.
|
||||
|
||||
### Launch checklist — each item must be proven against the real deployment before public exposure
|
||||
|
||||
Until every one of these is verified on the actual hosts, the deployment is not launch-ready. None
|
||||
of them can be satisfied by reading this repository.
|
||||
|
||||
This checklist gates a **public launch**. It does not gate development: Surfaces, modules, and Core
|
||||
work all proceed against the local development infrastructure while every item below is still
|
||||
outstanding.
|
||||
|
||||
1. The reverse-proxy path serves the API successfully.
|
||||
2. The origin port is unreachable from the public Internet.
|
||||
3. The origin port is also unreachable from any untrusted network/VLAN — only the intended private
|
||||
proxy path connects. Security must not depend on an unpublished DNS name or origin IP.
|
||||
4. The proxy **overwrites** (never appends to, never passes through) the client-IP header Ekklesia
|
||||
is configured to trust.
|
||||
5. Rotating a caller-supplied `Forwarded`, `X-Forwarded-For`, `CF-Connecting-IP`, `X-Real-IP`, or
|
||||
the platform's own internal sanitized header cannot rotate the authentication rate-limit bucket.
|
||||
6. The actual immediate proxy peer is in Ekklesia's explicit trusted-proxy set, and an untrusted
|
||||
direct peer's forwarded header is ignored.
|
||||
7. Proxy logs do not record cookies, authorization values, request bodies, or raw forwarding chains.
|
||||
8. PostgreSQL is reachable only from the API host, over TLS, with the certificate and hostname
|
||||
verification Ekklesia already requires.
|
||||
|
||||
### Rate limiting is per-process today
|
||||
|
||||
Better Auth's authentication rate limiter is **in-memory and per API process**. That is acceptable
|
||||
only while **both** of the following hold, which is the current state:
|
||||
|
||||
- production public signup is disabled (and cannot be enabled — see above); and
|
||||
- exactly one API instance serves traffic.
|
||||
|
||||
If either changes — public signup is enabled, or a second instance is added — a durable shared or
|
||||
edge rate limiter becomes mandatory, and it must be proven that its counters are shared across
|
||||
instances, survive process restart, and cannot be re-bucketed by a spoofed client-IP header.
|
||||
Ekklesia does not ship one. Do not describe the current limiter as distributed throttling.
|
||||
|
||||
## Continuous integration boundary
|
||||
|
||||
CI runs on self-hosted Gitea Actions runners. The inventoried server is **Gitea 1.24.2**, and that
|
||||
version determines which parts of the workflow are real guarantees and which are not.
|
||||
|
||||
### The current operating rule: trusted code only
|
||||
|
||||
**This CI installation is a trusted-code-only boundary.** Only code controlled and reviewed by
|
||||
trusted repository collaborators may execute on the self-hosted runner. An unknown or untrusted pull
|
||||
request must **not** execute on it automatically — a maintainer reviews the diff, including any
|
||||
change to the workflow itself, before it runs.
|
||||
|
||||
This is an operating rule, not an enforced technical control, and it is the reason the rest of this
|
||||
section is safe today. Everything below describes what the repository can and cannot prove about
|
||||
that runner; none of it is a substitute for the rule.
|
||||
|
||||
### What is true of the repository today
|
||||
|
||||
These are properties of files in this repository, enforced by the action or the package manager,
|
||||
independent of server version:
|
||||
|
||||
- every action reference is pinned by a full immutable commit SHA;
|
||||
- dependencies install with `pnpm install --frozen-lockfile`;
|
||||
- there is no `pull_request_target` workflow;
|
||||
- no repository, organization, or instance secret is referenced anywhere in the workflow;
|
||||
- every checkout step sets `persist-credentials: false`;
|
||||
- the workflow declares `permissions: contents: read`.
|
||||
|
||||
### What `persist-credentials: false` actually proves
|
||||
|
||||
It proves exactly one thing: `actions/checkout` does not write its credential into `.git/config`,
|
||||
so a later step in the same job cannot read or reuse it from there.
|
||||
|
||||
It does **not** remove the short-lived `GITEA_TOKEN` from the job environment, and it does **not**
|
||||
reduce that token's server-side authority. A step that wants the token can still read it.
|
||||
|
||||
### What Gitea 1.24 ignores — so the branch cannot claim it
|
||||
|
||||
Per the official comparison for this version
|
||||
(<https://docs.gitea.com/1.24/usage/actions/comparison>), Gitea 1.24's unsupported workflow syntax
|
||||
includes `permissions` and `jobs.<job_id>.permissions`, `concurrency`, and
|
||||
`jobs.<job_id>.timeout-minutes`. On this server those declarations are inert. Therefore:
|
||||
|
||||
- **The workflow's `permissions: contents: read` does not restrict the job token.** This branch
|
||||
cannot claim the `GITEA_TOKEN` is read-only, because nothing in workflow YAML makes it so on
|
||||
1.24. Job-token permission controls, repository/owner defaults, and server-side
|
||||
maximum-permission clamping arrive in the 1.26 feature line
|
||||
(<https://docs.gitea.com/1.26/usage/actions/token-permissions>).
|
||||
- **`concurrency` and `timeout-minutes` are not bounded-execution guarantees here.** Cancellation,
|
||||
timeout, and teardown must be enforced by a trusted provisioner and reaper outside the workflow.
|
||||
|
||||
The declarations are kept deliberately: they are already correct for a supported Gitea release and
|
||||
for GitHub Actions, and keeping them means the upgrade does not require re-deriving the intent. They
|
||||
are documented as ignored, not removed.
|
||||
|
||||
### Runner topology is not verified
|
||||
|
||||
**The runner topology is not verified in this repository and is not documented here as though it
|
||||
were.** A pull request can edit `runs-on` and select any runner label its repository can reach, so a
|
||||
workflow file cannot be the boundary. Whether untrusted pull-request jobs execute on a disposable
|
||||
guest, and whether any runner reachable from this repository holds deployment authority or a host
|
||||
Docker socket, must be established against the actual Gitea instance and runner hosts. Until that
|
||||
evidence exists, treat pull-request CI as executing with whatever authority the runner host actually
|
||||
grants.
|
||||
|
||||
### Requirements before executing untrusted contributions
|
||||
|
||||
These five items are the prerequisites for **relaxing the trusted-code-only rule above** — that is,
|
||||
for letting code this project has not reviewed execute on this Gitea installation. They are backlog
|
||||
requirements for that specific future capability. **They do not block Phase 6 Surface work**, and
|
||||
they are not conditions on merging trusted, reviewed work.
|
||||
|
||||
None of them can be satisfied by editing this repository; each requires access to the Gitea server
|
||||
and runner hosts, and each must be proven there.
|
||||
|
||||
1. Upgrade Gitea to a reviewed supported release with job-token permission controls — at least the
|
||||
1.26 feature line. Select the actual release during infrastructure work, after backup and
|
||||
compatibility review; do not pin a version from this document.
|
||||
2. Configure repository and owner defaults, and the server-side maximum permission, so workflow
|
||||
code cannot request more than read-only repository access.
|
||||
3. Prove against the real server that the job's `GITEA_TOKEN` cannot push code, create or update
|
||||
releases, packages, issues, or pull requests, change Actions configuration, or reach another
|
||||
private repository.
|
||||
4. Enforce job timeout, cancellation, concurrency, and teardown in the trusted
|
||||
provisioner/reaper rather than relying on the ignored workflow keys above.
|
||||
5. Complete a disposable-guest runner isolation proof: one throwaway guest per job, torn down by a
|
||||
trusted reaper outside workflow YAML, holding no deployment credential, registration token, or
|
||||
reusable key.
|
||||
|
||||
Moving future public contribution workflows to a service that provides isolated hosted runners — for
|
||||
example GitHub — is an accepted alternative to satisfying items 1–5 on this installation.
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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/<module-id>`: `"/"` 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 })
|
||||
|
||||
@@ -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<typeof grantIdSchema>;
|
||||
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,6 +48,7 @@ describe("defineModuleRuntime", () => {
|
||||
method: "GET",
|
||||
path: "/records",
|
||||
permission: "example.records.read",
|
||||
maxBodyBytes: 0,
|
||||
handler: () => new Response(null, { status: 204 }),
|
||||
});
|
||||
},
|
||||
|
||||
Generated
+22
-243
@@ -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/[email protected]':
|
||||
resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -549,12 +550,6 @@ packages:
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -567,12 +562,6 @@ packages:
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -585,12 +574,6 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -603,12 +586,6 @@ packages:
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -621,12 +598,6 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -639,12 +610,6 @@ packages:
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -657,12 +622,6 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -675,12 +634,6 @@ packages:
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -693,12 +646,6 @@ packages:
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -711,12 +658,6 @@ packages:
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -729,12 +670,6 @@ packages:
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -747,12 +682,6 @@ packages:
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -765,12 +694,6 @@ packages:
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -783,12 +706,6 @@ packages:
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -801,12 +718,6 @@ packages:
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -831,12 +742,6 @@ packages:
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -861,12 +766,6 @@ packages:
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -891,12 +790,6 @@ packages:
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -909,12 +802,6 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -927,12 +814,6 @@ packages:
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -945,12 +826,6 @@ packages:
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1579,11 +1454,6 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==}
|
||||
engines: {node: '>=12'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1757,8 +1627,8 @@ packages:
|
||||
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
[email protected]7.21:
|
||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
||||
[email protected]8.1:
|
||||
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
@@ -1769,8 +1639,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
[email protected]5:
|
||||
resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==}
|
||||
[email protected]6:
|
||||
resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
@@ -1843,8 +1713,8 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
|
||||
|
||||
[email protected].17:
|
||||
resolution: {integrity: sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==}
|
||||
[email protected].24:
|
||||
resolution: {integrity: sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
[email protected]:
|
||||
@@ -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/[email protected]':
|
||||
dependencies:
|
||||
'@chevrotain/types': 10.5.0
|
||||
lodash: 4.17.21
|
||||
lodash: 4.18.1
|
||||
|
||||
'@chevrotain/[email protected]': {}
|
||||
|
||||
@@ -2456,7 +2326,7 @@ snapshots:
|
||||
|
||||
'@esbuild-kit/[email protected]':
|
||||
dependencies:
|
||||
esbuild: 0.18.20
|
||||
esbuild: 0.25.12
|
||||
source-map-support: 0.5.21
|
||||
|
||||
'@esbuild-kit/[email protected]':
|
||||
@@ -2470,144 +2340,96 @@ snapshots:
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
@@ -2620,9 +2442,6 @@ snapshots:
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
@@ -2635,9 +2454,6 @@ snapshots:
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
@@ -2650,36 +2466,24 @@ snapshots:
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@esbuild/[email protected]':
|
||||
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
|
||||
|
||||
[email protected]:
|
||||
@@ -3112,31 +2916,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
optionalDependencies:
|
||||
'@esbuild/android-arm': 0.18.20
|
||||
'@esbuild/android-arm64': 0.18.20
|
||||
'@esbuild/android-x64': 0.18.20
|
||||
'@esbuild/darwin-arm64': 0.18.20
|
||||
'@esbuild/darwin-x64': 0.18.20
|
||||
'@esbuild/freebsd-arm64': 0.18.20
|
||||
'@esbuild/freebsd-x64': 0.18.20
|
||||
'@esbuild/linux-arm': 0.18.20
|
||||
'@esbuild/linux-arm64': 0.18.20
|
||||
'@esbuild/linux-ia32': 0.18.20
|
||||
'@esbuild/linux-loong64': 0.18.20
|
||||
'@esbuild/linux-mips64el': 0.18.20
|
||||
'@esbuild/linux-ppc64': 0.18.20
|
||||
'@esbuild/linux-riscv64': 0.18.20
|
||||
'@esbuild/linux-s390x': 0.18.20
|
||||
'@esbuild/linux-x64': 0.18.20
|
||||
'@esbuild/netbsd-x64': 0.18.20
|
||||
'@esbuild/openbsd-x64': 0.18.20
|
||||
'@esbuild/sunos-x64': 0.18.20
|
||||
'@esbuild/win32-arm64': 0.18.20
|
||||
'@esbuild/win32-ia32': 0.18.20
|
||||
'@esbuild/win32-x64': 0.18.20
|
||||
|
||||
[email protected]:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.25.12
|
||||
@@ -3297,7 +3076,7 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]7.21: {}
|
||||
[email protected]8.1: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
@@ -3309,7 +3088,7 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]5: {}
|
||||
[email protected]6: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
@@ -3375,9 +3154,9 @@ snapshots:
|
||||
exsolve: 1.1.0
|
||||
pathe: 2.0.3
|
||||
|
||||
[email protected].17:
|
||||
[email protected].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:
|
||||
|
||||
@@ -9,3 +9,29 @@ packages:
|
||||
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
|
||||
minimumReleaseAgeExclude:
|
||||
- [email protected]
|
||||
|
||||
# Narrowly scoped advisory remediation. Each entry raises exactly the one edge that resolves a
|
||||
# vulnerable version, never the whole graph, and pins an exact release rather than a range.
|
||||
overrides:
|
||||
# GHSA-r28c-9q8g-f849 (high). PostCSS is reached only through Vite, which better-auth pulls in
|
||||
# via Vitest. Vite 8.1.4's own dependency range still resolves the vulnerable 8.5.17.
|
||||
vite>postcss: "8.5.24"
|
||||
# GHSA-67mh-4wv8-2f99 (moderate). The vulnerable esbuild 0.18.20 is reached only through the
|
||||
# deprecated @esbuild-kit/core-utils (drizzle-kit -> @esbuild-kit/esm-loader), whose pinned range
|
||||
# predates the fix. Scoped to that one edge deliberately: drizzle-kit already resolves esbuild
|
||||
# 0.25.12 directly and tsx/vite resolve 0.28.1, so a global override would drag those newer,
|
||||
# unaffected versions backwards.
|
||||
"@esbuild-kit/core-utils>esbuild": "0.25.12"
|
||||
# GHSA-r5fr-rjxr-66jc (high), GHSA-f23m-r3pf-42rh and GHSA-xxjr-mmjv-4gpg (moderate). Lodash is
|
||||
# reached only through the development-time Better Auth `auth` CLI's Prisma parser
|
||||
# (@mrleebo/prisma-ast -> chevrotain). All three chevrotain packages pin lodash to exactly
|
||||
# 4.17.21, so no in-range release is patched; the advisories require >= 4.17.24, which the 4.17.x
|
||||
# line never published, making 4.18.1 the lowest fixed release. Verified compatible: it keeps the
|
||||
# per-method CommonJS subpath files (`lodash/assign`, `lodash/isRegExp`, ...) chevrotain requires,
|
||||
# with the same `main` and no restrictive `exports` field.
|
||||
chevrotain>lodash: "4.18.1"
|
||||
"@chevrotain/gast>lodash": "4.18.1"
|
||||
"@chevrotain/cst-dts-gen>lodash": "4.18.1"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user