Compare commits
8
Commits
5e09330001
...
5c589d8fec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c589d8fec | ||
|
|
532ce3803b | ||
|
|
6b6b77c105 | ||
|
|
7a0f624fc3 | ||
|
|
3d6c85b0f8 | ||
|
|
1161f5a076 | ||
|
|
d3c6bd73d3 | ||
|
|
cafd513019 |
@@ -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
|
||||
|
||||
@@ -8,7 +8,10 @@ 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 (public request perimeter
|
||||
hardening against an independent security audit) is in progress; pass 1 of 5 has landed — see
|
||||
below. **Phase 5D is not complete, and Phase 6 (Surface work) remains blocked until every audit
|
||||
finding is resolved and a final re-audit passes.** 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 +35,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 +96,80 @@ 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 does not complete Phase 5D** — passes 2–5 (identity/database transport hardening,
|
||||
authorization integrity, module/runtime/migration correctness, and deployment/supply-chain closure
|
||||
plus a final independent re-audit) remain outstanding before Phase 6 (Surface work) may begin.
|
||||
|
||||
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. Phase 5D is still not complete** — passes 3–5
|
||||
(authorization integrity, module/runtime/migration correctness, and deployment/supply-chain closure
|
||||
plus a final independent re-audit) remain 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. Phase 5D is still not complete** — passes 4 (module/runtime/
|
||||
migration correctness) and 5 (deployment/supply chain plus a final independent re-audit) remain
|
||||
outstanding, and Phase 6 remains blocked.
|
||||
|
||||
## 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 +181,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
|
||||
@@ -141,6 +219,8 @@ Data definitions
|
||||
core:0001_authorization_core_tables · 830e1fbc26d0
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
+234
-37
@@ -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,29 +291,55 @@ 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);
|
||||
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", requireOrigin, requireManagePermission, async (c) => {
|
||||
routes.post(
|
||||
"/api/v1/grants",
|
||||
requireOrigin,
|
||||
mutationBodyLimit,
|
||||
requireManagePermission,
|
||||
async (c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
const principal = getAuthorizedPrincipal(c);
|
||||
if (!isAuthenticatedPrincipal(principal)) {
|
||||
@@ -159,7 +352,7 @@ export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono {
|
||||
} catch {
|
||||
return c.json({ code: "INVALID_REQUEST" }, 400);
|
||||
}
|
||||
const parsed = parseCreateGrantBody(rawBody);
|
||||
const parsed = parseCreateGrantBody(rawBody, now());
|
||||
if (!parsed) {
|
||||
return c.json({ code: "INVALID_REQUEST" }, 400);
|
||||
}
|
||||
@@ -179,7 +372,6 @@ export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono {
|
||||
actor: createUserActor(principal.userId),
|
||||
...(parsed.expiresAt === undefined ? {} : { expiresAt: parsed.expiresAt }),
|
||||
permission: parsed.permission,
|
||||
...(parsed.scope === undefined ? {} : { scope: parsed.scope }),
|
||||
sourceKind: "explicit",
|
||||
userId: parsed.subjectUserId,
|
||||
});
|
||||
@@ -187,11 +379,13 @@ export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono {
|
||||
} catch (error) {
|
||||
return sanitizedStoreErrorResponse(c, error);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
routes.post(
|
||||
"/api/v1/grants/:grantId/revoke",
|
||||
requireOrigin,
|
||||
mutationBodyLimit,
|
||||
requireManagePermission,
|
||||
async (c) => {
|
||||
c.header("Cache-Control", "no-store");
|
||||
@@ -200,6 +394,9 @@ export function createGrantsRoutes(options: CreateGrantsRoutesOptions): Hono {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+178
-3
@@ -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,10 @@ 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>;
|
||||
}
|
||||
|
||||
async function safeCloseDatabase(handle: ApiDatabaseHandle): Promise<void> {
|
||||
@@ -61,14 +152,58 @@ async function safeStopModuleRuntime(moduleRuntime: StartedModuleRuntime): Promi
|
||||
}
|
||||
}
|
||||
|
||||
function bindServer(app: Hono, host: string, port: number): Promise<ServerType> {
|
||||
/**
|
||||
* 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 }, () => {
|
||||
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 +260,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;
|
||||
@@ -178,9 +317,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);
|
||||
@@ -250,6 +395,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 +423,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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -155,6 +155,7 @@ async function createWorkingFixtureModule(
|
||||
method: "GET",
|
||||
path: "/ping",
|
||||
permission: ${JSON.stringify(permissionId)},
|
||||
maxBodyBytes: 0,
|
||||
handler: async () => new Response("pong", { status: 200 }),
|
||||
});`
|
||||
: "";
|
||||
@@ -212,8 +213,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 +225,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 +239,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 +252,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 +422,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 +569,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 +985,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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -43,14 +43,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 +1153,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 }));
|
||||
|
||||
@@ -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;
|
||||
|
||||
+252
-24
@@ -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,32 @@ 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,
|
||||
// The tuple columns are immutable once written, so reading them before taking the lock
|
||||
// is safe; whether the target is still unrevoked is re-decided under the lock by the
|
||||
// update predicate 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,12 +403,29 @@ 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({
|
||||
await lockEquivalence(tx, target);
|
||||
|
||||
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) {
|
||||
// A concurrent revocation committed between the lookup and the lock.
|
||||
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,
|
||||
@@ -234,7 +433,8 @@ export function createAuthorizationStore(db: AuthorizationDrizzleDatabase): Auth
|
||||
permissionId: row.permissionId,
|
||||
scope: row.scope,
|
||||
subjectUserId: row.userId,
|
||||
});
|
||||
})),
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAuthorizationStoreError(error)) {
|
||||
@@ -269,10 +469,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 +491,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,252 @@
|
||||
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 Node performs no identity
|
||||
* check at all when `servername` is unset — so a certificate issued for an entirely different name
|
||||
* is 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";
|
||||
|
||||
+129
-16
@@ -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,8 +213,12 @@ export function createIdentityRuntime(
|
||||
): EkklesiaIdentityRuntime {
|
||||
const rateLimitOverride = options.rateLimitOverride;
|
||||
|
||||
const auth = constructBetterAuth(() =>
|
||||
betterAuth({
|
||||
// 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
|
||||
@@ -149,6 +231,21 @@ export function createIdentityRuntime(
|
||||
// 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, {
|
||||
@@ -157,7 +254,13 @@ export function createIdentityRuntime(
|
||||
}),
|
||||
emailAndPassword: {
|
||||
autoSignIn: false,
|
||||
disableSignUp: false,
|
||||
// Public registration is disabled unless explicitly enabled (`IdentityConfig.
|
||||
// publicSignupEnabled`, `core/src/identity/config.ts`) — never a side effect of an absent,
|
||||
// misspelled, or loosely parsed configuration value. Better Auth's own `disableSignUp` gate
|
||||
// rejects every `/sign-up/email` call with a fixed `400 EMAIL_PASSWORD_SIGN_UP_DISABLED`
|
||||
// before ever looking up the email, so a disabled deployment never reveals whether an
|
||||
// address is already registered; sign-in and existing sessions are entirely unaffected.
|
||||
disableSignUp: !options.config.publicSignupEnabled,
|
||||
enabled: true,
|
||||
// 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
|
||||
@@ -204,7 +307,15 @@ export function createIdentityRuntime(
|
||||
},
|
||||
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,
|
||||
@@ -76,6 +80,10 @@ export class PostgresMigrationDatabaseError extends Error {
|
||||
export interface PostgresMigrationDatabaseOptions {
|
||||
readonly connectionTimeoutMs?: number;
|
||||
readonly operationTimeoutMs?: number;
|
||||
/** The validated transport policy (`core/src/database/transport.js`). Every production executable
|
||||
* that plans or applies migrations passes one; omitting it keeps the pre-existing plaintext
|
||||
* behavior used by the disposable loopback PostgreSQL harness. */
|
||||
readonly transport?: DatabaseTransportPolicy;
|
||||
}
|
||||
|
||||
interface HistoryExistenceRow extends QueryResultRow {
|
||||
@@ -178,8 +186,16 @@ function createClientConfig(
|
||||
"The PostgreSQL operation timeout is invalid.",
|
||||
);
|
||||
|
||||
// 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,
|
||||
...(ssl === undefined ? {} : { ssl }),
|
||||
...(validatedConnectionTimeoutMs === undefined
|
||||
? {}
|
||||
: { connectionTimeoutMillis: validatedConnectionTimeoutMs }),
|
||||
|
||||
@@ -35,6 +35,7 @@ export type ModuleRuntimeIssueCode =
|
||||
| "MODULE_ROLLBACK_FAILED"
|
||||
| "MODULE_ROUTE_DEFINITION_INVALID"
|
||||
| "MODULE_ROUTE_DUPLICATE"
|
||||
| "MODULE_ROUTE_MAX_BODY_BYTES_INVALID"
|
||||
| "MODULE_ROUTE_METHOD_INVALID"
|
||||
| "MODULE_ROUTE_PATH_INVALID"
|
||||
| "MODULE_ROUTE_PERMISSION_INVALID"
|
||||
@@ -334,6 +335,7 @@ class StagedModuleHttpRoutes {
|
||||
local.push(
|
||||
Object.freeze({
|
||||
handler: parsed.data.handler,
|
||||
maxBodyBytes: parsed.data.maxBodyBytes,
|
||||
method: parsed.data.method,
|
||||
moduleId: module.manifest.id,
|
||||
path: parsed.data.path,
|
||||
|
||||
@@ -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: past,
|
||||
expiresAt: new Date(Date.now() - 1_000),
|
||||
permission: parsePermissionId("calendar.events.export"),
|
||||
sourceKind: "explicit",
|
||||
userId: userOneId,
|
||||
});
|
||||
} catch (error) {
|
||||
captured = error;
|
||||
}
|
||||
expect(captured).toMatchObject({ code: "AUTHORIZATION_GRANT_EXPIRATION_INVALID" });
|
||||
expect(
|
||||
await safeQuery(admin, `select id from "ekklesia_core_grant" where permission_id = $1`, [
|
||||
"calendar.events.export",
|
||||
]),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("denies a grant once its expiry has passed, evaluated against a real elapsed instant", async () => {
|
||||
const expiringPermission = parsePermissionId("calendar.events.expiring");
|
||||
const expiresAt = new Date(Date.now() + 60_000);
|
||||
await store.createGrant({
|
||||
actor,
|
||||
expiresAt,
|
||||
permission: expiringPermission,
|
||||
sourceKind: "explicit",
|
||||
userId: userOneId,
|
||||
});
|
||||
|
||||
const active = await store.hasActiveGrant({
|
||||
now: new Date(),
|
||||
// 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,
|
||||
});
|
||||
expect(active).toBe(false);
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await store.hasActiveGrant({
|
||||
now: new Date(expiresAt.getTime() + 1_000),
|
||||
permission: expiringPermission,
|
||||
userId: userOneId,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows a grant that has not yet expired", async () => {
|
||||
@@ -537,3 +573,515 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,331 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
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, its name, and its published loopback port are likewise disposable and removed in
|
||||
* `afterAll` even when an assertion fails.
|
||||
*/
|
||||
const containerName = `ekklesia-database-tls-${process.pid}`;
|
||||
let certificateDirectory: string | undefined;
|
||||
let containerStarted = false;
|
||||
let boundPort: 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.");
|
||||
}
|
||||
}
|
||||
|
||||
/** Generates a private CA, a server certificate signed by it whose only SAN is `DNS:localhost`,
|
||||
* and an unrelated second CA. The deliberately narrow SAN is what makes the hostname-mismatch
|
||||
* assertion below meaningful: reaching the very same server through `127.0.0.1` must fail. */
|
||||
async function generateCertificates(directory: string): Promise<void> {
|
||||
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=localhost",
|
||||
],
|
||||
directory,
|
||||
);
|
||||
await writeFile(path.join(directory, "san.cnf"), "subjectAltName=DNS:localhost\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,
|
||||
);
|
||||
}
|
||||
|
||||
async function docker(args: readonly string[]): Promise<string> {
|
||||
const { stdout } = await run("docker", [...args]);
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
async function startTlsPostgres(directory: string): Promise<number> {
|
||||
// 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 a bind-
|
||||
// mounted host file keeps the host's ownership.
|
||||
await docker([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
containerName,
|
||||
"-e",
|
||||
"POSTGRES_DB=ekklesia_tls",
|
||||
"-e",
|
||||
"POSTGRES_USER=ekklesia_tls",
|
||||
"-e",
|
||||
"POSTGRES_PASSWORD=ekklesia_tls",
|
||||
"-v",
|
||||
`${directory}:/certs-src:ro`,
|
||||
"-p",
|
||||
"127.0.0.1:0:5432",
|
||||
"--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",
|
||||
]);
|
||||
containerStarted = true;
|
||||
|
||||
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.",
|
||||
);
|
||||
}
|
||||
return Number(match[1]);
|
||||
}
|
||||
|
||||
/** Bounded readiness polling — never an arbitrary sleep. Resolves once the server accepts a TLS
|
||||
* connection, or fails after the deadline. */
|
||||
async function waitForTlsReadiness(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: "localhost",
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
if (containerStarted) {
|
||||
await docker(["rm", "-f", containerName]).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-"));
|
||||
await generateCertificates(certificateDirectory);
|
||||
boundPort = await startTlsPostgres(certificateDirectory);
|
||||
const ca = await readFile(path.join(certificateDirectory, "ca.crt"), "utf8");
|
||||
await waitForTlsReadiness(boundPort, ca);
|
||||
expect(boundPort).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("connects successfully, and encrypted, when the certificate is trusted and its identity matches the host", async () => {
|
||||
const ca = await readFile(path.join(certificateDirectory as string, "ca.crt"), "utf8");
|
||||
const ssl = databaseTlsConnectionOptions(
|
||||
`postgresql://ekklesia_tls@localhost:${boundPort}/ekklesia_tls`,
|
||||
{ certificateAuthority: ca, mode: "verify-full" },
|
||||
);
|
||||
|
||||
const { encrypted } = await connectWith("localhost", boundPort as number, ssl);
|
||||
expect(encrypted).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a certificate that does not chain to the configured CA", async () => {
|
||||
const otherCa = await readFile(
|
||||
path.join(certificateDirectory as string, "other-ca.crt"),
|
||||
"utf8",
|
||||
);
|
||||
const ssl = databaseTlsConnectionOptions(
|
||||
`postgresql://ekklesia_tls@localhost:${boundPort}/ekklesia_tls`,
|
||||
{ certificateAuthority: otherCa, mode: "verify-full" },
|
||||
);
|
||||
|
||||
await expect(connectWith("localhost", boundPort as number, 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 server certificate's only SAN is `DNS:localhost`, so reaching the identical server via
|
||||
// its IP 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 Node performs no identity
|
||||
// check when it is unset, so `rejectUnauthorized: true` alone would accept this connection.
|
||||
const ca = await readFile(path.join(certificateDirectory as string, "ca.crt"), "utf8");
|
||||
const ssl = databaseTlsConnectionOptions(
|
||||
`postgresql://[email protected]:${boundPort}/ekklesia_tls`,
|
||||
{ certificateAuthority: ca, mode: "verify-full" },
|
||||
);
|
||||
|
||||
await expect(connectWith("127.0.0.1", boundPort as number, 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 ca = await readFile(path.join(certificateDirectory as string, "ca.crt"), "utf8");
|
||||
// Exactly what `ssl: { ca, rejectUnauthorized: true }` alone does — no pinned
|
||||
// `checkServerIdentity`. It connects, which is why the policy supplies one.
|
||||
const { encrypted } = await connectWith("127.0.0.1", boundPort as number, {
|
||||
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 readFile(path.join(certificateDirectory as string, "ca.crt"), "utf8");
|
||||
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());
|
||||
|
||||
expect(principal).toEqual({ kind: "anonymous" });
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps an expired/revoked/malformed session (cookie present, null session) to anonymous when the database is reachable", 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 }) });
|
||||
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(
|
||||
new Headers({ cookie: "better-auth.session_token=stale-or-garbage" }),
|
||||
cookie === undefined ? new Headers() : new Headers({ cookie }),
|
||||
);
|
||||
|
||||
expect(principal).toEqual({ kind: "anonymous" });
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
expect(db.queries).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("never downgrades an infrastructure failure to anonymous: throws when the database is unreachable", async () => {
|
||||
it("reads the real session relation and its expected columns only when the configured session cookie was presented", async () => {
|
||||
const getSession = vi.fn().mockResolvedValue(null);
|
||||
betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() });
|
||||
const db = fakeDb();
|
||||
|
||||
const runtime = createIdentityRuntime({ config: validConfig(), db });
|
||||
const principal = await runtime.resolvePrincipal(
|
||||
new Headers({ cookie: `${productionSessionCookie}=stale-or-garbage` }),
|
||||
);
|
||||
|
||||
// A healthy session store plus an unresolvable token is a genuinely anonymous caller.
|
||||
expect(principal).toEqual({ kind: "anonymous" });
|
||||
// The readiness check is a real read of the session relation's expected columns — never
|
||||
// `select 1`, and never a generic pool ping.
|
||||
expect(db.queries).toEqual(["select:expiresAt,id,token,userId"]);
|
||||
});
|
||||
|
||||
it("detects the session cookie among several cookies and with irregular whitespace", async () => {
|
||||
const getSession = vi.fn().mockResolvedValue(null);
|
||||
const execute = vi.fn().mockRejectedValue(new Error("connection terminated unexpectedly"));
|
||||
betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() });
|
||||
|
||||
const runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb({ execute }) });
|
||||
for (const cookie of [
|
||||
`a=1; ${productionSessionCookie}=tok; b=2`,
|
||||
` ${productionSessionCookie}=tok `,
|
||||
`a=1;${productionSessionCookie}=tok`,
|
||||
// A duplicated session cookie is ambiguous and must fail safe *towards* checking the store.
|
||||
`${productionSessionCookie}=one; ${productionSessionCookie}=two`,
|
||||
// A valueless session cookie still means Better Auth saw the name.
|
||||
`${productionSessionCookie}`,
|
||||
]) {
|
||||
const db = fakeDb();
|
||||
const runtime = createIdentityRuntime({ config: validConfig(), db });
|
||||
await runtime.resolvePrincipal(new Headers({ cookie }));
|
||||
expect(db.queries).toEqual(["select:expiresAt,id,token,userId"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the unprefixed development cookie name when the configured base URL is http", async () => {
|
||||
const getSession = vi.fn().mockResolvedValue(null);
|
||||
betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() });
|
||||
const developmentConfig = validConfig({
|
||||
baseUrl: "http://localhost:3000",
|
||||
trustedOrigins: Object.freeze(["http://localhost:3000"]),
|
||||
});
|
||||
|
||||
const unprefixed = fakeDb();
|
||||
await createIdentityRuntime({ config: developmentConfig, db: unprefixed }).resolvePrincipal(
|
||||
new Headers({ cookie: "better-auth.session_token=tok" }),
|
||||
);
|
||||
expect(unprefixed.queries).toEqual(["select:expiresAt,id,token,userId"]);
|
||||
|
||||
// The production `__Secure-` name is *not* what an http deployment reads.
|
||||
const prefixed = fakeDb();
|
||||
await createIdentityRuntime({ config: developmentConfig, db: prefixed }).resolvePrincipal(
|
||||
new Headers({ cookie: `${productionSessionCookie}=tok` }),
|
||||
);
|
||||
expect(prefixed.queries).toEqual([]);
|
||||
});
|
||||
|
||||
it("never downgrades a broken session store to anonymous, and leaks no database detail", async () => {
|
||||
const getSession = vi.fn().mockResolvedValue(null);
|
||||
betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() });
|
||||
|
||||
// Every distinguishable session-store failure reaches this seam as a rejected read: a missing
|
||||
// relation, a dropped column, a denied privilege, a dead connection, and a timeout alike.
|
||||
const hostileFailures = [
|
||||
new Error('relation "ekklesia_core_session" does not exist'),
|
||||
new Error('column "token" does not exist'),
|
||||
new Error("permission denied for table ekklesia_core_session at 10.1.2.3:5432"),
|
||||
new Error("connection terminated unexpectedly"),
|
||||
new Error("timed out"),
|
||||
];
|
||||
for (const sessionProbeError of hostileFailures) {
|
||||
const runtime = createIdentityRuntime({
|
||||
config: validConfig(),
|
||||
db: fakeDb({ sessionProbeError }),
|
||||
});
|
||||
|
||||
let captured: unknown;
|
||||
try {
|
||||
await runtime.resolvePrincipal(new Headers({ cookie: "better-auth.session_token=x" }));
|
||||
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 () => {
|
||||
|
||||
@@ -444,6 +444,7 @@ describe("module HTTP routes", () => {
|
||||
method: "GET",
|
||||
path: "/items",
|
||||
permission: "single.items.read",
|
||||
maxBodyBytes: 1024,
|
||||
handler: ${noopHandlerSource()},
|
||||
});
|
||||
}
|
||||
@@ -473,8 +474,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 +486,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 +510,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 +533,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 +552,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()} });
|
||||
}
|
||||
};`,
|
||||
});
|
||||
@@ -570,7 +571,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 +587,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 +595,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 +641,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 +661,7 @@ describe("module HTTP routes", () => {
|
||||
method: "GET",
|
||||
path: "/items",
|
||||
permission: "core.authorization.grants.manage",
|
||||
maxBodyBytes: 1024,
|
||||
handler: ${noopHandlerSource()},
|
||||
});
|
||||
}
|
||||
@@ -667,7 +685,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 +707,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 +719,7 @@ describe("module HTTP routes", () => {
|
||||
for (const route of runtime.routes) {
|
||||
expect(Object.keys(route).sort()).toEqual([
|
||||
"handler",
|
||||
"maxBodyBytes",
|
||||
"method",
|
||||
"moduleId",
|
||||
"path",
|
||||
@@ -724,6 +743,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 +765,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 +792,7 @@ describe("module HTTP routes", () => {
|
||||
method: "GET",
|
||||
path: "/items",
|
||||
permission: "mutate.items.read",
|
||||
maxBodyBytes: 1024,
|
||||
handler: ${noopHandlerSource()},
|
||||
};
|
||||
http.route(definition);
|
||||
@@ -800,7 +821,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 +830,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 +850,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"); }
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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.
|
||||
@@ -25,3 +25,6 @@ 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
|
||||
|
||||
@@ -90,8 +90,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
|
||||
@@ -116,6 +117,18 @@ pool. See [ADR 0014](decisions/0014-module-permission-declarations-and-platform-
|
||||
[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.
|
||||
|
||||
Phase 5D pass 1 of 5 hardens the public request perimeter against an independent security audit of
|
||||
the Phase 5C checkpoint: a server-owned client-address resolution boundary (direct mode by default,
|
||||
explicit trusted-proxy mode otherwise) replaces trusting a caller-controlled forwarded header for
|
||||
the authentication rate limiter; public sign-up is disabled by default behind an explicit typed
|
||||
opt-in; every public route — Better Auth, Core's grant mutation, and every module route via a new
|
||||
required `maxBodyBytes` field on the executable route contract — has an explicit, enforced
|
||||
request-body ceiling with one sanitized `413` contract; and the Node HTTP server has finite
|
||||
header/request timeouts. See
|
||||
[ADR 0017](decisions/0017-public-request-perimeter-hardening.md). This pass does not add
|
||||
distributed rate limiting or durable signup-abuse control, and Phase 5D itself is not complete —
|
||||
passes 2–5 and a final independent re-audit remain before Phase 6 may begin.
|
||||
|
||||
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
|
||||
composition, physical storage-bucket provisioning, jobs/workflow automation, richer public resource
|
||||
|
||||
+246
-21
@@ -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
|
||||
@@ -369,8 +592,10 @@ runtime handle to stop in that case, since `startDiscoveredModuleRuntime` never
|
||||
## 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.
|
||||
|
||||
@@ -126,12 +126,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 +160,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 +181,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
|
||||
|
||||
|
||||
@@ -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,20 @@ 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).
|
||||
|
||||
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:
|
||||
|
||||
|
||||
@@ -199,6 +199,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 +212,23 @@ 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.
|
||||
|
||||
`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 +384,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;
|
||||
@@ -151,15 +168,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 +192,7 @@ export interface ModuleRouteDefinition {
|
||||
readonly method: ModuleHttpMethod;
|
||||
readonly path: ModuleRoutePath;
|
||||
readonly permission: PermissionId;
|
||||
readonly maxBodyBytes: number;
|
||||
readonly handler: ModuleRouteHandler;
|
||||
}
|
||||
|
||||
@@ -187,18 +211,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 +249,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 +258,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 +278,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,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isModuleHttpMethod,
|
||||
MODULE_HTTP_METHODS,
|
||||
MODULE_ROUTE_MAX_BODY_BYTES_CEILING,
|
||||
type ModuleRouteDefinitionInput,
|
||||
moduleRoutePathSchema,
|
||||
moduleRouteShapeKey,
|
||||
@@ -22,6 +23,7 @@ function validDefinition(
|
||||
method: "GET",
|
||||
path: "/events/:eventId",
|
||||
permission: "calendar.events.read",
|
||||
maxBodyBytes: 0,
|
||||
handler: noopHandler,
|
||||
...overrides,
|
||||
};
|
||||
@@ -153,6 +155,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 +219,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);
|
||||
|
||||
@@ -48,6 +48,7 @@ describe("defineModuleRuntime", () => {
|
||||
method: "GET",
|
||||
path: "/records",
|
||||
permission: "example.records.read",
|
||||
maxBodyBytes: 0,
|
||||
handler: () => new Response(null, { status: 204 }),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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