feat: expose core identity transport
CI / verify (pull_request) Successful in 27s
CI / infrastructure-smoke (pull_request) Successful in 19s
CI / migration-smoke (pull_request) Successful in 26s

This commit is contained in:
ekklesia
2026-07-21 19:15:14 -05:00
parent 82fe338a8d
commit 91d8f5946f
33 changed files with 2672 additions and 224 deletions
+5 -2
View File
@@ -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
+22 -6
View File
@@ -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)
+39
View File
@@ -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"
}
}
+72
View File
@@ -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;
}
+117
View File
@@ -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 });
}
+168
View File
@@ -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;
});
}
+631
View File
@@ -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);
});
});
+167
View File
@@ -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.
});
+151
View 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");
});
});
+75
View File
@@ -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();
});
});
+14
View File
@@ -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"]
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@ekklesia/typescript-config/node.json",
"compilerOptions": {
"noEmit": true,
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}
+4
View File
@@ -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": {
+6
View File
@@ -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({
-58
View File
@@ -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 });
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./config.js";
export * from "./database.js";
export * from "./runtime.js";
+187
View File
@@ -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
View File
@@ -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";
+19 -2
View File
@@ -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();
});
}); });
-123
View File
@@ -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");
});
});
+288
View File
@@ -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.
+1
View File
@@ -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
+19 -8
View File
@@ -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.
+201
View File
@@ -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.
+31 -15
View File
@@ -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.
+5 -3
View File
@@ -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`:
+26 -5
View File
@@ -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:
+1
View File
@@ -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",
+11 -1
View File
@@ -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>;
+20
View File
@@ -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();
+53
View File
@@ -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 -1
View File
@@ -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,
}, },