feat: establish core identity foundation
CI / verify (pull_request) Successful in 25s
CI / infrastructure-smoke (pull_request) Successful in 12s
CI / migration-smoke (pull_request) Successful in 22s

Adds Core-owned identity contracts, a Core-owned Better Auth database schema
(ekklesia_core_user/session/account/verification), a first-class Core-owned
migration source that coexists with module-owned migrations under the exact
existing migration system, and an explicit Better Auth construction boundary.
No authentication endpoint or sign-in UI is exposed yet (ADR 0010).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
ekklesia
2026-07-21 16:39:15 -05:00
co-authored by Claude Sonnet 5
parent df0f20d8af
commit 679a5d225c
43 changed files with 4259 additions and 64 deletions
+184
View File
@@ -0,0 +1,184 @@
export type IdentityEnvironment = "development" | "production";
export type IdentityConfigErrorCode =
| "BASE_URL_INSECURE_IN_PRODUCTION"
| "BASE_URL_INVALID"
| "BASE_URL_LOOPBACK_IN_PRODUCTION"
| "BASE_URL_MISSING"
| "SECRET_LOW_ENTROPY"
| "SECRET_MISSING"
| "SECRET_TOO_SHORT"
| "TRUSTED_ORIGIN_INSECURE_IN_PRODUCTION"
| "TRUSTED_ORIGIN_INVALID"
| "TRUSTED_ORIGIN_LOOPBACK_IN_PRODUCTION"
| "TRUSTED_ORIGIN_WILDCARD_UNSUPPORTED"
| "TRUSTED_ORIGINS_EMPTY"
| "TRUSTED_ORIGINS_MISSING";
const MIN_SECRET_LENGTH = 32;
const LOOPBACK_HOSTNAMES: ReadonlySet<string> = new Set([
"localhost",
"127.0.0.1",
"::1",
"0.0.0.0",
]);
const errorMessages: Readonly<Record<IdentityConfigErrorCode, string>> = Object.freeze({
BASE_URL_INSECURE_IN_PRODUCTION: "The identity base URL must use https in production.",
BASE_URL_INVALID: "The identity base URL must be an absolute http or https URL.",
BASE_URL_LOOPBACK_IN_PRODUCTION:
"The identity base URL must not resolve to a loopback host in production.",
BASE_URL_MISSING: "The identity base URL is required.",
SECRET_LOW_ENTROPY: "The identity secret does not have sufficient entropy.",
SECRET_MISSING: "The identity secret is required.",
SECRET_TOO_SHORT: `The identity secret must be at least ${MIN_SECRET_LENGTH} characters.`,
TRUSTED_ORIGIN_INSECURE_IN_PRODUCTION: "Trusted origins must use https in production.",
TRUSTED_ORIGIN_INVALID:
"Every trusted origin must be an absolute http or https origin with no path, query, or fragment.",
TRUSTED_ORIGIN_LOOPBACK_IN_PRODUCTION:
"Trusted origins must not resolve to a loopback host in production.",
TRUSTED_ORIGIN_WILDCARD_UNSUPPORTED:
"Wildcard trusted origins are not supported in this release.",
TRUSTED_ORIGINS_EMPTY: "At least one trusted origin is required.",
TRUSTED_ORIGINS_MISSING: "Trusted origins are required.",
});
/**
* Never carries the offending secret, URL, origin, hostname, or any other caller-supplied value.
* Only a stable code and a fixed, generic message are ever exposed.
*/
export class IdentityConfigError extends Error {
readonly code: IdentityConfigErrorCode;
constructor(code: IdentityConfigErrorCode) {
super(errorMessages[code]);
this.name = "IdentityConfigError";
this.code = code;
this.stack = `${this.name}: ${this.message}`;
Object.freeze(this);
}
}
export interface IdentityConfig {
readonly baseUrl: string;
readonly secret: string;
readonly trustedOrigins: readonly string[];
}
export interface IdentityConfigInput {
readonly baseUrl?: unknown;
readonly secret?: unknown;
readonly trustedOrigins?: unknown;
}
export interface CreateIdentityConfigOptions {
/** Defaults to "production" so an omitted environment never silently relaxes validation. */
readonly environment?: IdentityEnvironment;
}
function isLoopbackHostname(hostname: string): boolean {
return LOOPBACK_HOSTNAMES.has(hostname.toLowerCase());
}
function parseAbsoluteHttpUrl(value: unknown): URL | undefined {
if (typeof value !== "string" || value.length === 0) {
return undefined;
}
try {
const url = new URL(value);
return url.protocol === "http:" || url.protocol === "https:" ? url : undefined;
} catch {
return undefined;
}
}
function validateSecret(secret: unknown): string {
if (typeof secret !== "string" || secret.length === 0) {
throw new IdentityConfigError("SECRET_MISSING");
}
if (secret.length < MIN_SECRET_LENGTH) {
throw new IdentityConfigError("SECRET_TOO_SHORT");
}
// A narrow, deterministic guard against trivially degenerate secrets (e.g. one repeated
// character). It cannot measure true entropy; operators remain responsible for generating a
// high-entropy value (for example `openssl rand -base64 32`).
if (/^(.)\1*$/u.test(secret)) {
throw new IdentityConfigError("SECRET_LOW_ENTROPY");
}
return secret;
}
function validateBaseUrl(baseUrl: unknown, environment: IdentityEnvironment): string {
if (typeof baseUrl !== "string" || baseUrl.length === 0) {
throw new IdentityConfigError("BASE_URL_MISSING");
}
const url = parseAbsoluteHttpUrl(baseUrl);
if (!url) {
throw new IdentityConfigError("BASE_URL_INVALID");
}
if (environment === "production") {
if (url.protocol !== "https:") {
throw new IdentityConfigError("BASE_URL_INSECURE_IN_PRODUCTION");
}
if (isLoopbackHostname(url.hostname)) {
throw new IdentityConfigError("BASE_URL_LOOPBACK_IN_PRODUCTION");
}
}
return baseUrl;
}
function validateTrustedOrigin(origin: unknown, environment: IdentityEnvironment): string {
if (typeof origin !== "string" || origin.length === 0) {
throw new IdentityConfigError("TRUSTED_ORIGIN_INVALID");
}
// Better Auth itself supports glob-style wildcard trusted origins. Phase 4A deliberately does
// not: cross-subdomain cookies are not enabled yet (see ADR 0010), so a wildcard origin would
// widen the trusted-origin surface without a matching cookie-scope decision. Revisit only
// alongside a future ADR that enables cross-subdomain cookies.
if (origin.includes("*") || origin.includes("?")) {
throw new IdentityConfigError("TRUSTED_ORIGIN_WILDCARD_UNSUPPORTED");
}
const url = parseAbsoluteHttpUrl(origin);
if (!url || url.origin !== origin) {
throw new IdentityConfigError("TRUSTED_ORIGIN_INVALID");
}
if (environment === "production") {
if (url.protocol !== "https:") {
throw new IdentityConfigError("TRUSTED_ORIGIN_INSECURE_IN_PRODUCTION");
}
if (isLoopbackHostname(url.hostname)) {
throw new IdentityConfigError("TRUSTED_ORIGIN_LOOPBACK_IN_PRODUCTION");
}
}
return origin;
}
function validateTrustedOrigins(
trustedOrigins: unknown,
environment: IdentityEnvironment,
): readonly string[] {
if (trustedOrigins === undefined || trustedOrigins === null) {
throw new IdentityConfigError("TRUSTED_ORIGINS_MISSING");
}
if (!Array.isArray(trustedOrigins) || trustedOrigins.length === 0) {
throw new IdentityConfigError("TRUSTED_ORIGINS_EMPTY");
}
return Object.freeze(trustedOrigins.map((origin) => validateTrustedOrigin(origin, environment)));
}
/**
* Validates explicit, typed identity configuration. Performs no environment reads, network
* calls, or database access; callers are responsible for sourcing `input` (for example from
* environment variables) before calling this function.
*/
export function createIdentityConfig(
input: IdentityConfigInput,
options: CreateIdentityConfigOptions = {},
): IdentityConfig {
const environment = options.environment ?? "production";
const secret = validateSecret(input.secret);
const baseUrl = validateBaseUrl(input.baseUrl, environment);
const trustedOrigins = validateTrustedOrigins(input.trustedOrigins, environment);
return Object.freeze({ baseUrl, secret, trustedOrigins });
}