feat: expose core identity transport #8
@@ -71,12 +71,15 @@ ever disagree:
|
|||||||
- `pnpm verify` — the full code-only gate (format, lint, boundaries, typecheck, tests, module and
|
- `pnpm verify` — the full code-only gate (format, lint, boundaries, typecheck, tests, module and
|
||||||
migration validation, identity schema drift check, build). Docker-free; never touches a live
|
migration validation, identity schema drift check, build). Docker-free; never touches a live
|
||||||
database.
|
database.
|
||||||
- `pnpm test:migrations:integration` — live, disposable-PostgreSQL migration evidence. Requires
|
- `pnpm test:migrations:integration` — live, disposable-PostgreSQL evidence: migration discovery,
|
||||||
Docker. Run this for any change touching migration discovery, planning, or execution.
|
planning, and execution, plus the `apps/api` HTTP transport (signup, sign-in, principal
|
||||||
|
resolution, sign-out). Requires Docker. Run this for any change touching either area.
|
||||||
- `pnpm doctor` — read-only local readiness report. Never treat it as a substitute for `pnpm
|
- `pnpm doctor` — read-only local readiness report. Never treat it as a substitute for `pnpm
|
||||||
verify` or the integration suite.
|
verify` or the integration suite.
|
||||||
- `pnpm migrations:plan` / `pnpm migrations:apply` — read-only planning vs. mutating execution
|
- `pnpm migrations:plan` / `pnpm migrations:apply` — read-only planning vs. mutating execution
|
||||||
against `DATABASE_URL`. Never run `migrations:apply` without the user's explicit intent.
|
against `DATABASE_URL`. Never run `migrations:apply` without the user's explicit intent.
|
||||||
|
- `pnpm api:start` — starts the identity HTTP transport against an already-migrated `DATABASE_URL`.
|
||||||
|
It never applies migrations itself. Never run it without the user's explicit intent.
|
||||||
|
|
||||||
## When you're unsure
|
## When you're unsure
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ surfaces.
|
|||||||
|
|
||||||
## Current status
|
## Current status
|
||||||
|
|
||||||
Phase 3 is complete and Phase 4A (Core identity foundation) has landed. The repository includes
|
Phase 3 is complete, and Phase 4A (Core identity foundation) and Phase 4B (identity HTTP
|
||||||
|
transport) have landed. The repository includes
|
||||||
public module contracts, independently versioned capabilities, static module manifests,
|
public module contracts, independently versioned capabilities, static module manifests,
|
||||||
deterministic automatic discovery, compiled server loading, a staged capability registry,
|
deterministic automatic discovery, compiled server loading, a staged capability registry,
|
||||||
dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic
|
dependency-ordered lifecycle with rollback, module-owned Drizzle table contracts, deterministic
|
||||||
@@ -19,16 +20,25 @@ concurrency and release, exact destructive approvals, and sanitization against t
|
|||||||
adapter. A Core-owned `pnpm doctor` dashboard combines the existing static discovery and read-only
|
adapter. A Core-owned `pnpm doctor` dashboard combines the existing static discovery and read-only
|
||||||
migration-planning services into one concise readiness report.
|
migration-planning services into one concise readiness report.
|
||||||
|
|
||||||
Phase 4A adds Core-owned identity foundations: a Core-owned Better Auth database schema
|
Phase 4A added Core-owned identity foundations: a Core-owned Better Auth database schema
|
||||||
(`ekklesia_core_user`, `ekklesia_core_session`, `ekklesia_core_account`,
|
(`ekklesia_core_user`, `ekklesia_core_session`, `ekklesia_core_account`,
|
||||||
`ekklesia_core_verification`), a first-class Core-owned migration source that coexists with
|
`ekklesia_core_verification`), a first-class Core-owned migration source that coexists with
|
||||||
module-owned migrations under the exact same migration system, minimal opaque-identity contracts
|
module-owned migrations under the exact same migration system, minimal opaque-identity contracts
|
||||||
in `@ekklesia/contracts`, and an explicit Better Auth construction boundary in Core. See
|
in `@ekklesia/contracts`, and an explicit Better Auth construction boundary in Core. See
|
||||||
[Identity](docs/development/identity.md) and
|
[Identity](docs/development/identity.md) and
|
||||||
[ADR 0010](docs/architecture/decisions/0010-core-identity-and-better-auth-adoption.md) for details.
|
[ADR 0010](docs/architecture/decisions/0010-core-identity-and-better-auth-adoption.md).
|
||||||
**No authentication endpoint or sign-in UI exists yet** — that, along with production
|
|
||||||
approval-provider composition, HTTP routing, jobs, physical storage provisioning, surfaces, and
|
Phase 4B adds identity HTTP transport: a minimal Hono-based `apps/api` composition root
|
||||||
feature modules, remains later vertical slices.
|
(`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
|
||||||
|
[API](docs/development/api.md) and
|
||||||
|
[ADR 0011](docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md).
|
||||||
|
**No email verification, password recovery, Dashboard, or role/permission model exists yet** —
|
||||||
|
that, along with production approval-provider composition, jobs, physical storage provisioning,
|
||||||
|
surfaces, and feature modules, remains later vertical slices. Public registration is not yet ready
|
||||||
|
for a real public launch.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
@@ -113,6 +123,11 @@ CLI into a temporary location and structurally compares it against the checked-i
|
|||||||
and is part of `pnpm verify`. See [Identity](docs/development/identity.md) for the full upgrade
|
and is part of `pnpm verify`. See [Identity](docs/development/identity.md) for the full upgrade
|
||||||
workflow.
|
workflow.
|
||||||
|
|
||||||
|
`pnpm api:start` builds the workspace, then starts the identity HTTP transport
|
||||||
|
(`GET /api/v1/principal`, Better Auth mounted at `/api/auth/*`) against an already-migrated
|
||||||
|
`DATABASE_URL` — it never applies migrations itself. See [API](docs/development/api.md) for
|
||||||
|
required configuration, routes, and a full request/response transcript.
|
||||||
|
|
||||||
Run `pnpm infrastructure:smoke` for an isolated end-to-end PostgreSQL and authenticated S3 check.
|
Run `pnpm infrastructure:smoke` for an isolated end-to-end PostgreSQL and authenticated S3 check.
|
||||||
|
|
||||||
The development services bind only to loopback:
|
The development services bind only to loopback:
|
||||||
@@ -130,6 +145,7 @@ environment.
|
|||||||
- [Repository workflow](docs/development/repository-workflow.md)
|
- [Repository workflow](docs/development/repository-workflow.md)
|
||||||
- [Testing](docs/development/testing.md)
|
- [Testing](docs/development/testing.md)
|
||||||
- [Identity](docs/development/identity.md)
|
- [Identity](docs/development/identity.md)
|
||||||
|
- [API](docs/development/api.md)
|
||||||
- [Module development](docs/module-development/README.md)
|
- [Module development](docs/module-development/README.md)
|
||||||
- [Architecture decisions](docs/architecture/decisions/README.md)
|
- [Architecture decisions](docs/architecture/decisions/README.md)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"name": "@ekklesia/api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/app.d.ts",
|
||||||
|
"import": "./dist/app.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --project tsconfig.build.json",
|
||||||
|
"lint": "biome lint .",
|
||||||
|
"test": "vitest run",
|
||||||
|
"typecheck": "tsc --noEmit --project tsconfig.json"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ekklesia/contracts": "workspace:*",
|
||||||
|
"@ekklesia/core": "workspace:*",
|
||||||
|
"@hono/node-server": "2.0.10",
|
||||||
|
"hono": "4.12.31"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "2.5.3",
|
||||||
|
"@ekklesia/typescript-config": "workspace:*",
|
||||||
|
"@types/node": "24.13.3",
|
||||||
|
"@types/pg": "8.20.0",
|
||||||
|
"pg": "8.22.0",
|
||||||
|
"typescript": "7.0.2",
|
||||||
|
"vitest": "4.1.10"
|
||||||
|
},
|
||||||
|
"ekklesia": {
|
||||||
|
"layer": "app"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { isAuthenticatedPrincipal } from "@ekklesia/contracts";
|
||||||
|
import { type EkklesiaIdentityRuntime, IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||||
|
import { Hono } from "hono";
|
||||||
|
import type { Context } from "hono";
|
||||||
|
import { cors } from "hono/cors";
|
||||||
|
|
||||||
|
export interface CreateApiAppOptions {
|
||||||
|
readonly runtime: EkklesiaIdentityRuntime;
|
||||||
|
/** Exact allowed browser origins. Never a wildcard; combined with `credentials: true`, only an
|
||||||
|
* exact, reflected match is ever returned in `Access-Control-Allow-Origin`. */
|
||||||
|
readonly trustedOrigins: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizedErrorResponse(c: Context, error: unknown, status: 500 | 503) {
|
||||||
|
const code = error instanceof IdentityRuntimeError ? error.code : "INTERNAL_ERROR";
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
return c.json({ code }, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure Hono application factory: no I/O, no environment reads, no process side effects. Suitable
|
||||||
|
* for unit and integration tests via `app.fetch` directly, with a fully injected identity runtime
|
||||||
|
* standing in for real Better Auth/PostgreSQL where appropriate.
|
||||||
|
*/
|
||||||
|
export function createApiApp(options: CreateApiAppOptions): Hono {
|
||||||
|
const { runtime } = options;
|
||||||
|
const allowedOrigins = new Set(options.trustedOrigins);
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
const corsMiddleware = cors({
|
||||||
|
allowHeaders: ["Content-Type"],
|
||||||
|
allowMethods: ["GET", "POST", "OPTIONS"],
|
||||||
|
credentials: true,
|
||||||
|
maxAge: 600,
|
||||||
|
origin: (origin) => (origin && allowedOrigins.has(origin) ? origin : undefined),
|
||||||
|
});
|
||||||
|
app.use("/api/auth/*", corsMiddleware);
|
||||||
|
app.use("/api/v1/*", corsMiddleware);
|
||||||
|
|
||||||
|
app.on(["POST", "GET"], "/api/auth/*", async (c) => {
|
||||||
|
try {
|
||||||
|
return await runtime.handleAuthRequest(c.req.raw);
|
||||||
|
} catch (error) {
|
||||||
|
return sanitizedErrorResponse(c, error, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/v1/principal", async (c) => {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
c.header("Vary", "Origin, Cookie");
|
||||||
|
try {
|
||||||
|
const principal = await runtime.resolvePrincipal(c.req.raw.headers);
|
||||||
|
return c.json(
|
||||||
|
isAuthenticatedPrincipal(principal)
|
||||||
|
? { kind: "authenticated" as const, userId: principal.userId }
|
||||||
|
: { kind: "anonymous" as const },
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
return sanitizedErrorResponse(c, error, 503);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.notFound((c) => {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
return c.json({ code: "NOT_FOUND" }, 404);
|
||||||
|
});
|
||||||
|
app.onError((_error, c) => sanitizedErrorResponse(c, _error, 500));
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import {
|
||||||
|
type CreateIdentityConfigOptions,
|
||||||
|
createIdentityConfig,
|
||||||
|
type IdentityConfig,
|
||||||
|
type IdentityConfigInput,
|
||||||
|
} from "@ekklesia/core/identity";
|
||||||
|
|
||||||
|
export type ApiConfigErrorCode =
|
||||||
|
| "DATABASE_URL_INVALID"
|
||||||
|
| "DATABASE_URL_MISSING"
|
||||||
|
| "HOST_INVALID"
|
||||||
|
| "PORT_INVALID";
|
||||||
|
|
||||||
|
/** Never carries the offending database URL, host, port, or any other caller-supplied value. */
|
||||||
|
export class ApiConfigError extends Error {
|
||||||
|
readonly code: ApiConfigErrorCode;
|
||||||
|
|
||||||
|
constructor(code: ApiConfigErrorCode) {
|
||||||
|
super(apiConfigErrorMessages[code]);
|
||||||
|
this.name = "ApiConfigError";
|
||||||
|
this.code = code;
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiConfigErrorMessages: Readonly<Record<ApiConfigErrorCode, string>> = Object.freeze({
|
||||||
|
DATABASE_URL_INVALID:
|
||||||
|
"The API database URL must be an absolute postgres:// or postgresql:// URL.",
|
||||||
|
DATABASE_URL_MISSING: "The API database URL is required.",
|
||||||
|
HOST_INVALID: "The API listen address must be a non-empty host.",
|
||||||
|
PORT_INVALID:
|
||||||
|
"The API listen port must be an integer between 0 and 65535 (0 requests an OS-assigned port).",
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultHost = "127.0.0.1";
|
||||||
|
const defaultPort = 3000;
|
||||||
|
|
||||||
|
export interface ApiRuntimeConfig {
|
||||||
|
readonly databaseUrl: string;
|
||||||
|
readonly host: string;
|
||||||
|
readonly identity: IdentityConfig;
|
||||||
|
readonly port: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiRuntimeConfigInput {
|
||||||
|
readonly databaseUrl?: unknown;
|
||||||
|
readonly host?: unknown;
|
||||||
|
readonly identity: IdentityConfigInput;
|
||||||
|
readonly port?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateApiRuntimeConfigOptions {
|
||||||
|
readonly environment?: CreateIdentityConfigOptions["environment"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateDatabaseUrl(databaseUrl: unknown): string {
|
||||||
|
if (typeof databaseUrl !== "string" || databaseUrl.trim().length === 0) {
|
||||||
|
throw new ApiConfigError("DATABASE_URL_MISSING");
|
||||||
|
}
|
||||||
|
let protocol: string;
|
||||||
|
try {
|
||||||
|
protocol = new URL(databaseUrl).protocol;
|
||||||
|
} catch {
|
||||||
|
throw new ApiConfigError("DATABASE_URL_INVALID");
|
||||||
|
}
|
||||||
|
if (protocol !== "postgres:" && protocol !== "postgresql:") {
|
||||||
|
throw new ApiConfigError("DATABASE_URL_INVALID");
|
||||||
|
}
|
||||||
|
return databaseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateHost(host: unknown): string {
|
||||||
|
if (host === undefined) {
|
||||||
|
return defaultHost;
|
||||||
|
}
|
||||||
|
if (typeof host !== "string" || host.trim().length === 0) {
|
||||||
|
throw new ApiConfigError("HOST_INVALID");
|
||||||
|
}
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePort(port: unknown): number {
|
||||||
|
if (port === undefined) {
|
||||||
|
return defaultPort;
|
||||||
|
}
|
||||||
|
const numericPort = typeof port === "string" ? Number(port) : port;
|
||||||
|
if (
|
||||||
|
typeof numericPort !== "number" ||
|
||||||
|
!Number.isInteger(numericPort) ||
|
||||||
|
numericPort < 0 ||
|
||||||
|
numericPort > 65_535
|
||||||
|
) {
|
||||||
|
throw new ApiConfigError("PORT_INVALID");
|
||||||
|
}
|
||||||
|
return numericPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates explicit, typed API runtime configuration, delegating identity fields to Core's
|
||||||
|
* existing `createIdentityConfig` rather than defining a competing identity configuration
|
||||||
|
* contract. Performs no environment reads, network calls, or database access.
|
||||||
|
*/
|
||||||
|
export function createApiRuntimeConfig(
|
||||||
|
input: ApiRuntimeConfigInput,
|
||||||
|
options: CreateApiRuntimeConfigOptions = {},
|
||||||
|
): ApiRuntimeConfig {
|
||||||
|
const databaseUrl = validateDatabaseUrl(input.databaseUrl);
|
||||||
|
const host = validateHost(input.host);
|
||||||
|
const port = validatePort(input.port);
|
||||||
|
const identity = createIdentityConfig(
|
||||||
|
input.identity,
|
||||||
|
options.environment === undefined ? {} : { environment: options.environment },
|
||||||
|
);
|
||||||
|
|
||||||
|
return Object.freeze({ databaseUrl, host, identity, port });
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
import {
|
||||||
|
createIdentityDrizzleDatabase,
|
||||||
|
createIdentityRuntime,
|
||||||
|
type IdentityDatabaseHandle,
|
||||||
|
type IdentityRateLimitOverride,
|
||||||
|
} from "@ekklesia/core/identity";
|
||||||
|
import { serve, type ServerType } from "@hono/node-server";
|
||||||
|
import type { Hono } from "hono";
|
||||||
|
import { createApiApp } from "./app.js";
|
||||||
|
import { type ApiRuntimeConfig, createApiRuntimeConfig } from "./config.js";
|
||||||
|
|
||||||
|
const connectionTimeoutMs = 10_000;
|
||||||
|
|
||||||
|
export interface RunningApi {
|
||||||
|
readonly app: Hono;
|
||||||
|
readonly server: ServerType;
|
||||||
|
shutdown(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartApiOptions {
|
||||||
|
/** Test-only escape hatch: never set by the executable entry point. See
|
||||||
|
* `IdentityRateLimitOverride` for why production must leave this unset. */
|
||||||
|
readonly rateLimitOverride?: IdentityRateLimitOverride;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeCloseDatabase(handle: IdentityDatabaseHandle): Promise<void> {
|
||||||
|
try {
|
||||||
|
await handle.close();
|
||||||
|
} catch {
|
||||||
|
// Cleanup failures are swallowed here deliberately: they must never mask a startup failure
|
||||||
|
// that is already propagating, and there is no raw driver detail safe to surface.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindServer(app: Hono, host: string, port: number): Promise<ServerType> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const server = serve({ fetch: app.fetch, hostname: host, port }, () => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
resolve(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
server.once("error", () => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
reject(new Error("The API server failed to start listening."));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicit runtime composition: constructs the PostgreSQL pool and Drizzle database, the Core
|
||||||
|
* identity runtime, and the Hono application, then starts the Node HTTP server. Returns a handle
|
||||||
|
* with an idempotent `shutdown()` so both the executable entry point and tests can stop the
|
||||||
|
* server and close the database pool deterministically. Never applies migrations or generates
|
||||||
|
* schema — the database must already be migrated through `pnpm migrations:apply`.
|
||||||
|
*/
|
||||||
|
export async function startApi(
|
||||||
|
config: ApiRuntimeConfig,
|
||||||
|
options: StartApiOptions = {},
|
||||||
|
): Promise<RunningApi> {
|
||||||
|
const databaseHandle = createIdentityDrizzleDatabase(config.databaseUrl, {
|
||||||
|
connectionTimeoutMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
let runtime: ReturnType<typeof createIdentityRuntime>;
|
||||||
|
try {
|
||||||
|
runtime = createIdentityRuntime({
|
||||||
|
config: config.identity,
|
||||||
|
db: databaseHandle.db,
|
||||||
|
...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await safeCloseDatabase(databaseHandle);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = createApiApp({ runtime, trustedOrigins: config.identity.trustedOrigins });
|
||||||
|
|
||||||
|
let server: ServerType;
|
||||||
|
try {
|
||||||
|
server = await bindServer(app, config.host, config.port);
|
||||||
|
} catch (error) {
|
||||||
|
await safeCloseDatabase(databaseHandle);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
let shuttingDown = false;
|
||||||
|
const shutdown = async (): Promise<void> => {
|
||||||
|
if (shuttingDown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
shuttingDown = true;
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
server.close(() => resolve());
|
||||||
|
});
|
||||||
|
await safeCloseDatabase(databaseHandle);
|
||||||
|
};
|
||||||
|
|
||||||
|
return Object.freeze({ app, server, shutdown });
|
||||||
|
}
|
||||||
|
|
||||||
|
function readConfigFromEnvironment(): {
|
||||||
|
readonly input: Parameters<typeof createApiRuntimeConfig>[0];
|
||||||
|
readonly environment: "development" | "production";
|
||||||
|
} {
|
||||||
|
const trustedOriginsValue = process.env.EKKLESIA_IDENTITY_TRUSTED_ORIGINS;
|
||||||
|
const trustedOrigins = trustedOriginsValue
|
||||||
|
?.split(",")
|
||||||
|
.map((origin) => origin.trim())
|
||||||
|
.filter((origin) => origin.length > 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
environment: process.env.NODE_ENV === "development" ? "development" : "production",
|
||||||
|
input: {
|
||||||
|
databaseUrl: process.env.DATABASE_URL,
|
||||||
|
host: process.env.EKKLESIA_API_HOST,
|
||||||
|
identity: {
|
||||||
|
baseUrl: process.env.EKKLESIA_IDENTITY_BASE_URL,
|
||||||
|
secret: process.env.EKKLESIA_IDENTITY_SECRET,
|
||||||
|
trustedOrigins,
|
||||||
|
},
|
||||||
|
port: process.env.EKKLESIA_API_PORT,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function main(): Promise<void> {
|
||||||
|
const { environment, input } = readConfigFromEnvironment();
|
||||||
|
const config = createApiRuntimeConfig(input, { environment });
|
||||||
|
const running = await startApi(config);
|
||||||
|
|
||||||
|
const address = running.server.address();
|
||||||
|
const boundPort = address && typeof address === "object" ? address.port : config.port;
|
||||||
|
console.log(`Ekklesia API listening on ${config.host}:${boundPort}`);
|
||||||
|
|
||||||
|
let signalReceived = false;
|
||||||
|
const handleSignal = (): void => {
|
||||||
|
if (signalReceived) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
signalReceived = true;
|
||||||
|
running
|
||||||
|
.shutdown()
|
||||||
|
.then(() => process.exit(0))
|
||||||
|
.catch(() => process.exit(1));
|
||||||
|
};
|
||||||
|
process.on("SIGINT", handleSignal);
|
||||||
|
process.on("SIGTERM", handleSignal);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDirectExecution(): boolean {
|
||||||
|
const entrypoint = process.argv[1];
|
||||||
|
return (
|
||||||
|
entrypoint !== undefined && import.meta.url === pathToFileURL(path.resolve(entrypoint)).href
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDirectExecution()) {
|
||||||
|
main().catch(() => {
|
||||||
|
console.error("Ekklesia API failed to start.");
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,631 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
applyModuleMigrations,
|
||||||
|
createPostgresMigrationDatabase,
|
||||||
|
discoverAllMigrations,
|
||||||
|
} from "@ekklesia/core/migrations";
|
||||||
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { Client, type QueryResultRow } from "pg";
|
||||||
|
import { afterAll, describe, expect, it } from "vitest";
|
||||||
|
import { createApiRuntimeConfig } from "../src/config.js";
|
||||||
|
import { startApi, type RunningApi } from "../src/server.js";
|
||||||
|
|
||||||
|
const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL";
|
||||||
|
const coreMigrationsDirectory = path.join(
|
||||||
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"core",
|
||||||
|
"drizzle",
|
||||||
|
);
|
||||||
|
const trackedDatabases = new Set<string>();
|
||||||
|
let databaseCounter = 0;
|
||||||
|
|
||||||
|
class IntegrationHarnessError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "IntegrationHarnessError";
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function harnessError(message: string): IntegrationHarnessError {
|
||||||
|
return new IntegrationHarnessError(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseDatabaseUrl(): string {
|
||||||
|
const value = process.env[integrationDatabaseUrlEnvironment];
|
||||||
|
if (typeof value !== "string" || value.trim() === "") {
|
||||||
|
throw harnessError("The API integration database is not configured.");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = new URL(value);
|
||||||
|
if (parsed.protocol !== "postgres:" && parsed.protocol !== "postgresql:") {
|
||||||
|
throw new TypeError("Unsupported protocol.");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
throw harnessError("The API integration database configuration is invalid.");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function quoteDatabaseName(databaseName: string): string {
|
||||||
|
if (!/^[a-z][a-z0-9_]{0,62}$/u.test(databaseName)) {
|
||||||
|
throw harnessError("The generated integration database name is invalid.");
|
||||||
|
}
|
||||||
|
return `"${databaseName}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDatabaseName(label: string): string {
|
||||||
|
const safeLabel = label
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll(/[^a-z0-9]+/gu, "_")
|
||||||
|
.replaceAll(/^_+|_+$/gu, "")
|
||||||
|
.slice(0, 14);
|
||||||
|
databaseCounter += 1;
|
||||||
|
return `ekk_api_${process.pid}_${databaseCounter}_${safeLabel || "scenario"}_${randomBytes(4).toString("hex")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function databaseUrl(databaseName: string): string {
|
||||||
|
const parsed = new URL(baseDatabaseUrl());
|
||||||
|
parsed.pathname = `/${databaseName}`;
|
||||||
|
parsed.searchParams.delete("application_name");
|
||||||
|
return parsed.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAdministrativeClient(connectionString: string): Promise<Client> {
|
||||||
|
const client = new Client({ connectionString });
|
||||||
|
client.on("error", () => undefined);
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
return client;
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await client.end();
|
||||||
|
} catch {
|
||||||
|
// The public harness failure remains static when connection cleanup also fails.
|
||||||
|
}
|
||||||
|
throw harnessError("The API integration database connection failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeQuery<Row extends QueryResultRow>(
|
||||||
|
client: Client,
|
||||||
|
text: string,
|
||||||
|
values: unknown[] = [],
|
||||||
|
): Promise<readonly Row[]> {
|
||||||
|
try {
|
||||||
|
const result = await client.query<Row>(text, values);
|
||||||
|
return result.rows;
|
||||||
|
} catch {
|
||||||
|
throw harnessError("An integration database administration operation failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeEnd(client: Client): Promise<void> {
|
||||||
|
try {
|
||||||
|
await client.end();
|
||||||
|
} catch {
|
||||||
|
throw harnessError("An integration database connection could not be closed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDatabase(databaseName: string): Promise<void> {
|
||||||
|
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
||||||
|
try {
|
||||||
|
await safeQuery(admin, `CREATE DATABASE ${quoteDatabaseName(databaseName)} TEMPLATE template0`);
|
||||||
|
} finally {
|
||||||
|
await safeEnd(admin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dropDatabase(databaseName: string): Promise<void> {
|
||||||
|
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
||||||
|
try {
|
||||||
|
await safeQuery(
|
||||||
|
admin,
|
||||||
|
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND pid <> pg_backend_pid()",
|
||||||
|
[databaseName],
|
||||||
|
);
|
||||||
|
await safeQuery(
|
||||||
|
admin,
|
||||||
|
`DROP DATABASE IF EXISTS ${quoteDatabaseName(databaseName)} WITH (FORCE)`,
|
||||||
|
);
|
||||||
|
trackedDatabases.delete(databaseName);
|
||||||
|
} finally {
|
||||||
|
await safeEnd(admin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activeConnectionCount(databaseName: string): Promise<number> {
|
||||||
|
const admin = await openAdministrativeClient(baseDatabaseUrl());
|
||||||
|
try {
|
||||||
|
const rows = await safeQuery<{ count: string }>(
|
||||||
|
admin,
|
||||||
|
"SELECT count(*)::text AS count FROM pg_stat_activity WHERE datname = $1",
|
||||||
|
[databaseName],
|
||||||
|
);
|
||||||
|
return Number(rows[0]?.count ?? "0");
|
||||||
|
} finally {
|
||||||
|
await safeEnd(admin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDisposableDatabase(label: string): Promise<{ name: string; url: string }> {
|
||||||
|
const name = makeDatabaseName(label);
|
||||||
|
await createDatabase(name);
|
||||||
|
trackedDatabases.add(name);
|
||||||
|
return { name, url: databaseUrl(name) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyCoreMigrations(url: string): Promise<void> {
|
||||||
|
const emptyModulesDirectory = await mkdtemp(path.join(tmpdir(), "ekklesia-api-no-modules-"));
|
||||||
|
try {
|
||||||
|
const aggregate = await discoverAllMigrations({
|
||||||
|
coreMigrationsDirectory,
|
||||||
|
moduleDiscovery: {
|
||||||
|
moduleSdkVersion: "0.1.0",
|
||||||
|
modulesDirectory: emptyModulesDirectory,
|
||||||
|
platformVersion: "0.1.0",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Phase 4A's committed migration must not have drifted while building this transport slice.
|
||||||
|
expect(aggregate.migrations.map((entry) => entry.identity)).toEqual([
|
||||||
|
"core:0000_identity_core_tables",
|
||||||
|
]);
|
||||||
|
expect(aggregate.migrations[0]?.checksum).toBe(
|
||||||
|
"f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f",
|
||||||
|
);
|
||||||
|
await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) });
|
||||||
|
} finally {
|
||||||
|
await rm(emptyModulesDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const databaseName of [...trackedDatabases]) {
|
||||||
|
await dropDatabase(databaseName).catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const identityBaseUrl = "https://identity.example.test";
|
||||||
|
const dashboardOrigin = "https://dashboard.example.test";
|
||||||
|
const untrustedOrigin = "https://evil.example.test";
|
||||||
|
|
||||||
|
function apiOrigin(running: RunningApi): string {
|
||||||
|
const address = running.server.address();
|
||||||
|
if (!address || typeof address !== "object") {
|
||||||
|
throw harnessError("The API integration server has no bound address.");
|
||||||
|
}
|
||||||
|
return `http://127.0.0.1:${address.port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startMigratedApi(url: string): Promise<RunningApi> {
|
||||||
|
const config = createApiRuntimeConfig(
|
||||||
|
{
|
||||||
|
databaseUrl: url,
|
||||||
|
host: "127.0.0.1",
|
||||||
|
identity: {
|
||||||
|
baseUrl: identityBaseUrl,
|
||||||
|
secret: "integration-test-secret-0123456789abcdefgh",
|
||||||
|
trustedOrigins: [identityBaseUrl, dashboardOrigin],
|
||||||
|
},
|
||||||
|
port: 0,
|
||||||
|
},
|
||||||
|
{ environment: "production" },
|
||||||
|
);
|
||||||
|
// A narrow, explicit test-only override: production composition (server.ts's `main`) never
|
||||||
|
// sets this. Better Auth's built-in sign-in/sign-up rate limit (3 requests / 10s) would
|
||||||
|
// otherwise reject this suite's many sequential requests.
|
||||||
|
return startApi(config, { rateLimitOverride: { max: 1000, window: 1 } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCookieValue(response: Response): string | undefined {
|
||||||
|
return response.headers.get("set-cookie") ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstCookiePair(setCookieHeader: string): string {
|
||||||
|
return setCookieHeader.split(",")[0]?.split(";")[0]?.trim() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SignUpResult {
|
||||||
|
readonly response: Response;
|
||||||
|
readonly body: { token: string | null; user: { id: string; email: string } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signUp(
|
||||||
|
origin: string,
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
headers: Record<string, string> = {},
|
||||||
|
): Promise<SignUpResult> {
|
||||||
|
const response = await fetch(`${origin}/api/auth/sign-up/email`, {
|
||||||
|
body: JSON.stringify({ email, name: "Integration Test User", password }),
|
||||||
|
headers: { "content-type": "application/json", ...headers },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
return { body: (await response.clone().json()) as SignUpResult["body"], response };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SignInResult {
|
||||||
|
readonly cookiePair: string | undefined;
|
||||||
|
readonly response: Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signIn(
|
||||||
|
origin: string,
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
headers: Record<string, string> = {},
|
||||||
|
): Promise<SignInResult> {
|
||||||
|
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
origin: dashboardOrigin,
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const setCookie = setCookieValue(response);
|
||||||
|
return { cookiePair: setCookie ? firstCookiePair(setCookie) : undefined, response };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPrincipal(
|
||||||
|
origin: string,
|
||||||
|
headers: Record<string, string> = {},
|
||||||
|
): Promise<{ body: unknown; response: Response }> {
|
||||||
|
const response = await fetch(`${origin}/api/v1/principal`, { headers });
|
||||||
|
return { body: await response.clone().json(), response };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.sequential("live API — signup, sign-in, principal, sign-out", () => {
|
||||||
|
let database: { name: string; url: string };
|
||||||
|
let running: RunningApi;
|
||||||
|
let origin: string;
|
||||||
|
const emailOne = "[email protected]";
|
||||||
|
const emailTwo = "[email protected]";
|
||||||
|
const password = "correct horse battery staple";
|
||||||
|
|
||||||
|
it("applies migrations and starts the API against a freshly migrated database", async () => {
|
||||||
|
database = await createDisposableDatabase("signup_flow");
|
||||||
|
await applyCoreMigrations(database.url);
|
||||||
|
running = await startMigratedApi(database.url);
|
||||||
|
origin = apiOrigin(running);
|
||||||
|
|
||||||
|
expect(running.server.listening).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the anonymous principal shape before any sign-in", async () => {
|
||||||
|
const { body, response } = await fetchPrincipal(origin);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(body).toEqual({ kind: "anonymous" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publicly creates the first ordinary identity via sign-up, with no automatic session (autoSignIn: false)", async () => {
|
||||||
|
const { body, response } = await signUp(origin, emailOne, password);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(body.token).toBeNull();
|
||||||
|
expect(body.user.email).toBe(emailOne);
|
||||||
|
expect(setCookieValue(response)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not sign in automatically after sign-up", async () => {
|
||||||
|
const { body } = await fetchPrincipal(origin);
|
||||||
|
expect(body).toEqual({ kind: "anonymous" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects wrong credentials without leaking raw internal information", async () => {
|
||||||
|
const { response } = await signIn(origin, emailOne, "wrong password entirely");
|
||||||
|
const text = await response.text();
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(text).not.toContain(database.url);
|
||||||
|
expect(text).not.toContain("postgres");
|
||||||
|
expect(text.toLowerCase()).not.toContain("stack");
|
||||||
|
});
|
||||||
|
|
||||||
|
let sessionCookie: string;
|
||||||
|
|
||||||
|
it("signs in explicitly and returns a valid, host-only, HttpOnly, SameSite=Lax, secure session cookie", async () => {
|
||||||
|
const { cookiePair, response } = await signIn(origin, emailOne, password);
|
||||||
|
const setCookie = setCookieValue(response);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(setCookie).toBeDefined();
|
||||||
|
expect(cookiePair).toBeDefined();
|
||||||
|
sessionCookie = cookiePair as string;
|
||||||
|
|
||||||
|
const cookieText = setCookie as string;
|
||||||
|
expect(cookieText).toContain("HttpOnly");
|
||||||
|
expect(cookieText).toContain("SameSite=Lax");
|
||||||
|
expect(cookieText).toContain("Secure");
|
||||||
|
expect(cookieText).not.toMatch(/domain=/iu);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves the authenticated principal containing only kind and the opaque user id", async () => {
|
||||||
|
const { body, response } = await fetchPrincipal(origin, { cookie: sessionCookie });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(body).toMatchObject({ kind: "authenticated" });
|
||||||
|
const record = body as Record<string, unknown>;
|
||||||
|
expect(Object.keys(record).sort()).toEqual(["kind", "userId"]);
|
||||||
|
expect(typeof record.userId).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves a malformed cookie as anonymous rather than failing", async () => {
|
||||||
|
const { body, response } = await fetchPrincipal(origin, {
|
||||||
|
cookie: "better-auth.session_token=not-a-real-token",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(body).toEqual({ kind: "anonymous" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves a nonexistent cookie name as anonymous", async () => {
|
||||||
|
const { body, response } = await fetchPrincipal(origin, { cookie: "unrelated=value" });
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(body).toEqual({ kind: "anonymous" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not create a second identity on duplicate sign-up, and does not leak unstable internal details", async () => {
|
||||||
|
const { body, response } = await signUp(origin, emailOne, "a totally different password");
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(body.token).toBeNull();
|
||||||
|
expect(typeof body.user.id).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps exactly one user row for the duplicated email after the duplicate sign-up attempt", async () => {
|
||||||
|
const admin = await openAdministrativeClient(database.url);
|
||||||
|
try {
|
||||||
|
const rows = await safeQuery<{ count: string }>(
|
||||||
|
admin,
|
||||||
|
'select count(*)::text as count from "ekklesia_core_user" where email = $1',
|
||||||
|
[emailOne],
|
||||||
|
);
|
||||||
|
expect(rows[0]?.count).toBe("1");
|
||||||
|
} finally {
|
||||||
|
await safeEnd(admin);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signs out and invalidates the session", async () => {
|
||||||
|
const response = await fetch(`${origin}/api/auth/sign-out`, {
|
||||||
|
body: "{}",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
cookie: sessionCookie,
|
||||||
|
origin: dashboardOrigin,
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(setCookieValue(response)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves the old cookie as anonymous after sign-out", async () => {
|
||||||
|
const { body, response } = await fetchPrincipal(origin, { cookie: sessionCookie });
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(body).toEqual({ kind: "anonymous" });
|
||||||
|
});
|
||||||
|
|
||||||
|
let secondUserId: string;
|
||||||
|
|
||||||
|
it("registers and signs in a second, distinct public account", async () => {
|
||||||
|
const signUpResult = await signUp(origin, emailTwo, password);
|
||||||
|
expect(signUpResult.response.status).toBe(200);
|
||||||
|
|
||||||
|
const signInResult = await signIn(origin, emailTwo, password);
|
||||||
|
expect(signInResult.response.status).toBe(200);
|
||||||
|
expect(signInResult.cookiePair).toBeDefined();
|
||||||
|
|
||||||
|
const { body } = await fetchPrincipal(origin, {
|
||||||
|
cookie: signInResult.cookiePair as string,
|
||||||
|
});
|
||||||
|
const record = body as { kind: string; userId: string };
|
||||||
|
expect(record.kind).toBe("authenticated");
|
||||||
|
secondUserId = record.userId;
|
||||||
|
expect(secondUserId).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives the first and second account identical principal shapes with no elevation", async () => {
|
||||||
|
const first = await signIn(origin, emailOne, password);
|
||||||
|
const { body: firstBody } = await fetchPrincipal(origin, {
|
||||||
|
cookie: first.cookiePair as string,
|
||||||
|
});
|
||||||
|
const second = await signIn(origin, emailTwo, password);
|
||||||
|
const { body: secondBody } = await fetchPrincipal(origin, {
|
||||||
|
cookie: second.cookiePair as string,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(Object.keys(firstBody as object).sort()).toEqual(
|
||||||
|
Object.keys(secondBody as object).sort(),
|
||||||
|
);
|
||||||
|
expect((firstBody as { kind: string }).kind).toBe((secondBody as { kind: string }).kind);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives neither account owner, role, permission, membership, or volunteer data at the database level", async () => {
|
||||||
|
const admin = await openAdministrativeClient(database.url);
|
||||||
|
try {
|
||||||
|
const columns = await safeQuery<{ column_name: string }>(
|
||||||
|
admin,
|
||||||
|
"select column_name from information_schema.columns where table_schema = 'public' and table_name = 'ekklesia_core_user'",
|
||||||
|
);
|
||||||
|
const columnNames = columns.map((row) => row.column_name);
|
||||||
|
for (const forbidden of [
|
||||||
|
"role",
|
||||||
|
"is_admin",
|
||||||
|
"is_owner",
|
||||||
|
"is_staff",
|
||||||
|
"is_volunteer",
|
||||||
|
"is_member",
|
||||||
|
"permissions",
|
||||||
|
"grants",
|
||||||
|
]) {
|
||||||
|
expect(columnNames).not.toContain(forbidden);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await safeEnd(admin);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never stores a plaintext password", async () => {
|
||||||
|
const admin = await openAdministrativeClient(database.url);
|
||||||
|
try {
|
||||||
|
const rows = await safeQuery<{ password: string | null }>(
|
||||||
|
admin,
|
||||||
|
`select a.password from "ekklesia_core_account" a
|
||||||
|
inner join "ekklesia_core_user" u on u.id = a.user_id
|
||||||
|
where u.email = $1`,
|
||||||
|
[emailOne],
|
||||||
|
);
|
||||||
|
const stored = rows[0]?.password;
|
||||||
|
expect(stored).toBeTruthy();
|
||||||
|
expect(stored).not.toBe(password);
|
||||||
|
expect(stored?.length ?? 0).toBeGreaterThan(password.length);
|
||||||
|
} finally {
|
||||||
|
await safeEnd(admin);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports a server-to-server sign-up request that carries no Origin header at all", async () => {
|
||||||
|
const { response } = await signUp(origin, "[email protected]", password);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a trusted browser origin for the principal route with credentials", async () => {
|
||||||
|
const response = await fetch(`${origin}/api/v1/principal`, {
|
||||||
|
headers: { cookie: sessionCookie, origin: dashboardOrigin },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("access-control-allow-origin")).toBe(dashboardOrigin);
|
||||||
|
expect(response.headers.get("access-control-allow-credentials")).toBe("true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never returns Access-Control-Allow-Origin for an untrusted browser origin", async () => {
|
||||||
|
const principalResponse = await fetch(`${origin}/api/v1/principal`, {
|
||||||
|
headers: { origin: untrustedOrigin },
|
||||||
|
});
|
||||||
|
expect(principalResponse.headers.get("access-control-allow-origin")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves Better Auth's handler unavailable outside /api/auth/*", async () => {
|
||||||
|
const response = await fetch(`${origin}/sign-in/email`, { method: "POST" });
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shuts down cleanly, closing the HTTP listener and the PostgreSQL pool", async () => {
|
||||||
|
await running.shutdown();
|
||||||
|
|
||||||
|
await expect(fetch(origin, { signal: AbortSignal.timeout(1_000) })).rejects.toBeTruthy();
|
||||||
|
const remainingConnections = await activeConnectionCount(database.name);
|
||||||
|
expect(remainingConnections).toBe(0);
|
||||||
|
|
||||||
|
await dropDatabase(database.name);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Better Auth's own server-side rejection of a cookie-bearing state-changing request from an
|
||||||
|
// untrusted origin (403 INVALID_ORIGIN) was verified directly and repeatedly against this exact
|
||||||
|
// pinned release and this exact compiled `apps/api` server — signup, sign-in, then a sign-out
|
||||||
|
// attempt with the valid session cookie and an untrusted Origin header reliably returns 403, and
|
||||||
|
// the presented session remains valid afterward — but only when driven by a plain Node script
|
||||||
|
// against the running server. The identical scenario could not be made to reproduce reliably when
|
||||||
|
// driven through Vitest specifically (the rejection silently does not fire), even in a single
|
||||||
|
// isolated test with its own fresh database and API instance, and even with `NODE_ENV` forced to
|
||||||
|
// `"production"` inside the test process. The cause was not pinned down to a documented,
|
||||||
|
// reproducible contract inside the time available for this slice, so no automated assertion is
|
||||||
|
// made for this specific request shape here rather than commit a flaky or misleading test. What
|
||||||
|
// *is* covered by an automated test above is the layer this repository's own code owns: CORS
|
||||||
|
// never returns `Access-Control-Allow-Origin` for an untrusted origin, which is the actual
|
||||||
|
// browser-enforced boundary preventing a malicious page from reading a response. See ADR 0011 for
|
||||||
|
// the full account, including the exact manual reproduction used.
|
||||||
|
|
||||||
|
describe.sequential("live API — startup and migration guarantees", () => {
|
||||||
|
it("does not create any tables or migration history when started against an unmigrated database", async () => {
|
||||||
|
const database = await createDisposableDatabase("unmigrated");
|
||||||
|
try {
|
||||||
|
const config = createApiRuntimeConfig(
|
||||||
|
{
|
||||||
|
databaseUrl: database.url,
|
||||||
|
host: "127.0.0.1",
|
||||||
|
identity: {
|
||||||
|
baseUrl: identityBaseUrl,
|
||||||
|
secret: "integration-test-secret-0123456789abcdefgh",
|
||||||
|
trustedOrigins: [identityBaseUrl],
|
||||||
|
},
|
||||||
|
port: 0,
|
||||||
|
},
|
||||||
|
{ environment: "production" },
|
||||||
|
);
|
||||||
|
const running = await startApi(config, { rateLimitOverride: { max: 1000, window: 1 } });
|
||||||
|
const origin = apiOrigin(running);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// A request that touches the (nonexistent) identity tables fails, but must not repair
|
||||||
|
// or create anything. The exact failure body is not asserted here (Better Auth's
|
||||||
|
// internal-error shape for this case is not a stable public contract); only that it is
|
||||||
|
// not a successful sign-up.
|
||||||
|
const response = await fetch(`${origin}/api/auth/sign-up/email`, {
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: "[email protected]",
|
||||||
|
name: "Integration Test User",
|
||||||
|
password: "correct horse battery staple",
|
||||||
|
}),
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
expect(response.status).not.toBe(200);
|
||||||
|
} finally {
|
||||||
|
await running.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = await openAdministrativeClient(database.url);
|
||||||
|
try {
|
||||||
|
const rows = await safeQuery<{ count: string }>(
|
||||||
|
admin,
|
||||||
|
"select count(*)::text as count from information_schema.tables where table_schema = 'public'",
|
||||||
|
);
|
||||||
|
expect(rows[0]?.count).toBe("0");
|
||||||
|
} finally {
|
||||||
|
await safeEnd(admin);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await dropDatabase(database.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.sequential("live API — principal infrastructure failure is never downgraded to anonymous", () => {
|
||||||
|
it("returns a sanitized failure, not a false anonymous 200, when the database becomes unreachable mid-session", async () => {
|
||||||
|
const database = await createDisposableDatabase("infra_failure");
|
||||||
|
await applyCoreMigrations(database.url);
|
||||||
|
const running = await startMigratedApi(database.url);
|
||||||
|
const origin = apiOrigin(running);
|
||||||
|
|
||||||
|
await signUp(origin, "[email protected]", "correct horse battery staple");
|
||||||
|
const { cookiePair } = await signIn(
|
||||||
|
origin,
|
||||||
|
"[email protected]",
|
||||||
|
"correct horse battery staple",
|
||||||
|
);
|
||||||
|
expect(cookiePair).toBeDefined();
|
||||||
|
|
||||||
|
// Force the database itself unreachable out from under the running API pool.
|
||||||
|
await dropDatabase(database.name);
|
||||||
|
|
||||||
|
const { body, response } = await fetchPrincipal(origin, { cookie: cookiePair as string });
|
||||||
|
expect(response.status).toBe(503);
|
||||||
|
expect(body).toEqual({ code: "PRINCIPAL_RESOLUTION_FAILED" });
|
||||||
|
|
||||||
|
await running.shutdown().catch(() => undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { ANONYMOUS_PRINCIPAL, createAuthenticatedPrincipal } from "@ekklesia/contracts";
|
||||||
|
import type { EkklesiaIdentityRuntime } from "@ekklesia/core/identity";
|
||||||
|
import { IdentityRuntimeError } from "@ekklesia/core/identity";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { createApiApp } from "../src/app.js";
|
||||||
|
|
||||||
|
const trustedOrigin = "https://dashboard.example.test";
|
||||||
|
const untrustedOrigin = "https://evil.example.test";
|
||||||
|
|
||||||
|
function fakeRuntime(overrides: Partial<EkklesiaIdentityRuntime> = {}): EkklesiaIdentityRuntime {
|
||||||
|
return {
|
||||||
|
handleAuthRequest: vi.fn().mockResolvedValue(new Response("auth-ok", { status: 200 })),
|
||||||
|
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Ekklesia API Hono application", () => {
|
||||||
|
it("mounts Better Auth exactly under /api/auth/*", async () => {
|
||||||
|
const runtime = fakeRuntime();
|
||||||
|
const app = createApiApp({ 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(runtime.handleAuthRequest).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not route requests outside /api/auth/* to Better Auth", async () => {
|
||||||
|
const runtime = fakeRuntime();
|
||||||
|
const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] });
|
||||||
|
|
||||||
|
const response = await app.request("/sign-in/email", { method: "POST" });
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(runtime.handleAuthRequest).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the anonymous principal shape with no-store caching", async () => {
|
||||||
|
const runtime = fakeRuntime({
|
||||||
|
resolvePrincipal: vi.fn().mockResolvedValue(ANONYMOUS_PRINCIPAL),
|
||||||
|
});
|
||||||
|
const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] });
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/principal");
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({ kind: "anonymous" });
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(response.headers.get("vary")).toContain("Cookie");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the authenticated principal shape containing only kind and userId", async () => {
|
||||||
|
const runtime = fakeRuntime({
|
||||||
|
resolvePrincipal: vi.fn().mockResolvedValue(createAuthenticatedPrincipal("user_123")),
|
||||||
|
});
|
||||||
|
const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] });
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/principal");
|
||||||
|
const body = (await response.json()) as Record<string, unknown>;
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(body).toEqual({ kind: "authenticated", userId: "user_123" });
|
||||||
|
expect(Object.keys(body).sort()).toEqual(["kind", "userId"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a stable sanitized 503 when principal resolution fails, never a false anonymous 200", async () => {
|
||||||
|
const runtime = fakeRuntime({
|
||||||
|
resolvePrincipal: vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue(new IdentityRuntimeError("PRINCIPAL_RESOLUTION_FAILED")),
|
||||||
|
});
|
||||||
|
const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] });
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/principal");
|
||||||
|
|
||||||
|
expect(response.status).toBe(503);
|
||||||
|
expect(await response.json()).toEqual({ code: "PRINCIPAL_RESOLUTION_FAILED" });
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a stable sanitized 500 for an unexpected auth-handler failure, never a raw message", async () => {
|
||||||
|
const runtime = fakeRuntime({
|
||||||
|
handleAuthRequest: vi.fn().mockRejectedValue(new Error("postgresql://admin:secret@db/x")),
|
||||||
|
});
|
||||||
|
const app = createApiApp({ runtime, trustedOrigins: [trustedOrigin] });
|
||||||
|
|
||||||
|
const response = await app.request("/api/auth/sign-in/email", { method: "POST" });
|
||||||
|
const body = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(body).toEqual({ code: "INTERNAL_ERROR" });
|
||||||
|
expect(JSON.stringify(body)).not.toContain("secret");
|
||||||
|
expect(JSON.stringify(body)).not.toContain("postgresql://");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has deterministic 404 behavior for an unknown route", async () => {
|
||||||
|
const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] });
|
||||||
|
|
||||||
|
const response = await app.request("/does-not-exist");
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(await response.json()).toEqual({ code: "NOT_FOUND" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reflects an allowed origin in Access-Control-Allow-Origin with credentials support", async () => {
|
||||||
|
const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] });
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/principal", {
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
|
||||||
|
expect(response.headers.get("access-control-allow-credentials")).toBe("true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never returns Access-Control-Allow-Origin for a disallowed origin", async () => {
|
||||||
|
const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] });
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/principal", {
|
||||||
|
headers: { origin: untrustedOrigin },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.headers.get("access-control-allow-origin")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports requests with no Origin header at all (native/server clients)", async () => {
|
||||||
|
const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] });
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/principal");
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles preflight OPTIONS requests deterministically for an allowed origin", async () => {
|
||||||
|
const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] });
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/principal", {
|
||||||
|
headers: {
|
||||||
|
"access-control-request-method": "GET",
|
||||||
|
origin: trustedOrigin,
|
||||||
|
},
|
||||||
|
method: "OPTIONS",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(204);
|
||||||
|
expect(response.headers.get("access-control-allow-origin")).toBe(trustedOrigin);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never wildcards Access-Control-Allow-Origin", async () => {
|
||||||
|
const app = createApiApp({ runtime: fakeRuntime(), trustedOrigins: [trustedOrigin] });
|
||||||
|
|
||||||
|
const response = await app.request("/api/v1/principal", {
|
||||||
|
headers: { origin: trustedOrigin },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.headers.get("access-control-allow-origin")).not.toBe("*");
|
||||||
|
});
|
||||||
|
|
||||||
|
// "No generalized session/user object stored in Hono context" is verified by construction
|
||||||
|
// rather than by a black-box context probe: unlike Better Auth's own documented Hono example
|
||||||
|
// (which hydrates `c.set("user", session.user)` on every request), createApiApp never calls
|
||||||
|
// `c.set` at all — the only per-request identity data that ever exists is the narrow
|
||||||
|
// EkklesiaPrincipal returned directly from the /api/v1/principal handler above, whose exact
|
||||||
|
// shape (kind + userId only) is already asserted by the principal-shape tests in this file.
|
||||||
|
});
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { ApiConfigError, createApiRuntimeConfig } from "../src/config.js";
|
||||||
|
|
||||||
|
const validSecret = "correct-horse-battery-staple-0123456789";
|
||||||
|
|
||||||
|
function validInput() {
|
||||||
|
return {
|
||||||
|
databaseUrl: "postgresql://ekklesia:[email protected]:5432/ekklesia",
|
||||||
|
host: "127.0.0.1",
|
||||||
|
identity: {
|
||||||
|
baseUrl: "https://identity.example.test",
|
||||||
|
secret: validSecret,
|
||||||
|
trustedOrigins: ["https://identity.example.test"],
|
||||||
|
},
|
||||||
|
port: 3000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("API runtime configuration", () => {
|
||||||
|
it("accepts valid API and identity configuration", () => {
|
||||||
|
const config = createApiRuntimeConfig(validInput(), { environment: "production" });
|
||||||
|
|
||||||
|
expect(config).toEqual({
|
||||||
|
databaseUrl: validInput().databaseUrl,
|
||||||
|
host: "127.0.0.1",
|
||||||
|
identity: {
|
||||||
|
baseUrl: "https://identity.example.test",
|
||||||
|
secret: validSecret,
|
||||||
|
trustedOrigins: ["https://identity.example.test"],
|
||||||
|
},
|
||||||
|
port: 3000,
|
||||||
|
});
|
||||||
|
expect(Object.isFrozen(config)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults host to loopback and port to 3000 when omitted", () => {
|
||||||
|
const { host: _host, port: _port, ...rest } = validInput();
|
||||||
|
const config = createApiRuntimeConfig(rest, { environment: "production" });
|
||||||
|
|
||||||
|
expect(config.host).toBe("127.0.0.1");
|
||||||
|
expect(config.port).toBe(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("permits an explicit non-loopback bind address", () => {
|
||||||
|
const config = createApiRuntimeConfig(
|
||||||
|
{ ...validInput(), host: "0.0.0.0" },
|
||||||
|
{ environment: "production" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(config.host).toBe("0.0.0.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing database URL", () => {
|
||||||
|
let captured: unknown;
|
||||||
|
try {
|
||||||
|
createApiRuntimeConfig({ ...validInput(), databaseUrl: undefined });
|
||||||
|
} catch (error) {
|
||||||
|
captured = error;
|
||||||
|
}
|
||||||
|
expect(captured).toBeInstanceOf(ApiConfigError);
|
||||||
|
expect(captured).toMatchObject({ code: "DATABASE_URL_MISSING" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid database URL", () => {
|
||||||
|
for (const databaseUrl of ["", "not-a-url", "mysql://example.test/db", "http://example.test"]) {
|
||||||
|
let captured: unknown;
|
||||||
|
try {
|
||||||
|
createApiRuntimeConfig({ ...validInput(), databaseUrl });
|
||||||
|
} catch (error) {
|
||||||
|
captured = error;
|
||||||
|
}
|
||||||
|
expect(captured).toBeInstanceOf(ApiConfigError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty listen host", () => {
|
||||||
|
let captured: unknown;
|
||||||
|
try {
|
||||||
|
createApiRuntimeConfig({ ...validInput(), host: "" });
|
||||||
|
} catch (error) {
|
||||||
|
captured = error;
|
||||||
|
}
|
||||||
|
expect(captured).toMatchObject({ code: "HOST_INVALID" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid listen port", () => {
|
||||||
|
for (const port of [-1, 1.5, 70_000, Number.NaN, "not-a-port"]) {
|
||||||
|
let captured: unknown;
|
||||||
|
try {
|
||||||
|
createApiRuntimeConfig({ ...validInput(), port });
|
||||||
|
} catch (error) {
|
||||||
|
captured = error;
|
||||||
|
}
|
||||||
|
expect(captured).toMatchObject({ code: "PORT_INVALID" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts port 0 as an explicit request for an OS-assigned ephemeral port", () => {
|
||||||
|
const config = createApiRuntimeConfig({ ...validInput(), port: 0 });
|
||||||
|
expect(config.port).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delegates identity validation to Core's createIdentityConfig, rejecting a short secret", () => {
|
||||||
|
let captured: unknown;
|
||||||
|
try {
|
||||||
|
createApiRuntimeConfig({
|
||||||
|
...validInput(),
|
||||||
|
identity: { ...validInput().identity, secret: "too-short" },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
captured = error;
|
||||||
|
}
|
||||||
|
expect(captured).toMatchObject({ code: "SECRET_TOO_SHORT" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explicitly allows local development origins only when environment is development", () => {
|
||||||
|
const config = createApiRuntimeConfig(
|
||||||
|
{
|
||||||
|
databaseUrl: validInput().databaseUrl,
|
||||||
|
host: "127.0.0.1",
|
||||||
|
identity: {
|
||||||
|
baseUrl: "http://localhost:3000",
|
||||||
|
secret: validSecret,
|
||||||
|
trustedOrigins: ["http://localhost:3000"],
|
||||||
|
},
|
||||||
|
port: 3000,
|
||||||
|
},
|
||||||
|
{ environment: "development" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(config.identity.baseUrl).toBe("http://localhost:3000");
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
try {
|
||||||
|
createApiRuntimeConfig({ ...validInput(), databaseUrl: hostileUrl, port: -1 });
|
||||||
|
} catch (error) {
|
||||||
|
captured = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(captured).toBeInstanceOf(ApiConfigError);
|
||||||
|
const publicText = `${String(captured)}\n${
|
||||||
|
captured instanceof Error ? (captured.stack ?? "") : ""
|
||||||
|
}\n${JSON.stringify(captured)}`;
|
||||||
|
expect(publicText).not.toContain(hostileUrl);
|
||||||
|
expect(publicText).not.toContain("s3cr3t");
|
||||||
|
expect(publicText).not.toContain("db.internal.example");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const poolConstructor = vi.hoisted(() => vi.fn());
|
||||||
|
const betterAuthMock = vi.hoisted(() => vi.fn());
|
||||||
|
const drizzleAdapterMock = vi.hoisted(() => vi.fn());
|
||||||
|
const serveMock = vi.hoisted(() => vi.fn());
|
||||||
|
const processOnSpy = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("pg", () => ({ Pool: poolConstructor }));
|
||||||
|
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
|
||||||
|
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
|
||||||
|
vi.mock("@hono/node-server", () => ({ serve: serveMock }));
|
||||||
|
|
||||||
|
describe("Ekklesia API import safety", () => {
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
const originalProcessOn = process.on.bind(process);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
poolConstructor.mockClear();
|
||||||
|
betterAuthMock.mockClear();
|
||||||
|
drizzleAdapterMock.mockClear();
|
||||||
|
serveMock.mockClear();
|
||||||
|
processOnSpy.mockClear();
|
||||||
|
for (const key of Object.keys(process.env)) {
|
||||||
|
if (!(key in originalEnv)) {
|
||||||
|
delete process.env[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
process.on = originalProcessOn;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not construct a database pool, Better Auth, or an HTTP server merely by importing the app factory", async () => {
|
||||||
|
await import("../src/app.js");
|
||||||
|
|
||||||
|
expect(poolConstructor).not.toHaveBeenCalled();
|
||||||
|
expect(betterAuthMock).not.toHaveBeenCalled();
|
||||||
|
expect(serveMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not construct a database pool, Better Auth, or an HTTP server merely by importing configuration", async () => {
|
||||||
|
await import("../src/config.js");
|
||||||
|
|
||||||
|
expect(poolConstructor).not.toHaveBeenCalled();
|
||||||
|
expect(betterAuthMock).not.toHaveBeenCalled();
|
||||||
|
expect(serveMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not read environment variables, connect to PostgreSQL, construct Better Auth, start a server, or install signal handlers merely by importing the executable server module", async () => {
|
||||||
|
// Deliberately leave every runtime-required environment variable unset. If importing this
|
||||||
|
// module performed any runtime composition, missing configuration would throw here.
|
||||||
|
delete process.env.DATABASE_URL;
|
||||||
|
delete process.env.EKKLESIA_IDENTITY_SECRET;
|
||||||
|
delete process.env.EKKLESIA_IDENTITY_BASE_URL;
|
||||||
|
delete process.env.EKKLESIA_IDENTITY_TRUSTED_ORIGINS;
|
||||||
|
const onSpy = vi.spyOn(process, "on").mockImplementation(processOnSpy);
|
||||||
|
|
||||||
|
await expect(import("../src/server.js")).resolves.toBeDefined();
|
||||||
|
|
||||||
|
expect(poolConstructor).not.toHaveBeenCalled();
|
||||||
|
expect(betterAuthMock).not.toHaveBeenCalled();
|
||||||
|
expect(drizzleAdapterMock).not.toHaveBeenCalled();
|
||||||
|
expect(serveMock).not.toHaveBeenCalled();
|
||||||
|
expect(processOnSpy).not.toHaveBeenCalled();
|
||||||
|
onSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes startApi and main as explicit functions without invoking them at import time", async () => {
|
||||||
|
const module = await import("../src/server.js");
|
||||||
|
|
||||||
|
expect(typeof module.startApi).toBe("function");
|
||||||
|
expect(typeof module.main).toBe("function");
|
||||||
|
expect(serveMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"noEmit": false,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"sourceMap": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["src/**/*.test.ts", "tests"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "@ekklesia/typescript-config/node.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["node", "vitest/globals"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -22,6 +22,10 @@
|
|||||||
"./diagnostics": {
|
"./diagnostics": {
|
||||||
"types": "./dist/diagnostics/index.d.ts",
|
"types": "./dist/diagnostics/index.d.ts",
|
||||||
"import": "./dist/diagnostics/index.js"
|
"import": "./dist/diagnostics/index.js"
|
||||||
|
},
|
||||||
|
"./identity": {
|
||||||
|
"types": "./dist/identity/index.d.ts",
|
||||||
|
"import": "./dist/identity/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ export function createIdentityDrizzleDatabase(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pool = new Pool(poolConfig);
|
const pool = new Pool(poolConfig);
|
||||||
|
// pg's own documentation requires an error listener on the pool: an idle client can be
|
||||||
|
// terminated by the server (for example, its backend is killed, or the network drops) after
|
||||||
|
// being returned to the pool, and the pool surfaces that as an 'error' event on itself rather
|
||||||
|
// than on any in-flight query. Without a listener, that event is an unhandled Node.js error and
|
||||||
|
// can crash the process even though no caller is doing anything wrong at the time.
|
||||||
|
pool.on("error", () => undefined);
|
||||||
const db = drizzle(pool, { schema: identityAdapterSchema });
|
const db = drizzle(pool, { schema: identityAdapterSchema });
|
||||||
|
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
import { betterAuth } from "better-auth";
|
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|
||||||
import type { IdentityConfig } from "./config.js";
|
|
||||||
import type { IdentityDrizzleDatabase } from "./database.js";
|
|
||||||
import { identityAdapterSchema } from "./schema.js";
|
|
||||||
|
|
||||||
export type IdentityConstructionErrorCode = "IDENTITY_CONSTRUCTION_FAILED";
|
|
||||||
|
|
||||||
/** Never carries the underlying Better Auth error, cause, stack, secret, or URL. */
|
|
||||||
export class IdentityConstructionError extends Error {
|
|
||||||
readonly code: IdentityConstructionErrorCode;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super("Identity could not be constructed.");
|
|
||||||
this.name = "IdentityConstructionError";
|
|
||||||
this.code = "IDENTITY_CONSTRUCTION_FAILED";
|
|
||||||
this.stack = `${this.name}: ${this.message}`;
|
|
||||||
Object.freeze(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateIdentityAuthOptions {
|
|
||||||
readonly config: IdentityConfig;
|
|
||||||
readonly db: IdentityDrizzleDatabase;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deliberately opaque: Phase 4A never exposes the Better Auth instance, its HTTP handler, the
|
|
||||||
* injected database, or the Drizzle adapter beyond this module. A later HTTP transport slice
|
|
||||||
* decides what (if anything) becomes reachable outside core/src/identity.
|
|
||||||
*/
|
|
||||||
export interface EkklesiaIdentityInstance {
|
|
||||||
readonly constructed: true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Core's Better Auth composition boundary. Constructs Better Auth explicitly from injected,
|
|
||||||
* already-validated configuration and an injected Drizzle database — it never reads environment
|
|
||||||
* variables, never resolves a connection string, and enables no provider or plugin beyond the
|
|
||||||
* four core models required for the identity schema itself.
|
|
||||||
*/
|
|
||||||
export function createIdentityAuth(options: CreateIdentityAuthOptions): EkklesiaIdentityInstance {
|
|
||||||
try {
|
|
||||||
betterAuth({
|
|
||||||
baseURL: options.config.baseUrl,
|
|
||||||
database: drizzleAdapter(options.db, {
|
|
||||||
provider: "pg",
|
|
||||||
schema: identityAdapterSchema,
|
|
||||||
}),
|
|
||||||
secret: options.config.secret,
|
|
||||||
trustedOrigins: [...options.config.trustedOrigins],
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
throw new IdentityConstructionError();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.freeze({ constructed: true });
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./config.js";
|
||||||
|
export * from "./database.js";
|
||||||
|
export * from "./runtime.js";
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import {
|
||||||
|
ANONYMOUS_PRINCIPAL,
|
||||||
|
createAuthenticatedPrincipal,
|
||||||
|
type EkklesiaPrincipal,
|
||||||
|
} from "@ekklesia/contracts";
|
||||||
|
import { betterAuth } from "better-auth";
|
||||||
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import type { IdentityConfig } from "./config.js";
|
||||||
|
import type { IdentityDrizzleDatabase } from "./database.js";
|
||||||
|
import { identityAdapterSchema } from "./schema.js";
|
||||||
|
|
||||||
|
export type IdentityRuntimeErrorCode =
|
||||||
|
| "AUTH_REQUEST_FAILED"
|
||||||
|
| "IDENTITY_CONSTRUCTION_FAILED"
|
||||||
|
| "PRINCIPAL_RESOLUTION_FAILED";
|
||||||
|
|
||||||
|
const runtimeErrorMessages: Readonly<Record<IdentityRuntimeErrorCode, string>> = Object.freeze({
|
||||||
|
AUTH_REQUEST_FAILED: "The identity authentication request could not be handled.",
|
||||||
|
IDENTITY_CONSTRUCTION_FAILED: "The identity runtime could not be constructed.",
|
||||||
|
PRINCIPAL_RESOLUTION_FAILED: "The request principal could not be resolved.",
|
||||||
|
});
|
||||||
|
|
||||||
|
const identityDatabaseProbeTimeoutMs = 3_000;
|
||||||
|
|
||||||
|
/** Never carries the underlying Better Auth error, database error, cause, stack, secret, or URL. */
|
||||||
|
export class IdentityRuntimeError extends Error {
|
||||||
|
readonly code: IdentityRuntimeErrorCode;
|
||||||
|
|
||||||
|
constructor(code: IdentityRuntimeErrorCode) {
|
||||||
|
super(runtimeErrorMessages[code]);
|
||||||
|
this.name = "IdentityRuntimeError";
|
||||||
|
this.code = code;
|
||||||
|
this.stack = `${this.name}: ${this.message}`;
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IdentityRateLimitOverride {
|
||||||
|
readonly max?: number;
|
||||||
|
readonly window?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateIdentityRuntimeOptions {
|
||||||
|
readonly config: IdentityConfig;
|
||||||
|
readonly db: IdentityDrizzleDatabase;
|
||||||
|
/**
|
||||||
|
* A narrow, explicit override for the sign-in/sign-up rate-limit window used only by test
|
||||||
|
* composition that issues many such requests quickly. Rate limiting itself is always enabled;
|
||||||
|
* production composition must leave this unset so Better Auth's own strict built-in default
|
||||||
|
* (3 requests / 10 seconds on sign-in and sign-up) applies unmodified.
|
||||||
|
*/
|
||||||
|
readonly rateLimitOverride?: IdentityRateLimitOverride;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliberately narrow: exposes exactly two Fetch-standard capabilities and nothing else. It never
|
||||||
|
* returns the Better Auth instance, `auth.api`, inferred user/session types, the Drizzle adapter,
|
||||||
|
* the database, a session token, cookie contents, a password hash, or Better Auth configuration.
|
||||||
|
*/
|
||||||
|
export interface EkklesiaIdentityRuntime {
|
||||||
|
/** Delegates directly to Better Auth's Fetch handler. Normal Better Auth HTTP responses
|
||||||
|
* (including its own 4xx error responses) pass through unmodified; only a genuinely unexpected
|
||||||
|
* failure is converted to a sanitized `IdentityRuntimeError`. */
|
||||||
|
handleAuthRequest(request: Request): Promise<Response>;
|
||||||
|
/** Resolves the caller's Ekklesia principal from request headers. Returns the anonymous
|
||||||
|
* principal for a missing, expired, revoked, or malformed session — never for an infrastructure
|
||||||
|
* failure, which instead throws a sanitized `IdentityRuntimeError`. */
|
||||||
|
resolvePrincipal(headers: Headers): Promise<EkklesiaPrincipal>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withTimeout<T>(operation: Promise<T>, timeoutMs: number): Promise<T> {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
operation,
|
||||||
|
new Promise<never>((_resolve, reject) => {
|
||||||
|
timer = setTimeout(() => reject(new Error("timed out")), timeoutMs);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeIdentityDatabase(db: IdentityDrizzleDatabase): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await withTimeout(db.execute(sql`select 1`), identityDatabaseProbeTimeoutMs);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function constructBetterAuth<T>(build: () => T): T {
|
||||||
|
try {
|
||||||
|
return build();
|
||||||
|
} catch {
|
||||||
|
throw new IdentityRuntimeError("IDENTITY_CONSTRUCTION_FAILED");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core's Better Auth composition boundary and Fetch-compatible identity transport. Constructs
|
||||||
|
* Better Auth explicitly from injected, already-validated configuration and an injected Drizzle
|
||||||
|
* database — it never reads environment variables and enables no provider or plugin beyond the
|
||||||
|
* four core models and email/password sign-up/sign-in required for this slice.
|
||||||
|
*/
|
||||||
|
export function createIdentityRuntime(
|
||||||
|
options: CreateIdentityRuntimeOptions,
|
||||||
|
): EkklesiaIdentityRuntime {
|
||||||
|
const rateLimitOverride = options.rateLimitOverride;
|
||||||
|
|
||||||
|
const auth = constructBetterAuth(() =>
|
||||||
|
betterAuth({
|
||||||
|
baseURL: options.config.baseUrl,
|
||||||
|
database: drizzleAdapter(options.db, {
|
||||||
|
provider: "pg",
|
||||||
|
schema: identityAdapterSchema,
|
||||||
|
}),
|
||||||
|
emailAndPassword: {
|
||||||
|
autoSignIn: false,
|
||||||
|
disableSignUp: false,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
rateLimit: {
|
||||||
|
enabled: true,
|
||||||
|
max: 100,
|
||||||
|
window: 10,
|
||||||
|
...(rateLimitOverride
|
||||||
|
? {
|
||||||
|
customRules: {
|
||||||
|
"/sign-in/email": {
|
||||||
|
max: rateLimitOverride.max ?? 100,
|
||||||
|
window: rateLimitOverride.window ?? 10,
|
||||||
|
},
|
||||||
|
"/sign-up/email": {
|
||||||
|
max: rateLimitOverride.max ?? 100,
|
||||||
|
window: rateLimitOverride.window ?? 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
secret: options.config.secret,
|
||||||
|
trustedOrigins: [...options.config.trustedOrigins],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
handleAuthRequest: async (request: Request): Promise<Response> => {
|
||||||
|
try {
|
||||||
|
return await auth.handler(request);
|
||||||
|
} catch {
|
||||||
|
throw new IdentityRuntimeError("AUTH_REQUEST_FAILED");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
resolvePrincipal: async (headers: Headers): Promise<EkklesiaPrincipal> => {
|
||||||
|
let session: Awaited<ReturnType<typeof auth.api.getSession>>;
|
||||||
|
try {
|
||||||
|
session = await auth.api.getSession({ headers });
|
||||||
|
} catch {
|
||||||
|
// Defense in depth: the pinned Better Auth release already catches internal failures and
|
||||||
|
// resolves to null instead of throwing here, but a future release is not guaranteed to.
|
||||||
|
throw new IdentityRuntimeError("PRINCIPAL_RESOLUTION_FAILED");
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session?.user?.id;
|
||||||
|
if (typeof userId === "string") {
|
||||||
|
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) {
|
||||||
|
throw new IdentityRuntimeError("PRINCIPAL_RESOLUTION_FAILED");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ANONYMOUS_PRINCIPAL;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./diagnostics/index.js";
|
export * from "./diagnostics/index.js";
|
||||||
|
export * from "./identity/index.js";
|
||||||
export * from "./modules/index.js";
|
export * from "./modules/index.js";
|
||||||
export * from "./migrations/index.js";
|
export * from "./migrations/index.js";
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ describe("Core identity Drizzle database composition", () => {
|
|||||||
|
|
||||||
it("explicitly constructs the pool only when called, with the identity schema attached", () => {
|
it("explicitly constructs the pool only when called, with the identity schema attached", () => {
|
||||||
poolConstructor.mockImplementation(function fakePool() {
|
poolConstructor.mockImplementation(function fakePool() {
|
||||||
return { end: async () => undefined };
|
return { end: async () => undefined, on: vi.fn() };
|
||||||
});
|
});
|
||||||
|
|
||||||
const handle = createIdentityDrizzleDatabase(
|
const handle = createIdentityDrizzleDatabase(
|
||||||
@@ -52,7 +52,7 @@ describe("Core identity Drizzle database composition", () => {
|
|||||||
|
|
||||||
it("passes the connection timeout through to the pool configuration", () => {
|
it("passes the connection timeout through to the pool configuration", () => {
|
||||||
poolConstructor.mockImplementation(function fakePool() {
|
poolConstructor.mockImplementation(function fakePool() {
|
||||||
return { end: async () => undefined };
|
return { end: async () => undefined, on: vi.fn() };
|
||||||
});
|
});
|
||||||
poolConstructor.mockClear();
|
poolConstructor.mockClear();
|
||||||
|
|
||||||
@@ -64,4 +64,21 @@ describe("Core identity Drizzle database composition", () => {
|
|||||||
expect.objectContaining({ connectionTimeoutMillis: 5_000 }),
|
expect.objectContaining({ connectionTimeoutMillis: 5_000 }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("registers a pool-level error listener so an idle client failure never becomes an uncaught exception", () => {
|
||||||
|
const on = vi.fn();
|
||||||
|
poolConstructor.mockImplementation(function fakePool() {
|
||||||
|
return { end: async () => undefined, on };
|
||||||
|
});
|
||||||
|
|
||||||
|
createIdentityDrizzleDatabase("postgresql://example.test/ekklesia");
|
||||||
|
|
||||||
|
expect(on).toHaveBeenCalledWith("error", expect.any(Function));
|
||||||
|
const handler = on.mock.calls.find((call) => call[0] === "error")?.[1] as
|
||||||
|
| ((error: unknown) => void)
|
||||||
|
| undefined;
|
||||||
|
expect(() =>
|
||||||
|
handler?.(new Error("terminating connection due to administrator command")),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const betterAuthMock = vi.hoisted(() => vi.fn());
|
|
||||||
const drizzleAdapterMock = vi.hoisted(() =>
|
|
||||||
vi.fn((db: unknown, options: unknown) => ({ __adapterOptions: options, db })),
|
|
||||||
);
|
|
||||||
|
|
||||||
vi.mock("better-auth", () => ({ betterAuth: betterAuthMock }));
|
|
||||||
vi.mock("better-auth/adapters/drizzle", () => ({ drizzleAdapter: drizzleAdapterMock }));
|
|
||||||
|
|
||||||
import type { IdentityConfig } from "../src/identity/config.js";
|
|
||||||
import { createIdentityAuth, IdentityConstructionError } from "../src/identity/factory.js";
|
|
||||||
import { identityAdapterSchema } from "../src/identity/schema.js";
|
|
||||||
|
|
||||||
function validConfig(): IdentityConfig {
|
|
||||||
return Object.freeze({
|
|
||||||
baseUrl: "https://identity.example.test",
|
|
||||||
secret: "correct-horse-battery-staple-0123456789",
|
|
||||||
trustedOrigins: Object.freeze(["https://identity.example.test"]),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Core identity Better Auth construction boundary", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
betterAuthMock.mockReset();
|
|
||||||
drizzleAdapterMock.mockClear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("constructs explicitly with narrow injected dependencies and returns only a success marker", () => {
|
|
||||||
betterAuthMock.mockReturnValue({ api: {}, handler: () => undefined });
|
|
||||||
const fakeDb = { __fakeDb: true };
|
|
||||||
|
|
||||||
const result = createIdentityAuth({ config: validConfig(), db: fakeDb as never });
|
|
||||||
|
|
||||||
expect(result).toEqual({ constructed: true });
|
|
||||||
expect(Object.keys(result)).toEqual(["constructed"]);
|
|
||||||
expect(Object.isFrozen(result)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not expose the raw database, adapter, or Better Auth instance on the returned value", () => {
|
|
||||||
betterAuthMock.mockReturnValue({ api: {}, handler: () => undefined });
|
|
||||||
const fakeDb = { __fakeDb: true };
|
|
||||||
|
|
||||||
const result = createIdentityAuth({ config: validConfig(), db: fakeDb as never });
|
|
||||||
|
|
||||||
expect(JSON.stringify(result)).not.toContain("__fakeDb");
|
|
||||||
expect(JSON.stringify(result)).not.toContain("handler");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes the explicit ekklesia_core_* schema mapping to the Drizzle adapter", () => {
|
|
||||||
betterAuthMock.mockReturnValue({});
|
|
||||||
const fakeDb = { __fakeDb: true };
|
|
||||||
|
|
||||||
createIdentityAuth({ config: validConfig(), db: fakeDb as never });
|
|
||||||
|
|
||||||
expect(drizzleAdapterMock).toHaveBeenCalledTimes(1);
|
|
||||||
const [db, options] = drizzleAdapterMock.mock.calls[0] as [unknown, unknown];
|
|
||||||
expect(db).toBe(fakeDb);
|
|
||||||
expect(options).toEqual({ provider: "pg", schema: identityAdapterSchema });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("enables no provider or plugin beyond the four core models", () => {
|
|
||||||
betterAuthMock.mockReturnValue({});
|
|
||||||
|
|
||||||
createIdentityAuth({ config: validConfig(), db: {} as never });
|
|
||||||
|
|
||||||
expect(betterAuthMock).toHaveBeenCalledTimes(1);
|
|
||||||
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
|
|
||||||
expect(Object.keys(options).sort()).toEqual([
|
|
||||||
"baseURL",
|
|
||||||
"database",
|
|
||||||
"secret",
|
|
||||||
"trustedOrigins",
|
|
||||||
]);
|
|
||||||
expect(options.baseURL).toBe(validConfig().baseUrl);
|
|
||||||
expect(options.secret).toBe(validConfig().secret);
|
|
||||||
expect(options.trustedOrigins).toEqual([...validConfig().trustedOrigins]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sanitizes a construction failure without leaking the underlying error, secret, or URL", () => {
|
|
||||||
const hostileSecret = validConfig().secret;
|
|
||||||
betterAuthMock.mockImplementation(() => {
|
|
||||||
throw new Error(
|
|
||||||
`Better Auth failed for secret ${hostileSecret} at https://internal.example.test`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
let captured: unknown;
|
|
||||||
try {
|
|
||||||
createIdentityAuth({ config: validConfig(), db: {} as never });
|
|
||||||
} catch (error) {
|
|
||||||
captured = error;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(captured).toBeInstanceOf(IdentityConstructionError);
|
|
||||||
expect(captured).toMatchObject({ code: "IDENTITY_CONSTRUCTION_FAILED" });
|
|
||||||
const publicText = `${String(captured)}\n${
|
|
||||||
captured instanceof Error ? (captured.stack ?? "") : ""
|
|
||||||
}`;
|
|
||||||
expect(publicText).not.toContain(hostileSecret);
|
|
||||||
expect(publicText).not.toContain("internal.example.test");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sanitizes an adapter construction failure the same way", () => {
|
|
||||||
drizzleAdapterMock.mockImplementation(() => {
|
|
||||||
throw new Error("adapter exploded with postgresql://admin:[email protected]/ekklesia");
|
|
||||||
});
|
|
||||||
|
|
||||||
let captured: unknown;
|
|
||||||
try {
|
|
||||||
createIdentityAuth({ config: validConfig(), db: {} as never });
|
|
||||||
} catch (error) {
|
|
||||||
captured = error;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(captured).toBeInstanceOf(IdentityConstructionError);
|
|
||||||
const publicText = `${String(captured)}\n${
|
|
||||||
captured instanceof Error ? (captured.stack ?? "") : ""
|
|
||||||
}`;
|
|
||||||
expect(publicText).not.toContain("db.internal");
|
|
||||||
expect(publicText).not.toContain("admin");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const betterAuthMock = vi.hoisted(() => vi.fn());
|
||||||
|
const drizzleAdapterMock = vi.hoisted(() =>
|
||||||
|
vi.fn((db: unknown, options: unknown) => ({ __adapterOptions: options, db })),
|
||||||
|
);
|
||||||
|
|
||||||
|
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 { identityAdapterSchema } from "../src/identity/schema.js";
|
||||||
|
|
||||||
|
function validConfig(): IdentityConfig {
|
||||||
|
return Object.freeze({
|
||||||
|
baseUrl: "https://identity.example.test",
|
||||||
|
secret: "correct-horse-battery-staple-0123456789",
|
||||||
|
trustedOrigins: Object.freeze(["https://identity.example.test"]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeDb(overrides: Partial<{ execute: ReturnType<typeof vi.fn> }> = {}) {
|
||||||
|
return { __fakeDb: true, execute: vi.fn().mockResolvedValue(undefined), ...overrides } as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Core identity runtime construction boundary", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
betterAuthMock.mockReset();
|
||||||
|
drizzleAdapterMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not construct Better Auth or the Drizzle adapter merely because the module is imported", () => {
|
||||||
|
expect(betterAuthMock).not.toHaveBeenCalled();
|
||||||
|
expect(drizzleAdapterMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("constructs explicitly and returns only the two narrow transport capabilities", () => {
|
||||||
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
|
|
||||||
|
const runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb() });
|
||||||
|
|
||||||
|
expect(Object.keys(runtime).sort()).toEqual(["handleAuthRequest", "resolvePrincipal"]);
|
||||||
|
expect(typeof runtime.handleAuthRequest).toBe("function");
|
||||||
|
expect(typeof runtime.resolvePrincipal).toBe("function");
|
||||||
|
expect(Object.isFrozen(runtime)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not expose the raw database, adapter, or Better Auth instance on the returned value", () => {
|
||||||
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
|
const db = fakeDb();
|
||||||
|
|
||||||
|
const runtime = createIdentityRuntime({ config: validConfig(), db });
|
||||||
|
|
||||||
|
expect(JSON.stringify(runtime)).toBe("{}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the explicit ekklesia_core_* schema mapping to the Drizzle adapter", () => {
|
||||||
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
|
const db = fakeDb();
|
||||||
|
|
||||||
|
createIdentityRuntime({ config: validConfig(), db });
|
||||||
|
|
||||||
|
expect(drizzleAdapterMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [passedDb, options] = drizzleAdapterMock.mock.calls[0] as [unknown, unknown];
|
||||||
|
expect(passedDb).toBe(db);
|
||||||
|
expect(options).toEqual({ provider: "pg", schema: identityAdapterSchema });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enables exactly email/password (autoSignIn disabled) and rate limiting, nothing else", () => {
|
||||||
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
|
|
||||||
|
createIdentityRuntime({ config: validConfig(), db: fakeDb() });
|
||||||
|
|
||||||
|
expect(betterAuthMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
|
||||||
|
expect(Object.keys(options).sort()).toEqual([
|
||||||
|
"baseURL",
|
||||||
|
"database",
|
||||||
|
"emailAndPassword",
|
||||||
|
"rateLimit",
|
||||||
|
"secret",
|
||||||
|
"trustedOrigins",
|
||||||
|
]);
|
||||||
|
expect(options.emailAndPassword).toEqual({
|
||||||
|
autoSignIn: false,
|
||||||
|
disableSignUp: false,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
expect(options.rateLimit).toEqual({ enabled: true, max: 100, window: 10 });
|
||||||
|
expect(options.baseURL).toBe(validConfig().baseUrl);
|
||||||
|
expect(options.secret).toBe(validConfig().secret);
|
||||||
|
expect(options.trustedOrigins).toEqual([...validConfig().trustedOrigins]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only widens the sign-in/sign-up rate-limit window when a narrow override is explicitly injected", () => {
|
||||||
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
|
||||||
|
|
||||||
|
createIdentityRuntime({
|
||||||
|
config: validConfig(),
|
||||||
|
db: fakeDb(),
|
||||||
|
rateLimitOverride: { max: 1000, window: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
|
||||||
|
expect(options.rateLimit).toEqual({
|
||||||
|
customRules: {
|
||||||
|
"/sign-in/email": { max: 1000, window: 1 },
|
||||||
|
"/sign-up/email": { max: 1000, window: 1 },
|
||||||
|
},
|
||||||
|
enabled: true,
|
||||||
|
max: 100,
|
||||||
|
window: 10,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sanitizes a construction failure without leaking the underlying error, secret, or URL", () => {
|
||||||
|
const hostileSecret = validConfig().secret;
|
||||||
|
betterAuthMock.mockImplementation(() => {
|
||||||
|
throw new Error(
|
||||||
|
`Better Auth failed for secret ${hostileSecret} at https://internal.example.test`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
let captured: unknown;
|
||||||
|
try {
|
||||||
|
createIdentityRuntime({ config: validConfig(), db: fakeDb() });
|
||||||
|
} catch (error) {
|
||||||
|
captured = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(captured).toBeInstanceOf(IdentityRuntimeError);
|
||||||
|
expect(captured).toMatchObject({ code: "IDENTITY_CONSTRUCTION_FAILED" });
|
||||||
|
const publicText = `${String(captured)}\n${
|
||||||
|
captured instanceof Error ? (captured.stack ?? "") : ""
|
||||||
|
}`;
|
||||||
|
expect(publicText).not.toContain(hostileSecret);
|
||||||
|
expect(publicText).not.toContain("internal.example.test");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Core identity runtime — handleAuthRequest", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
betterAuthMock.mockReset();
|
||||||
|
drizzleAdapterMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delegates directly to Better Auth's Fetch handler and passes the response through unmodified", async () => {
|
||||||
|
const expectedResponse = new Response("ok", { status: 200 });
|
||||||
|
const handler = vi.fn().mockResolvedValue(expectedResponse);
|
||||||
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler });
|
||||||
|
|
||||||
|
const runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb() });
|
||||||
|
const request = new Request("https://identity.example.test/api/auth/sign-in/email");
|
||||||
|
const response = await runtime.handleAuthRequest(request);
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledWith(request);
|
||||||
|
expect(response).toBe(expectedResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes through Better Auth's own error responses (e.g. 401, 403, 422) unmodified", async () => {
|
||||||
|
const errorResponse = new Response(JSON.stringify({ code: "INVALID_EMAIL_OR_PASSWORD" }), {
|
||||||
|
status: 401,
|
||||||
|
});
|
||||||
|
const handler = vi.fn().mockResolvedValue(errorResponse);
|
||||||
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler });
|
||||||
|
|
||||||
|
const runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb() });
|
||||||
|
const response = await runtime.handleAuthRequest(
|
||||||
|
new Request("https://identity.example.test/x"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sanitizes a genuinely unexpected handler failure", async () => {
|
||||||
|
const handler = vi.fn().mockRejectedValue(new Error("postgresql://admin:[email protected]/x"));
|
||||||
|
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler });
|
||||||
|
|
||||||
|
const runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb() });
|
||||||
|
|
||||||
|
let captured: unknown;
|
||||||
|
try {
|
||||||
|
await runtime.handleAuthRequest(new Request("https://identity.example.test/x"));
|
||||||
|
} catch (error) {
|
||||||
|
captured = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(captured).toBeInstanceOf(IdentityRuntimeError);
|
||||||
|
expect(captured).toMatchObject({ code: "AUTH_REQUEST_FAILED" });
|
||||||
|
const publicText = `${String(captured)}\n${
|
||||||
|
captured instanceof Error ? (captured.stack ?? "") : ""
|
||||||
|
}`;
|
||||||
|
expect(publicText).not.toContain("db.internal");
|
||||||
|
expect(publicText).not.toContain("admin");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Core identity runtime — resolvePrincipal", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
betterAuthMock.mockReset();
|
||||||
|
drizzleAdapterMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps a valid session to an authenticated principal containing only the opaque user id", 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 runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb() });
|
||||||
|
const principal = await runtime.resolvePrincipal(
|
||||||
|
new Headers({ cookie: "better-auth.session_token=tok" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(principal).toEqual({ kind: "authenticated", userId: "user_123" });
|
||||||
|
expect(Object.keys(principal)).toEqual(["kind", "userId"]);
|
||||||
|
expect(Object.isFrozen(principal)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps a missing session with no cookie header to anonymous without probing the database", 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 }) });
|
||||||
|
const principal = await runtime.resolvePrincipal(
|
||||||
|
new Headers({ cookie: "better-auth.session_token=stale-or-garbage" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(principal).toEqual({ kind: "anonymous" });
|
||||||
|
expect(execute).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never downgrades an infrastructure failure to anonymous: throws when the database is unreachable", async () => {
|
||||||
|
const getSession = vi.fn().mockResolvedValue(null);
|
||||||
|
const execute = vi.fn().mockRejectedValue(new Error("connection terminated unexpectedly"));
|
||||||
|
betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() });
|
||||||
|
|
||||||
|
const runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb({ execute }) });
|
||||||
|
|
||||||
|
let captured: unknown;
|
||||||
|
try {
|
||||||
|
await runtime.resolvePrincipal(new Headers({ cookie: "better-auth.session_token=x" }));
|
||||||
|
} catch (error) {
|
||||||
|
captured = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(captured).toBeInstanceOf(IdentityRuntimeError);
|
||||||
|
expect(captured).toMatchObject({ code: "PRINCIPAL_RESOLUTION_FAILED" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats an unexpected throw from getSession itself as a sanitized infrastructure failure", async () => {
|
||||||
|
const hostile = "password=hunter2 host=db.internal.example port=5432";
|
||||||
|
const getSession = vi.fn().mockRejectedValue(new Error(hostile));
|
||||||
|
betterAuthMock.mockReturnValue({ api: { getSession }, handler: vi.fn() });
|
||||||
|
|
||||||
|
const runtime = createIdentityRuntime({ config: validConfig(), db: fakeDb() });
|
||||||
|
|
||||||
|
let captured: unknown;
|
||||||
|
try {
|
||||||
|
await runtime.resolvePrincipal(new Headers({ cookie: "better-auth.session_token=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 ?? "") : ""
|
||||||
|
}`;
|
||||||
|
expect(publicText).not.toContain("hunter2");
|
||||||
|
expect(publicText).not.toContain("db.internal.example");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
# 0011: Identity HTTP transport and public enrollment policy
|
||||||
|
|
||||||
|
- Status: Accepted
|
||||||
|
- Date: 2026-07-21
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR 0010 established Core's identity schema, Core-owned identity migrations, and a Better Auth
|
||||||
|
construction boundary, but deliberately stopped before exposing anything over HTTP: "no
|
||||||
|
authentication endpoint or UI exists yet." Ekklesia now needs a real, publicly reachable way for a
|
||||||
|
person to create an account and sign in, and a way for any Core/Surface/API code to resolve who
|
||||||
|
(if anyone) is making a given request.
|
||||||
|
|
||||||
|
This is also the first PR to make a product policy decision about *who* may hold an Ekklesia
|
||||||
|
identity and what that identity is worth. The project owner has been explicit: public registration
|
||||||
|
is intentional, a registered account is not a church-membership record, and the platform must not
|
||||||
|
let signup order or account creation itself grant any authority. Those decisions govern this ADR
|
||||||
|
as much as the technical transport choices do.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### Public registration is an intentional product policy, not a placeholder
|
||||||
|
|
||||||
|
Anyone may create an Ekklesia account through email/password sign-up. Registration creates only an
|
||||||
|
**authentication identity** — a row proving "this email and password combination can prove control
|
||||||
|
of a session" — not a church-person record, a membership record, or evidence of attending,
|
||||||
|
volunteering, or belonging to the church in any way. A future church-person/profile model, not
|
||||||
|
Better Auth's `user` table, is where that domain data will eventually live, linked only through the
|
||||||
|
opaque `EkklesiaUserId` (ADR 0010).
|
||||||
|
|
||||||
|
### Every signup receives identical, lowest-privilege baseline status
|
||||||
|
|
||||||
|
Every account created through public registration receives the same thing: an authenticated
|
||||||
|
`EkklesiaPrincipal` carrying only its own opaque user ID. That status exists because being
|
||||||
|
authenticated is useful on its own — it is what will eventually let a signed-in person have
|
||||||
|
ownership-scoped personal features such as watch history, continue-watching state, saved content,
|
||||||
|
or preferences, all scoped to *their own* data. None of those features are implemented in this PR;
|
||||||
|
only the identity that will eventually carry them is. Baseline authenticated access is **not** an
|
||||||
|
administrative role, is not stored as a role/grant row (there is no role or grant system yet), and
|
||||||
|
is not distinguishable in storage from any other account beyond Better Auth's own required
|
||||||
|
`user`/`account` rows. It never grants Dashboard access, module-management access, staff or
|
||||||
|
volunteer-management access, or any media/event/form/roster/people-management permission — none of
|
||||||
|
that exists yet, and nothing in this PR creates a path toward an authenticated account acquiring it
|
||||||
|
implicitly.
|
||||||
|
|
||||||
|
**Signup order is never an authorization input.** No code path in this PR inspects how many users
|
||||||
|
already exist, branches on being the first account, or treats an empty database as special. The
|
||||||
|
first registered account and every account after it go through the exact same code path and
|
||||||
|
receive the exact same principal shape — proven directly by the test suite (see Testing). Platform
|
||||||
|
owner authority is not part of this schema at all: there is no `isAdmin`, `isOwner`, `isStaff`,
|
||||||
|
`isVolunteer`, `isMember`, role column, or admin plugin anywhere in this slice, because Core has no
|
||||||
|
authorization schema yet to grant such authority meaningfully. Platform-owner bootstrap is
|
||||||
|
deferred to Phase 4C, which must introduce an explicit, auditable, operator-invoked command that
|
||||||
|
grants an *existing* identity platform-owner authority — never an implicit consequence of being
|
||||||
|
first, and never a side effect of a public HTTP endpoint.
|
||||||
|
|
||||||
|
### Future authorization is deferred, and its shape is already distinguished
|
||||||
|
|
||||||
|
This PR does not implement authorization. It is written expecting Phase 4C (or later) to
|
||||||
|
distinguish, at minimum: public (unauthenticated) access; authenticated, self-scoped baseline
|
||||||
|
access (this PR); relationship-derived access (for example, a person added to a volunteer roster
|
||||||
|
automatically gaining narrowly scoped, reversible read access to that roster, which disappears the
|
||||||
|
moment the qualifying relationship ends — no unrelated or permanent elevation); explicit grants or
|
||||||
|
roles; and platform-owner authority. None of the last three exist yet. Relationship-derived access
|
||||||
|
in particular belongs to a future roster slice, not this one — it is a fundamentally different
|
||||||
|
mechanism (derived from a live domain relationship, not stored as a standing grant) and must not be
|
||||||
|
approximated here with a role field.
|
||||||
|
|
||||||
|
### Email/password is the only enabled provider, and why
|
||||||
|
|
||||||
|
`emailAndPassword: { enabled: true, disableSignUp: false, autoSignIn: false }` is the entire
|
||||||
|
authentication surface enabled in this slice. No social, OAuth/OIDC, passkey, magic-link, username,
|
||||||
|
OTP, or anonymous provider is enabled; no admin, organization, or two-factor plugin is enabled; no
|
||||||
|
custom user field, role field, or owner field is added to the schema. `autoSignIn: false` is
|
||||||
|
deliberate, not merely permitted: the pinned `[email protected]` release documents that setting
|
||||||
|
`autoSignIn: false` (like `requireEmailVerification: true`) activates its built-in **email
|
||||||
|
enumeration protection** — a duplicate sign-up returns the same `200` shape and a synthetic user
|
||||||
|
(`token: null`) instead of revealing that the email is already registered, and no second identity
|
||||||
|
row is created. This was verified directly against the pinned release, both by reading
|
||||||
|
`packages/better-auth/src/api/routes/sign-up.ts` and by exercising it against a live disposable
|
||||||
|
PostgreSQL database: a duplicate `/sign-up/email` call returns `200` with a fresh random `id` and
|
||||||
|
no second `ekklesia_core_user` row, while a first-time `/sign-up/email` call returns `200` with
|
||||||
|
`token: null` and **no session cookie is set**. A caller must call `/sign-in/email` explicitly
|
||||||
|
afterward to obtain a session. This is intentionally the *only* email-enumeration mitigation in
|
||||||
|
this slice; it is a Better Auth built-in, not new code.
|
||||||
|
|
||||||
|
Email verification and password recovery remain deferred **because no email delivery service
|
||||||
|
exists in this repository yet** — `sendResetPassword` and any verification-email sender are left
|
||||||
|
unconfigured. Consequently, **public registration is not ready for a real public launch** after
|
||||||
|
this PR: an account with a forgotten password has no recovery path, and an unverified email address
|
||||||
|
is trusted as entered. Both are explicitly out of scope here and are called out as follow-up work,
|
||||||
|
not silently accepted risk.
|
||||||
|
|
||||||
|
### Password policy
|
||||||
|
|
||||||
|
Better Auth's default password-length policy (`minPasswordLength: 8`, `maxPasswordLength: 128`) is
|
||||||
|
kept unmodified; this ADR does not deliberately justify a different bound. Passwords are hashed
|
||||||
|
with Better Auth's own `scrypt`-based hashing (its documented default) — Core never substitutes
|
||||||
|
custom cryptography.
|
||||||
|
|
||||||
|
### The Better Auth transport boundary hides Better Auth completely
|
||||||
|
|
||||||
|
`core/src/identity/runtime.ts` replaces Phase 4A's construction-proof-only factory (which returned
|
||||||
|
a discarded `{constructed: true}` marker) with `createIdentityRuntime`, now that a real HTTP
|
||||||
|
consumer exists. It returns a frozen `EkklesiaIdentityRuntime` exposing exactly two Fetch-standard
|
||||||
|
capabilities and nothing else:
|
||||||
|
|
||||||
|
- `handleAuthRequest(request: Request): Promise<Response>` — delegates directly to
|
||||||
|
`auth.handler(request)`. Better Auth's own HTTP responses (including its 401s, 403s, and 422s)
|
||||||
|
pass through completely unmodified; this is not rewritten into a second authentication protocol.
|
||||||
|
Only a genuinely unexpected throw (not a normal Better Auth error response, which Better Auth
|
||||||
|
itself already returns as a `Response`) is converted into a sanitized `IdentityRuntimeError`.
|
||||||
|
- `resolvePrincipal(headers: Headers): Promise<EkklesiaPrincipal>` — resolves the caller's
|
||||||
|
`EkklesiaPrincipal`, described below.
|
||||||
|
|
||||||
|
Neither method, nor anything else reachable from `@ekklesia/core/identity`, exposes `auth.api`,
|
||||||
|
Better Auth's inferred user/session types, Better Auth's database models, the Drizzle adapter, the
|
||||||
|
raw database, a session token, cookie contents, a password hash, or Better Auth configuration.
|
||||||
|
Core's public barrel (`core/src/index.ts` → `core/src/identity/index.ts`) exports only
|
||||||
|
`createIdentityConfig`/`IdentityConfig` (ADR 0010), `createIdentityDrizzleDatabase` (ADR 0010, for
|
||||||
|
composing the database in the executable entry point), and `createIdentityRuntime`/
|
||||||
|
`EkklesiaIdentityRuntime`/`IdentityRuntimeError`. The Drizzle table objects
|
||||||
|
(`core/src/identity/schema.ts`) remain internal to `core/src/identity/`, reachable only by Core's
|
||||||
|
own code and tests — nothing outside Core needs them, since `createIdentityDrizzleDatabase` already
|
||||||
|
bakes the schema in.
|
||||||
|
|
||||||
|
**A resolved infrastructure failure must never look like "no session."** This is a genuine tension
|
||||||
|
with the pinned Better Auth release: `auth.api.getSession({headers})` is documented (and
|
||||||
|
source-verified) to catch every internal failure — including a downed database — and resolve to
|
||||||
|
`null`, exactly like a missing or expired session; it never throws. Left unmitigated, that would
|
||||||
|
mean a database outage during principal resolution would silently look identical to "nobody is
|
||||||
|
signed in," which is precisely the ambiguity this ADR is required to avoid. `resolvePrincipal`
|
||||||
|
therefore does not trust a `null` result unconditionally: when the incoming request carries **no**
|
||||||
|
`Cookie` header at all, Better Auth's own source never queries the database for that case (verified
|
||||||
|
in `packages/better-auth/src/api/routes/session.ts`), so `null` is unambiguous and is returned as
|
||||||
|
the anonymous principal immediately, at zero extra cost — this is the common case (most requests
|
||||||
|
carry no session cookie). Only when a `Cookie` header **is** present and `getSession` still returns
|
||||||
|
`null` — a genuinely ambiguous case, since it could mean an expired/malformed session *or* a
|
||||||
|
database that went unreachable partway through resolution — does `resolvePrincipal` run one small,
|
||||||
|
bounded (3-second) reachability probe (`select 1`) against the same injected database connection
|
||||||
|
before trusting the `null`. If the probe also fails, `resolvePrincipal` throws
|
||||||
|
`IdentityRuntimeError` with code `PRINCIPAL_RESOLUTION_FAILED` instead of returning the anonymous
|
||||||
|
principal, and the `/api/v1/principal` route converts that into a sanitized `503`, never a `200`
|
||||||
|
anonymous response. This is a deliberate, documented, imperfect mitigation — a small race window
|
||||||
|
remains between the probe and Better Auth's own lookup — not a claim of perfect detection; a future
|
||||||
|
slice could tighten it (for example, by tracking the underlying connection pool's own error state)
|
||||||
|
without changing this boundary's public shape.
|
||||||
|
|
||||||
|
### Ekklesia user ID and principal (extends ADR 0010)
|
||||||
|
|
||||||
|
`EkklesiaUserId` was, in Phase 4A, a plain Zod-validated `string` with no nominal typing —
|
||||||
|
confirmed by inspection before this PR began. It is now branded via Zod's `.brand<"EkklesiaUserId">()`:
|
||||||
|
a bare `string` is no longer assignable to `EkklesiaUserId` without passing through
|
||||||
|
`parseEkklesiaUserId`/`safeParseEkklesiaUserId`. The brand is type-system-only — it adds no runtime
|
||||||
|
wrapper, tag, or field, so a value still serializes as a plain opaque string (`JSON.stringify`,
|
||||||
|
string concatenation, and equality all behave exactly as before). Validation still asserts only the
|
||||||
|
constraints Ekklesia itself owns (non-empty after trimming, a generous 128-character upper bound)
|
||||||
|
and still does **not** assume any particular format. In particular it does not assume UUIDs: Phase
|
||||||
|
4A's schema stores `id: text("id").primaryKey()` with Better Auth's default *application-generated*
|
||||||
|
id mode, an opaque base62-style string, not a database-generated UUID — confirmed by inspecting
|
||||||
|
`core/src/identity/schema.ts` and the pinned Better Auth ID-generation documentation. Asserting a
|
||||||
|
UUID shape here would have been asserting something Ekklesia does not actually guarantee.
|
||||||
|
|
||||||
|
`EkklesiaPrincipal` (ADR 0010) is unchanged in shape: `{kind: "anonymous"}` or
|
||||||
|
`{kind: "authenticated", userId}`. This PR does not add email, name, image, email-verification
|
||||||
|
status, session ID, session expiry, session token, account/provider details, roles, permissions,
|
||||||
|
membership status, or volunteer relationships to it. The general principal stays exactly as narrow
|
||||||
|
as ADR 0010 defined it.
|
||||||
|
|
||||||
|
### HTTP surface
|
||||||
|
|
||||||
|
Better Auth is mounted at exactly `/api/auth/*` (`app.on(["POST", "GET"], "/api/auth/*", ...)`),
|
||||||
|
matching Better Auth's own documented Hono integration pattern. All Ekklesia-owned APIs live under
|
||||||
|
a versioned `/api/v1/*` namespace; this slice adds exactly one: `GET /api/v1/principal`, returning
|
||||||
|
the existing `EkklesiaPrincipal` contract as JSON (`{"kind":"anonymous"}` or
|
||||||
|
`{"kind":"authenticated","userId":"..."}`) with `Cache-Control: no-store` and a
|
||||||
|
`Vary: Origin, Cookie` response header. No generalized authorization middleware is added; there is
|
||||||
|
nothing yet for it to authorize.
|
||||||
|
|
||||||
|
### `apps/api` is a composition root and transport host, not a Surface
|
||||||
|
|
||||||
|
`apps/api` (workspace glob `apps/*` already existed per ADR 0002/repository-boundaries.md) owns
|
||||||
|
HTTP composition and process lifecycle. It depends on `@ekklesia/core`, `@ekklesia/contracts`,
|
||||||
|
`hono`, and `@hono/node-server` — Hono lives in the API application, never in Core, keeping Core
|
||||||
|
importable by any future transport without forcing an HTTP framework dependency on it.
|
||||||
|
`scripts/check-boundaries.ts`'s existing `apps-are-composition-roots` rule already forbids an app
|
||||||
|
from importing another app, a builder, or a module; it does not restrict `app → core`, so no
|
||||||
|
boundary-checker change was needed to let `apps/api` depend on `@ekklesia/core` directly. `app.ts`
|
||||||
|
exports a pure `createApiApp({runtime, trustedOrigins})` factory (no I/O, testable via `app.fetch`
|
||||||
|
directly with an injected fake runtime); `server.ts` separately exports a testable `startApi(config)`
|
||||||
|
composition function (constructs the database, the identity runtime, the app, and starts the real
|
||||||
|
HTTP listener, returning `{app, server, shutdown}`) and a process-level `main()` that only reads
|
||||||
|
environment variables, calls `startApi`, and wires `SIGINT`/`SIGTERM` to the returned `shutdown()`
|
||||||
|
— gated behind the same `isDirectExecution()` guard already used by `core/src/cli/doctor.ts`, so
|
||||||
|
importing `server.ts` (as tests do, to reach `startApi`) never starts a server or reads the
|
||||||
|
environment.
|
||||||
|
|
||||||
|
### The server never applies migrations
|
||||||
|
|
||||||
|
`apps/api` requires an already-migrated PostgreSQL database. It has no migration-discovery,
|
||||||
|
planning, or execution code at all — it only ever opens a Drizzle connection via Core's existing
|
||||||
|
`createIdentityDrizzleDatabase` (ADR 0010) and queries through it. There is no code path by which
|
||||||
|
starting the API creates the migration history table, creates the identity tables, or executes any
|
||||||
|
DDL. Operators must run `pnpm migrations:apply` first, exactly as ADR 0009 already requires for
|
||||||
|
every other Core or module migration.
|
||||||
|
|
||||||
|
### Origin, CORS, cookies, and CSRF
|
||||||
|
|
||||||
|
The API reuses Phase 4A's typed `IdentityConfig.trustedOrigins` as the single source of truth for
|
||||||
|
both Better Auth's own trusted-origin/CSRF checks and the API's CORS policy — there is no second,
|
||||||
|
competing origin configuration. `createApiApp`'s CORS middleware (`hono/cors`) uses a **function**
|
||||||
|
`origin`, not a static string or `"*"`: it reflects a request's `Origin` header back only when that
|
||||||
|
exact origin is present in the configured allowlist, and returns nothing (no CORS header at all)
|
||||||
|
otherwise. This is the safe, standard way to combine per-request dynamic origins with
|
||||||
|
`credentials: true` — the Fetch spec forbids `Access-Control-Allow-Origin: *` together with
|
||||||
|
`Access-Control-Allow-Credentials: true`, and Hono's own middleware does not enforce that
|
||||||
|
constraint for you, so an exact allowlist match (never a wildcard) is required here regardless.
|
||||||
|
CORS is applied to both `/api/auth/*` and `/api/v1/*`; only `GET`, `POST`, and `OPTIONS` and only
|
||||||
|
the `Content-Type` header are permitted, matching what these two routes actually need. A request
|
||||||
|
that legitimately carries no `Origin` header (native/server-to-server clients) is never blocked by
|
||||||
|
this CORS layer — CORS is a browser-enforced, response-header-only mechanism; omitting the header
|
||||||
|
for an unrecognized origin does not prevent the server from processing the request, and Better
|
||||||
|
Auth's own origin/CSRF checks (below) independently protect state-changing calls regardless of
|
||||||
|
CORS.
|
||||||
|
|
||||||
|
Better Auth's own CSRF/origin protections are untouched: `advanced.disableCSRFCheck` and
|
||||||
|
`advanced.disableOriginCheck` are never set. Verified directly and repeatedly against the pinned
|
||||||
|
release, through the real, compiled `apps/api` HTTP server (not just a direct `auth.handler()`
|
||||||
|
call) driven by a plain Node script: any non-`GET`/`HEAD`/`OPTIONS` request that carries a
|
||||||
|
`Cookie` header must also carry a trusted `Origin` (or `Referer`) header, or Better Auth itself
|
||||||
|
rejects it with `403 MISSING_OR_NULL_ORIGIN` (unrecognized origin: `403 INVALID_ORIGIN`) — a
|
||||||
|
`sign-out` call with a valid session cookie but an untrusted `Origin` is reliably rejected by
|
||||||
|
Better Auth itself, before this ADR's CORS layer is even involved, and the presented session
|
||||||
|
remains valid afterward since the rejected call never reaches sign-out logic. This is Ekklesia's
|
||||||
|
baseline, always-on CSRF guarantee for every cookie-bearing state-changing call, and it is real,
|
||||||
|
reproduced, production behavior of the pinned release running this exact code.
|
||||||
|
|
||||||
|
That guarantee is **not**, however, part of the automated `pnpm test:migrations:integration` suite.
|
||||||
|
While building this ADR, the identical scenario — same compiled server, same request sequence —
|
||||||
|
could not be made to reproduce reliably when driven through Vitest specifically: the rejection
|
||||||
|
silently does not fire, even in a single isolated test with its own fresh database and API
|
||||||
|
instance, and even with `NODE_ENV` forced to `"production"` inside the test process. The same is
|
||||||
|
true of Better Auth's documented stricter "first-login CSRF" check for email sign-in/sign-up
|
||||||
|
(validating `Origin`/`Referer` even before any cookie exists), which was also observed to stop
|
||||||
|
firing after several earlier sign-in/sign-up calls in the same process. Both were reproduced
|
||||||
|
correctly and repeatedly outside Vitest and were not pinned down to a documented, reproducible
|
||||||
|
cause inside the time available for this slice, so `apps/api/tests/api-http.integration.ts`
|
||||||
|
deliberately does not assert either behavior rather than commit a flaky or misleading automated
|
||||||
|
test — it documents the manual reproduction inline instead. What the automated suite does assert
|
||||||
|
is the layer this repository's own code owns end-to-end: CORS never returns
|
||||||
|
`Access-Control-Allow-Origin` for an untrusted origin, which is the actual browser-enforced
|
||||||
|
boundary. A future slice touching authentication endpoints should re-verify Better Auth's
|
||||||
|
server-side origin rejection directly (as this ADR did) before relying on it changing.
|
||||||
|
|
||||||
|
Cookies keep Better Auth's own defaults: `httpOnly: true` always, `sameSite: "lax"` always, and
|
||||||
|
`secure` driven by the configured `baseUrl`'s protocol (Better Auth treats an `https://` `baseURL`
|
||||||
|
as secure regardless of `NODE_ENV`). `advanced.crossSubDomainCookies` is not enabled and no cookie
|
||||||
|
`Domain` attribute is set, so every cookie remains host-only, matching ADR 0010's direction.
|
||||||
|
Trusted proxy headers (`advanced.ipAddress.trustedProxies`, `ipAddressHeaders`, or any
|
||||||
|
`X-Forwarded-*` trust) are not configured in this slice; a deployment behind a reverse proxy is
|
||||||
|
future work, and accepting spoofable forwarded headers without a proxy policy in place would be
|
||||||
|
worse than not trusting them at all.
|
||||||
|
|
||||||
|
### Rate limiting
|
||||||
|
|
||||||
|
Better Auth's built-in rate limiting is always enabled (`rateLimit.enabled: true`), including in
|
||||||
|
test/local composition — it is never disabled to make testing convenient. Its endpoint-specific
|
||||||
|
built-in rules (by default, a strict 3 requests per 10 seconds on every `/sign-in*` and `/sign-up*`
|
||||||
|
path) are preserved unmodified in production composition. Test composition alone may inject a
|
||||||
|
narrow, explicit `rateLimitOverride` (verified empirically to work via Better Auth's own
|
||||||
|
`rateLimit.customRules` targeting `/sign-in/email` and `/sign-up/email` specifically) so an
|
||||||
|
integration suite that legitimately issues many sign-in/sign-up requests in a short window is not
|
||||||
|
itself rate-limited; production composition (`apps/api/src/server.ts`'s `main()`) never sets this
|
||||||
|
override. Storage remains the default in-memory backend (`rateLimit.storage` unset). This has two
|
||||||
|
honest, documented limitations carried forward from ADR 0010's rate-limiting-adjacent guidance and
|
||||||
|
confirmed against the pinned release: it is **per-process**, so it provides 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 per-path bucket rather than a genuine per-client-IP bucket
|
||||||
|
when it cannot resolve a real client address. Neither limitation is solved by trusting spoofable
|
||||||
|
`X-Forwarded-*` headers, which this PR deliberately does not do. Public signup is therefore
|
||||||
|
rate-limited, but this is not yet the final, distributed, production-grade abuse-control design.
|
||||||
|
|
||||||
|
## Alternatives
|
||||||
|
|
||||||
|
- Letting the first registered account become an administrator automatically was rejected outright
|
||||||
|
per explicit product direction: signup order must never be an authorization input, and Core has
|
||||||
|
no owner capability to grant safely in this slice regardless.
|
||||||
|
- Adding an `isAdmin`/`isOwner`/role column now, "to be ready for" Phase 4C, was rejected: an unused
|
||||||
|
authorization field invites exactly the kind of accidental elevation this ADR forbids, and Phase
|
||||||
|
4C's bootstrap command is explicitly required to be an auditable operator action, not a schema
|
||||||
|
default.
|
||||||
|
- Leaving `autoSignIn` at its default (`true`) was rejected: it would immediately grant a session
|
||||||
|
(and disable Better Auth's built-in enumeration protection) on every signup, including duplicate
|
||||||
|
attempts against an existing email, which is a weaker default than the one Better Auth itself
|
||||||
|
documents for exactly this public-registration scenario.
|
||||||
|
- Trusting `getSession`'s `null` result unconditionally as "anonymous" was rejected: the pinned
|
||||||
|
release's own source shows it swallows infrastructure failures, and the task's authorization
|
||||||
|
ambiguity concern is real — a later authorization system consuming a falsely-anonymous principal
|
||||||
|
during a database outage would be a silent security-relevant bug, not a graceful degradation.
|
||||||
|
- Enabling `advanced.ipAddress.trustedProxies`/`ipAddressHeaders` now, to give rate limiting a real
|
||||||
|
per-client bucket, was rejected: there is no deployment proxy policy yet, and accepting forwarded
|
||||||
|
IP headers without one would let any direct caller spoof its rate-limit bucket.
|
||||||
|
- A wildcard or reflect-any-origin CORS policy was rejected as incompatible with
|
||||||
|
`credentials: true` per the Fetch specification, and as a direct violation of "allow only exact
|
||||||
|
configured trusted origins."
|
||||||
|
- Reimplementing Better Auth's session validation directly against the identity tables (to sidestep
|
||||||
|
the `getSession`-swallows-errors problem entirely) was rejected: it would create a second,
|
||||||
|
divergent session-validation implementation alongside Better Auth's own, exactly the kind of
|
||||||
|
duplicated authentication logic Core is meant to avoid.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
A person can now publicly register, sign in, be resolved to an `EkklesiaPrincipal` by any code
|
||||||
|
holding the narrow Core identity runtime, and sign out — all while Better Auth stays fully
|
||||||
|
contained behind Core's contracts and Core stays fully unaware of Hono. Every account, first or
|
||||||
|
five-hundredth, is provably identical in privilege. What remains explicitly unavailable after this
|
||||||
|
PR: any role, permission, grant, or platform-owner capability; any relationship-derived access;
|
||||||
|
Dashboard, Website, or Mobile UI; email delivery, email verification, or password recovery; any
|
||||||
|
provider beyond email/password; and any deployment-proxy or TLS decision. The next expected slice
|
||||||
|
is Phase 4C: Core authorization (public / authenticated-baseline / relationship-derived / grants /
|
||||||
|
platform-owner), an explicit, auditable platform-owner bootstrap command, and the beginning of
|
||||||
|
reversible relationship-derived access — not a Dashboard Surface, which still depends on an
|
||||||
|
authorization model that does not exist yet.
|
||||||
@@ -19,3 +19,4 @@ ADRs capture durable choices that affect more than one package or phase.
|
|||||||
- [0008: Module-owned migration contracts and offline validation](0008-module-owned-migration-contracts.md) — Accepted
|
- [0008: Module-owned migration contracts and offline validation](0008-module-owned-migration-contracts.md) — Accepted
|
||||||
- [0009: Transactional module migration execution](0009-transactional-module-migrations.md) — Accepted
|
- [0009: Transactional module migration execution](0009-transactional-module-migrations.md) — Accepted
|
||||||
- [0010: Core identity architecture and Better Auth adoption](0010-core-identity-and-better-auth-adoption.md) — Accepted
|
- [0010: Core identity architecture and Better Auth adoption](0010-core-identity-and-better-auth-adoption.md) — Accepted
|
||||||
|
- [0011: Identity HTTP transport and public enrollment policy](0011-identity-http-transport-and-public-enrollment.md) — Accepted
|
||||||
|
|||||||
@@ -78,18 +78,29 @@ definition entrypoints, which the module contract requires to be side-effect-fre
|
|||||||
imports or starts module server entrypoints. It does not apply migrations or modify PostgreSQL, and
|
imports or starts module server entrypoints. It does not apply migrations or modify PostgreSQL, and
|
||||||
it honestly skips object storage because no production Core storage service exists yet.
|
it honestly skips object storage because no production Core storage service exists yet.
|
||||||
|
|
||||||
Phase 4A adds the Core identity foundation. Core owns a Better Auth-backed identity schema
|
Phase 4A added the Core identity foundation. Core owns a Better Auth-backed identity schema
|
||||||
(`ekklesia_core_user`, `ekklesia_core_session`, `ekklesia_core_account`,
|
(`ekklesia_core_user`, `ekklesia_core_session`, `ekklesia_core_account`,
|
||||||
`ekklesia_core_verification`) reachable only through Ekklesia-owned contracts and an explicit
|
`ekklesia_core_verification`) reachable only through Ekklesia-owned contracts and an explicit
|
||||||
Core-owned Better Auth construction boundary; modules never import Better Auth or receive a raw
|
Core-owned Better Auth construction boundary; modules never import Better Auth or receive a raw
|
||||||
database client. That schema ships as a first-class Core-owned migration source that reuses the
|
database client. That schema ships as a first-class Core-owned migration source that reuses the
|
||||||
exact migration format, checksum rule, planner, and transactional runner Phase 3 already
|
exact migration format, checksum rule, planner, and transactional runner Phase 3 already
|
||||||
established, ordered before module migrations in the same aggregate. See
|
established, ordered before module migrations in the same aggregate. See
|
||||||
[ADR 0010](decisions/0010-core-identity-and-better-auth-adoption.md) and
|
[ADR 0010](decisions/0010-core-identity-and-better-auth-adoption.md).
|
||||||
[the identity development guide](../development/identity.md). Phase 4A does not mount an HTTP
|
|
||||||
handler or expose a sign-in endpoint; no user can authenticate yet.
|
|
||||||
|
|
||||||
The next architectural phase is identity HTTP transport and an explicit enrollment policy. Other
|
Phase 4B adds identity HTTP transport: a minimal Hono-based `apps/api` composition root mounts
|
||||||
target architecture not yet implemented includes production approval-provider composition,
|
Better Auth at `/api/auth/*` and exposes one Ekklesia-owned route, `GET /api/v1/principal`, behind
|
||||||
permission enforcement, physical storage-bucket provisioning, Surface SDK, richer public resource
|
Core's own Fetch-compatible identity runtime (`@ekklesia/core/identity`) — Core still does not
|
||||||
and action contracts, API server, dashboard, Builder, and reference feature modules.
|
depend on Hono. Public email/password registration is intentional; every account receives
|
||||||
|
identical lowest-privilege baseline status, and signup order never grants elevation. 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
|
||||||
|
contracts. It is now possible to publicly register, sign in, resolve a principal, and sign out; it
|
||||||
|
is not yet possible to verify an email, recover a password, reach a Dashboard, or hold any role or
|
||||||
|
permission.
|
||||||
|
|
||||||
|
The next architectural phase is Phase 4C: Core authorization (public / authenticated-baseline /
|
||||||
|
relationship-derived / grants / platform-owner) and an explicit, auditable platform-owner bootstrap
|
||||||
|
command. Other target architecture not yet implemented includes production approval-provider
|
||||||
|
composition, physical storage-bucket provisioning, Surface SDK, richer public resource and action
|
||||||
|
contracts, dashboard, Builder, and reference feature modules.
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
# API
|
||||||
|
|
||||||
|
Phase 4B adds `apps/api`: a minimal Hono-based HTTP transport that mounts Better Auth and exposes
|
||||||
|
one Ekklesia-owned route to resolve the current principal. It does not add a Dashboard, Website, or
|
||||||
|
Mobile Surface, and it does not add roles, permissions, or a platform-owner concept — see
|
||||||
|
[ADR 0011](../architecture/decisions/0011-identity-http-transport-and-public-enrollment.md) for the
|
||||||
|
full architectural decision and [Identity](identity.md) for the identity foundation this builds on.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Starting the API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm migrations:apply # the database must already be migrated — the API never does this itself
|
||||||
|
pnpm api:start
|
||||||
|
```
|
||||||
|
|
||||||
|
`pnpm api:start` follows the repository's build-first convention (`turbo run build`) so it works
|
||||||
|
from a clean checkout, then runs the compiled `apps/api/dist/server.js`. It reads configuration
|
||||||
|
from the environment (optionally via an uncommitted `.env`, loaded with
|
||||||
|
`--env-file-if-exists=.env`) and fails with a concise, sanitized error and a nonzero exit code if
|
||||||
|
required configuration is missing or invalid — it never applies migrations, creates the identity
|
||||||
|
tables, or repairs schema, and it fails safely (without mutating the database) if the required
|
||||||
|
schema is absent.
|
||||||
|
|
||||||
|
### Required configuration
|
||||||
|
|
||||||
|
No real values are shown here — configure per environment and never commit them:
|
||||||
|
|
||||||
|
| Variable | Requirement |
|
||||||
|
| --- | --- |
|
||||||
|
| `DATABASE_URL` | An absolute `postgres://` or `postgresql://` URL to an already-migrated database. |
|
||||||
|
| `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. |
|
||||||
|
| `NODE_ENV` | `development` explicitly opts into relaxed (loopback/`http`) origin validation for local development. Anything else, including unset, validates as production. |
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
| Route | Owner | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `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`. |
|
||||||
|
|
||||||
|
Better Auth's handler is unreachable outside `/api/auth/*`; every other unmatched path returns a
|
||||||
|
deterministic `404 {"code":"NOT_FOUND"}`.
|
||||||
|
|
||||||
|
### `GET /api/v1/principal` response shapes
|
||||||
|
|
||||||
|
Anonymous (no session, or an expired/revoked/malformed session):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "kind": "anonymous" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Authenticated:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "kind": "authenticated", "userId": "2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Infrastructure failure (the identity database could not be reached while resolving the principal —
|
||||||
|
never silently reported as anonymous):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "code": "PRINCIPAL_RESOLUTION_FAILED" }
|
||||||
|
```
|
||||||
|
returned with HTTP `503`.
|
||||||
|
|
||||||
|
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
|
||||||
|
Ekklesia-owned contract every other Core/Surface consumer should use; it never contains email,
|
||||||
|
name, image, session id or expiry, session token, account/provider details, or any role,
|
||||||
|
permission, membership, or volunteer data.
|
||||||
|
|
||||||
|
## Public registration policy
|
||||||
|
|
||||||
|
Anyone may create an account (`emailAndPassword.enabled: true`, `disableSignUp: false`). 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
|
||||||
|
account, first or five-hundredth, receives identical baseline authenticated status: no Dashboard
|
||||||
|
access, no module-management access, no staff/volunteer-management access, and no
|
||||||
|
media/event/form/roster/people-management permission — Core has no authorization schema in this
|
||||||
|
slice to grant any of that, and no code path in this repository inspects how many accounts already
|
||||||
|
exist or treats an empty database as special. Baseline authenticated status is what will eventually
|
||||||
|
let a signed-in person use ownership-scoped personal features (watch history, continue watching,
|
||||||
|
saved content, preferences) — none of those features are implemented in this PR.
|
||||||
|
|
||||||
|
Explicit **platform-owner bootstrap is deferred to Phase 4C**, which must introduce an explicit,
|
||||||
|
auditable operator command that grants an *existing* identity platform-owner authority — signup
|
||||||
|
order is never part of that decision. Future **relationship-derived access** (for example, a
|
||||||
|
person added to a volunteer roster automatically gaining narrow, reversible read access to that
|
||||||
|
roster) belongs to a later roster slice, not this one.
|
||||||
|
|
||||||
|
`emailAndPassword.autoSignIn` is set to `false`. Signing up creates the account but does **not**
|
||||||
|
automatically create a session — no `Set-Cookie` is sent, and the sign-up response's `token` field
|
||||||
|
is `null`. The caller must call `/api/auth/sign-in/email` explicitly afterward. This is deliberate:
|
||||||
|
per the pinned Better Auth release, `autoSignIn: false` activates its built-in email-enumeration
|
||||||
|
protection, so a duplicate sign-up returns the same `200` shape (with a fresh, unrelated random id)
|
||||||
|
whether or not the email was already registered, rather than revealing which. No custom
|
||||||
|
cryptography replaces Better Auth's own password hashing, and its default password-length policy
|
||||||
|
(8–128 characters) is kept unmodified.
|
||||||
|
|
||||||
|
## Origin, CORS, and cookies
|
||||||
|
|
||||||
|
The API reuses the same `EKKLESIA_IDENTITY_TRUSTED_ORIGINS` list for both CORS and Better Auth's
|
||||||
|
own origin/CSRF checks — there is no second, competing configuration. CORS reflects a request's
|
||||||
|
`Origin` header back only when it exactly matches a configured trusted origin (never a wildcard,
|
||||||
|
since `Access-Control-Allow-Origin: *` cannot be combined with `Access-Control-Allow-Credentials:
|
||||||
|
true`); a disallowed origin never receives the header at all, though the request itself may still
|
||||||
|
be processed — CORS is a browser-enforced, response-header mechanism, not a server-side gate. A
|
||||||
|
request with no `Origin` header at all (native or server-to-server clients) is unaffected by CORS.
|
||||||
|
|
||||||
|
Better Auth's own CSRF/origin protections are never disabled
|
||||||
|
(`advanced.disableCSRFCheck`/`advanced.disableOriginCheck` are never set). Verified directly
|
||||||
|
against the running compiled server: any non-`GET`/`HEAD`/`OPTIONS` request carrying a session
|
||||||
|
cookie must also carry a trusted `Origin` (or `Referer`), or Better Auth itself rejects it with
|
||||||
|
`403`. See ADR 0011 for the exact verification method and an important caveat about reproducing
|
||||||
|
part of this guarantee inside the automated test suite.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Graceful shutdown
|
||||||
|
|
||||||
|
`startApi()` (the composition function `apps/api/src/server.ts` exports, used by both the
|
||||||
|
executable entry point and tests) returns `{app, server, shutdown}`. `shutdown()` is idempotent: it
|
||||||
|
stops the HTTP listener from accepting new connections and lets in-flight requests finish, then
|
||||||
|
closes the PostgreSQL pool. The executable entry point wires `SIGINT`/`SIGTERM` to call it and
|
||||||
|
exits afterward; a startup failure after the database pool was already created (identity
|
||||||
|
construction failure, or the server failing to bind its listen address) always closes that pool
|
||||||
|
before the failure propagates.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST /api/auth/sign-up/email
|
||||||
|
{"email":"[email protected]","name":"Alice","password":"<redacted>"}
|
||||||
|
→ 200
|
||||||
|
{"token":null,"user":{"id":"2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1","email":"[email protected]", ...}}
|
||||||
|
|
||||||
|
POST /api/auth/sign-in/email
|
||||||
|
{"email":"[email protected]","password":"<redacted>"}
|
||||||
|
→ 200
|
||||||
|
Set-Cookie: better-auth.session_token=<redacted>; Max-Age=604800; Path=/; HttpOnly; SameSite=Lax; Secure
|
||||||
|
{"redirect":false,"token":"<redacted>","user":{"id":"2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1", ...}}
|
||||||
|
|
||||||
|
GET /api/v1/principal
|
||||||
|
Cookie: better-auth.session_token=<redacted>
|
||||||
|
→ 200
|
||||||
|
Cache-Control: no-store
|
||||||
|
{"kind":"authenticated","userId":"2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1"}
|
||||||
|
|
||||||
|
POST /api/auth/sign-out
|
||||||
|
Cookie: better-auth.session_token=<redacted>
|
||||||
|
Origin: https://dashboard.example.test
|
||||||
|
→ 200
|
||||||
|
Set-Cookie: better-auth.session_token=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax
|
||||||
|
{"success":true}
|
||||||
|
|
||||||
|
GET /api/v1/principal
|
||||||
|
Cookie: better-auth.session_token=<redacted, now revoked>
|
||||||
|
→ 200
|
||||||
|
{"kind":"anonymous"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next phase
|
||||||
|
|
||||||
|
Phase 4C: Core authorization (public / authenticated-baseline / relationship-derived / grants /
|
||||||
|
platform-owner), an explicit, auditable platform-owner bootstrap command, and reversible
|
||||||
|
relationship-derived access. Not a Dashboard Surface, which still depends on an authorization model
|
||||||
|
that does not exist yet.
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
# Identity
|
# Identity
|
||||||
|
|
||||||
Phase 4A establishes Core's identity foundation: a Core-owned Better Auth database schema, a
|
Phase 4A established Core's identity foundation: a Core-owned Better Auth database schema, a
|
||||||
first-class Core-owned migration source, minimal opaque-identity contracts, and an explicit Better
|
first-class Core-owned migration source, minimal opaque-identity contracts, and an explicit Better
|
||||||
Auth construction boundary. It does not expose a sign-in endpoint, mount an HTTP handler, or make
|
Auth construction boundary. Phase 4B built the HTTP transport on top of it — see
|
||||||
any provider or enrollment decision. See
|
[API](api.md) and
|
||||||
[ADR 0010](../architecture/decisions/0010-core-identity-and-better-auth-adoption.md) for the full
|
[ADR 0011](../architecture/decisions/0011-identity-http-transport-and-public-enrollment.md) for
|
||||||
architectural decision; this page is the practical developer guide.
|
public registration, sign-in, and principal resolution over HTTP. This page covers the identity
|
||||||
|
foundation itself; see [ADR 0010](../architecture/decisions/0010-core-identity-and-better-auth-adoption.md)
|
||||||
|
for that architectural decision.
|
||||||
|
|
||||||
## Ownership
|
## Ownership
|
||||||
|
|
||||||
@@ -18,7 +20,8 @@ surface concern. Better Auth is an implementation detail contained entirely behi
|
|||||||
cannot import `core` at all under `scripts/check-boundaries.ts`'s boundary matrix, regardless of
|
cannot import `core` at all under `scripts/check-boundaries.ts`'s boundary matrix, regardless of
|
||||||
what Core exposes.
|
what Core exposes.
|
||||||
- **Surfaces will eventually authenticate through Core-exposed HTTP/API and client contracts**,
|
- **Surfaces will eventually authenticate through Core-exposed HTTP/API and client contracts**,
|
||||||
never by querying Better Auth's tables directly. No such Core API exists yet.
|
never by querying Better Auth's tables directly. `apps/api` (Phase 4B, see [API](api.md)) is that
|
||||||
|
transport for server-side/API consumers; no Dashboard, Website, or Mobile Surface exists yet.
|
||||||
- Better Auth's `user` row is an authentication identity, not the future church-person/profile
|
- Better Auth's `user` row is an authentication identity, not the future church-person/profile
|
||||||
model. Church membership, ministry roles, contact history, attendance, permissions, and other
|
model. Church membership, ministry roles, contact history, attendance, permissions, and other
|
||||||
module-specific fields must never be added to the identity tables; a future, separately designed
|
module-specific fields must never be added to the identity tables; a future, separately designed
|
||||||
@@ -117,8 +120,8 @@ required. It is part of `pnpm verify`.
|
|||||||
## Required identity configuration
|
## Required identity configuration
|
||||||
|
|
||||||
`createIdentityConfig` (`core/src/identity/config.ts`) validates three explicit values before
|
`createIdentityConfig` (`core/src/identity/config.ts`) validates three explicit values before
|
||||||
`core/src/identity/factory.ts` constructs Better Auth. This document intentionally contains no
|
`core/src/identity/runtime.ts`'s `createIdentityRuntime` constructs Better Auth. This document
|
||||||
real values — configure them per environment, never commit them:
|
intentionally contains no real values — configure them per environment, never commit them:
|
||||||
|
|
||||||
| Value | Requirement |
|
| Value | Requirement |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -133,12 +136,25 @@ raises a sanitized `IdentityConfigError` carrying only a stable code, never the
|
|||||||
Secret rotation (Better Auth's plural `secrets` list) and cross-subdomain cookies are deferred; see
|
Secret rotation (Better Auth's plural `secrets` list) and cross-subdomain cookies are deferred; see
|
||||||
ADR 0010.
|
ADR 0010.
|
||||||
|
|
||||||
|
## The identity runtime (Core's Better Auth transport boundary)
|
||||||
|
|
||||||
|
`@ekklesia/core/identity` publicly exports `createIdentityRuntime({config, db})`
|
||||||
|
(`core/src/identity/runtime.ts`), which constructs Better Auth explicitly and returns a frozen
|
||||||
|
`EkklesiaIdentityRuntime` exposing exactly two Fetch-standard capabilities:
|
||||||
|
`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
|
||||||
|
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).
|
||||||
|
|
||||||
## What is not operational yet
|
## What is not operational yet
|
||||||
|
|
||||||
No HTTP server or API route exists, Better Auth's handler is not mounted, and there is no sign-in
|
No email verification, password recovery, invitation flow, email delivery, social or OAuth
|
||||||
or sign-up endpoint, public registration, invitation flow, email delivery, password reset, email
|
provider, passkey, magic link, two-factor authentication, or roles/permissions/platform-owner model
|
||||||
verification, social or OAuth provider, passkey, magic link, two-factor authentication, or roles
|
exists. Public registration, sign-in, principal resolution, and sign-out over HTTP **are**
|
||||||
and permissions model. **No user can authenticate through this repository yet.** The next expected
|
operational as of Phase 4B — see [API](api.md) for the transport and routes, and ADR 0011 for why
|
||||||
slice is identity HTTP transport and an explicit enrollment policy (for example, how the first
|
public registration is intentional and grants no elevation. The next expected slice is Phase 4C:
|
||||||
administrator account is created) — not a Dashboard Surface, which depends on that transport slice
|
Core authorization and an explicit, auditable platform-owner bootstrap command — not a Dashboard
|
||||||
existing first.
|
Surface, which still depends on an authorization model that does not exist yet.
|
||||||
|
|||||||
@@ -130,9 +130,11 @@ Result: READY WITH WARNINGS
|
|||||||
Doctor is a local, human-readable summary. `pnpm verify`, `pnpm ci:verify`, infrastructure smoke,
|
Doctor is a local, human-readable summary. `pnpm verify`, `pnpm ci:verify`, infrastructure smoke,
|
||||||
and migration smoke remain the authoritative automated evidence because they exercise formatting,
|
and migration smoke remain the authoritative automated evidence because they exercise formatting,
|
||||||
boundaries, types, tests, builds, containers, and live mutation/rollback behavior that Doctor does
|
boundaries, types, tests, builds, containers, and live mutation/rollback behavior that Doctor does
|
||||||
not repeat. Identity HTTP transport and an explicit enrollment policy are the next architectural
|
not repeat. Core authorization and an explicit platform-owner bootstrap command are the next
|
||||||
phase; Doctor does not diagnose authorization, HTTP routing, runtime module health, or production
|
architectural phase; Doctor does not diagnose authorization, the `apps/api` HTTP transport, runtime
|
||||||
storage yet, and it never constructs an operational Better Auth instance or claims sign-in works.
|
module health, or production storage yet, and it never constructs an operational Better Auth
|
||||||
|
instance, starts the API, or claims sign-in works. See [API](api.md) for `pnpm api:start` and the
|
||||||
|
identity HTTP transport this repository now has.
|
||||||
|
|
||||||
Migration planning and application require `DATABASE_URL`. Keep it in the uncommitted `.env`
|
Migration planning and application require `DATABASE_URL`. Keep it in the uncommitted `.env`
|
||||||
created from `.env.example`:
|
created from `.env.example`:
|
||||||
|
|||||||
@@ -13,11 +13,16 @@ These commands exercise package tests, repository boundaries, type checking, off
|
|||||||
module migration discovery, identity schema drift detection, formatting, linting, and builds.
|
module migration discovery, identity schema drift detection, formatting, linting, and builds.
|
||||||
Migration-runner unit tests use a narrow database/session port to prove deterministic planning,
|
Migration-runner unit tests use a narrow database/session port to prove deterministic planning,
|
||||||
approval, transaction orchestration, cleanup, and sanitized errors. They do not claim to prove
|
approval, transaction orchestration, cleanup, and sanitized errors. They do not claim to prove
|
||||||
PostgreSQL behavior. Identity unit tests cover principal/opaque-ID contracts, configuration
|
PostgreSQL behavior. Identity unit tests cover branded opaque-ID contracts, principal shapes,
|
||||||
validation (secret, base URL, and trusted-origin rules), the Better Auth construction boundary
|
configuration validation (secret, base URL, and trusted-origin rules), the Core identity runtime
|
||||||
(explicit construction, exact schema mapping, no unexpected provider or plugin, sanitized
|
(explicit construction, exact schema mapping, only email/password and rate limiting enabled,
|
||||||
failures), and Core migration discovery (deterministic ordering, the reserved `core` identity, and
|
sanitized failures, and that a database outage during principal resolution is never silently
|
||||||
collision rejection) — all without a live database.
|
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, CORS allow/deny/preflight, deterministic 404 and sanitized
|
||||||
|
error responses), its runtime configuration, and that importing any of its modules — including the
|
||||||
|
executable server module without calling its exported `main` — never reads the environment,
|
||||||
|
connects to PostgreSQL, or starts a server.
|
||||||
|
|
||||||
`pnpm verify` is intentionally Docker-free. It never plans or applies migrations against a live
|
`pnpm verify` is intentionally Docker-free. It never plans or applies migrations against a live
|
||||||
database.
|
database.
|
||||||
@@ -52,6 +57,22 @@ staying byte-for-byte unchanged once Core is introduced, and a later module migr
|
|||||||
rolling back cleanly without damaging the already-committed Core identity migration or leaking the
|
rolling back cleanly without damaging the already-committed Core identity migration or leaking the
|
||||||
advisory lock.
|
advisory lock.
|
||||||
|
|
||||||
|
A second dedicated suite (`apps/api/tests/api-http.integration.ts`, included in the same
|
||||||
|
`pnpm test:migrations:integration` run) starts the real `apps/api` HTTP server against a freshly
|
||||||
|
migrated disposable database and exercises it with real HTTP requests: public sign-up (with no
|
||||||
|
automatic session, per `autoSignIn: false`), explicit sign-in producing a host-only
|
||||||
|
`HttpOnly`/`SameSite=Lax`/secure session cookie, `/api/v1/principal` for both the authenticated and
|
||||||
|
anonymous shapes, sign-out actually invalidating the session, a malformed or missing cookie
|
||||||
|
resolving as anonymous, duplicate sign-up creating no second identity row, two distinct accounts
|
||||||
|
producing identical principal shapes with no elevation and no owner/role/permission data at the
|
||||||
|
database level, no plaintext password ever stored, CORS never returning an allow-origin header for
|
||||||
|
an untrusted origin, Better Auth's handler being unreachable outside `/api/auth/*`, a database
|
||||||
|
outage during principal resolution producing a sanitized `503` rather than a false anonymous `200`,
|
||||||
|
starting the API against an unmigrated database creating no tables at all, and a clean listener/pool
|
||||||
|
shutdown. See [ADR 0011](../architecture/decisions/0011-identity-http-transport-and-public-enrollment.md)
|
||||||
|
for one CSRF/origin guarantee that was verified manually against the real server but could not be
|
||||||
|
reproduced reliably inside this test runner, and is documented rather than silently asserted.
|
||||||
|
|
||||||
CI runs the same command in the dedicated `migration-smoke` job. After this change is merged, add
|
CI runs the same command in the dedicated `migration-smoke` job. After this change is merged, add
|
||||||
the following pull-request status context to `main` branch protection:
|
the following pull-request status context to `main` branch protection:
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"packageManager": "[email protected]",
|
"packageManager": "[email protected]",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"api:start": "turbo run build && node --env-file-if-exists=.env apps/api/dist/server.js",
|
||||||
"boundaries": "node scripts/check-boundaries.ts",
|
"boundaries": "node scripts/check-boundaries.ts",
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"ci:verify": "pnpm verify && pnpm infrastructure:config",
|
"ci:verify": "pnpm verify && pnpm infrastructure:config",
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const ekklesiaUserIdSchema = z.string().trim().min(1).max(128);
|
/**
|
||||||
|
* Opaque at the type level via Zod's nominal `.brand()`: a plain `string` is not assignable to
|
||||||
|
* `EkklesiaUserId` without passing through `parseEkklesiaUserId`/`safeParseEkklesiaUserId`. The
|
||||||
|
* brand exists only in the type system — it adds no runtime tag, wrapper, or field, so a value
|
||||||
|
* still serializes (JSON.stringify, string concatenation, equality) as a plain opaque string.
|
||||||
|
* Ekklesia does not assume any particular format (in particular, not UUID): Phase 4A's identity
|
||||||
|
* schema stores Better Auth's default application-generated id, an opaque base62-ish string, not
|
||||||
|
* a database-generated UUID. Only the constraints Ekklesia itself owns are validated: non-empty
|
||||||
|
* after trimming, and a generous upper bound against unreasonably large values.
|
||||||
|
*/
|
||||||
|
export const ekklesiaUserIdSchema = z.string().trim().min(1).max(128).brand<"EkklesiaUserId">();
|
||||||
|
|
||||||
export type EkklesiaUserId = z.infer<typeof ekklesiaUserIdSchema>;
|
export type EkklesiaUserId = z.infer<typeof ekklesiaUserIdSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
ANONYMOUS_PRINCIPAL,
|
ANONYMOUS_PRINCIPAL,
|
||||||
createAuthenticatedPrincipal,
|
createAuthenticatedPrincipal,
|
||||||
type EkklesiaPrincipal,
|
type EkklesiaPrincipal,
|
||||||
|
type EkklesiaUserId,
|
||||||
isAnonymousPrincipal,
|
isAnonymousPrincipal,
|
||||||
isAuthenticatedPrincipal,
|
isAuthenticatedPrincipal,
|
||||||
parseEkklesiaUserId,
|
parseEkklesiaUserId,
|
||||||
@@ -14,6 +15,25 @@ describe("Ekklesia identity contracts", () => {
|
|||||||
expect(parseEkklesiaUserId("user_123")).toBe("user_123");
|
expect(parseEkklesiaUserId("user_123")).toBe("user_123");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 EkklesiaUserId type.
|
||||||
|
const notAllowed: EkklesiaUserId = "raw-string";
|
||||||
|
expect(notAllowed).toBe("raw-string"); // the brand adds no runtime wrapper or tag
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes as a plain opaque string despite the type-level brand", () => {
|
||||||
|
const userId = parseEkklesiaUserId("user_123");
|
||||||
|
|
||||||
|
expect(JSON.stringify({ userId })).toBe('{"userId":"user_123"}');
|
||||||
|
expect(`${userId}`).toBe("user_123");
|
||||||
|
expect(typeof userId).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not assume any particular id format such as UUID", () => {
|
||||||
|
expect(() => parseEkklesiaUserId("not-a-uuid-at-all")).not.toThrow();
|
||||||
|
expect(() => parseEkklesiaUserId("2aKp4jPeV1QbXwyz1OH6IcAUfl2n1fc1")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects an empty or oversized user id", () => {
|
it("rejects an empty or oversized user id", () => {
|
||||||
expect(() => parseEkklesiaUserId("")).toThrow();
|
expect(() => parseEkklesiaUserId("")).toThrow();
|
||||||
expect(() => parseEkklesiaUserId(" ")).toThrow();
|
expect(() => parseEkklesiaUserId(" ")).toThrow();
|
||||||
|
|||||||
Generated
+53
@@ -27,6 +27,43 @@ importers:
|
|||||||
specifier: 2.9.0
|
specifier: 2.9.0
|
||||||
version: 2.9.0
|
version: 2.9.0
|
||||||
|
|
||||||
|
apps/api:
|
||||||
|
dependencies:
|
||||||
|
'@ekklesia/contracts':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/contracts
|
||||||
|
'@ekklesia/core':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../core
|
||||||
|
'@hono/node-server':
|
||||||
|
specifier: 2.0.10
|
||||||
|
version: 2.0.10([email protected])
|
||||||
|
hono:
|
||||||
|
specifier: 4.12.31
|
||||||
|
version: 4.12.31
|
||||||
|
devDependencies:
|
||||||
|
'@biomejs/biome':
|
||||||
|
specifier: 2.5.3
|
||||||
|
version: 2.5.3
|
||||||
|
'@ekklesia/typescript-config':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/typescript-config
|
||||||
|
'@types/node':
|
||||||
|
specifier: 24.13.3
|
||||||
|
version: 24.13.3
|
||||||
|
'@types/pg':
|
||||||
|
specifier: 8.20.0
|
||||||
|
version: 8.20.0
|
||||||
|
pg:
|
||||||
|
specifier: 8.22.0
|
||||||
|
version: 8.22.0
|
||||||
|
typescript:
|
||||||
|
specifier: 7.0.2
|
||||||
|
version: 7.0.2
|
||||||
|
vitest:
|
||||||
|
specifier: 4.1.10
|
||||||
|
version: 4.1.10(@types/[email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected]))
|
||||||
|
|
||||||
core:
|
core:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ekklesia/contracts':
|
'@ekklesia/contracts':
|
||||||
@@ -923,6 +960,12 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@hono/[email protected]':
|
||||||
|
resolution: {integrity: sha512-ZcnNVhKTmyDJeg0UlnZjvM73JBsTAuhrH/J4fjwGOw59PwOW51r4J+p6CsKZWXdKSme4MFqU62CZMOsdDrU4CA==}
|
||||||
|
engines: {node: '>=20'}
|
||||||
|
peerDependencies:
|
||||||
|
hono: ^4
|
||||||
|
|
||||||
'@jridgewell/[email protected]':
|
'@jridgewell/[email protected]':
|
||||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||||
|
|
||||||
@@ -1587,6 +1630,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==}
|
resolution: {integrity: sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==}
|
||||||
|
engines: {node: '>=16.9.0'}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
|
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
|
||||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||||
@@ -2636,6 +2683,10 @@ snapshots:
|
|||||||
'@esbuild/[email protected]':
|
'@esbuild/[email protected]':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@hono/[email protected]([email protected])':
|
||||||
|
dependencies:
|
||||||
|
hono: 4.12.31
|
||||||
|
|
||||||
'@jridgewell/[email protected]':
|
'@jridgewell/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.5
|
'@jridgewell/sourcemap-codec': 1.5.5
|
||||||
@@ -3166,6 +3217,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
fileParallelism: false,
|
fileParallelism: false,
|
||||||
hookTimeout: 30_000,
|
hookTimeout: 30_000,
|
||||||
include: ["core/tests/postgres-migration.integration.ts"],
|
include: [
|
||||||
|
"core/tests/postgres-migration.integration.ts",
|
||||||
|
"apps/api/tests/api-http.integration.ts",
|
||||||
|
],
|
||||||
passWithNoTests: false,
|
passWithNoTests: false,
|
||||||
testTimeout: 30_000,
|
testTimeout: 30_000,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user