diff --git a/.env.example b/.env.example index dedf229..710a9ec 100644 --- a/.env.example +++ b/.env.example @@ -81,3 +81,18 @@ S3_BUCKET=ekklesia-development # EKKLESIA_SMTP_PASSWORD= # EKKLESIA_SMTP_FROM_ADDRESS=noreply@example.org # EKKLESIA_SMTP_FROM_NAME=Ekklesia + +# Where an emailed verification or password-reset link sends a browser (ADR 0038). +# +# Absent means the Dashboard is served from EKKLESIA_IDENTITY_BASE_URL, which is the ordinary +# same-origin production shape. Set it only when a deployment separates the two, for example an API +# at https://api.example.org and the Dashboard at https://dash.example.org. It must also appear in +# EKKLESIA_IDENTITY_TRUSTED_ORIGINS, or the identity system refuses the emailed link — Ekklesia +# checks that at startup rather than letting every recipient discover it. +# +# This is the authenticated Dashboard host, never the public church website. +# EKKLESIA_DASHBOARD_ORIGIN=https://dash.example.org +# +# Verification and password recovery need a working relay above. The initial platform-owner +# bootstrap (pnpm identity:create-initial-owner) deliberately does not: it creates that one account +# already verified, so a fresh server can be signed in to before any mail exists. diff --git a/README.md b/README.md index e795d0b..76a8e47 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,8 @@ order never grants elevation — but is now **disabled by default, and cannot be production at all** until durable abuse controls exist (Phase 5D pass 1, below). See [API](docs/development/api.md) and [ADR 0011](docs/architecture/decisions/0011-identity-http-transport-and-public-enrollment.md). -Email verification, password recovery, and invitation/operator enrollment still do not exist. +Email verification and password recovery now exist (Phase 7C pass 2, below); invitation and operator +enrollment still do not. Public registration remains disabled by default and prohibited in production — and a fresh production installation now has a supported way to get its first administrator anyway: Phase 7B pass 1B's `pnpm identity:create-initial-owner` (below). The Dashboard now has a minimal authenticated shell and login/logout flow, @@ -719,12 +720,31 @@ ambiguous, and retrying it would send someone several copies of the same reset e single bounded attempt and deliberately not an exactly-once promise. There is **no `POST /api/v1/send-email`** and no route that touches the provider. -Email remains **optional**: an installation with no SMTP configured is a complete deployment, since -nothing sends mail yet. `pnpm doctor` reports the configuration and can probe connectivity **without -sending anything**, while stating plainly that a successful connection does not establish -deliverability. **Email verification, password reset, and invitations are Pass 2** — Better Auth's -`sendResetPassword` and `sendVerificationEmail` hooks are still unimplemented, and this transport is -the seam they will use. See [Outbound email](docs/development/email.md). +Email remains **optional**: an installation with no SMTP configured is a complete deployment. +`pnpm doctor` reports the configuration and can probe connectivity **without sending anything**, +while stating plainly that a successful connection does not establish deliverability. See +[Outbound email](docs/development/email.md). + +Phase 7C pass 2 completes the human identity lifecycle over that transport +([ADR 0038](docs/architecture/decisions/0038-email-verification-and-password-recovery.md)). **A +verified email address is a precondition for holding a session**: the identity system refuses an +unverified sign-in after checking the password and before creating one, so a correct password on an +unverified address carries exactly the authority of no password at all — which is why no +"unverified principal" exists and `/api/v1/principal` is unchanged. Better Auth owns every token; +Ekklesia adds no token table, no token hashing, and no expiry clock. Verification and reset links are +rebuilt from a validated Dashboard origin rather than from anything a request supplied, so no caller +can steer where a recipient lands, and following a verification link deliberately mints **no +session** — mail scanners dereference links. Password reset revokes that person's other sessions and +leaves managed devices untouched. Password recovery answers identically for an address with an +account, without one, already verified, or undeliverable — including when SMTP is broken, which is +why the delivery adapter never throws. + +**The initial platform owner is the one exception**: `pnpm identity:create-initial-owner` creates +that account already verified and has no mail prerequisite at all, so a freshly provisioned server +can be signed in to before any relay exists. Normal production use of verification and recovery does +require configured outbound email. **Public signup remains disabled** — verification is not a licence +to self-register — and invitations and user administration are still not built. See +[Identity](docs/development/identity.md). Ekklesia publishes the display bundle at `GET /api/v1/module-ui///display`, serves a stable permission-gated page at @@ -869,7 +889,7 @@ Object storage Outbound email ○ Outbound email not configured - No EKKLESIA_SMTP_HOST is set. This is a complete deployment: nothing in the platform sends email yet. + No EKKLESIA_SMTP_HOST is set. The platform runs and the bootstrap owner can sign in, but email verification and account recovery cannot deliver. Result: READY WITH WARNINGS ``` diff --git a/apps/api/package.json b/apps/api/package.json index b5a4816..2932788 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -31,8 +31,10 @@ "@ekklesia/typescript-config": "workspace:*", "@types/node": "24.13.3", "@types/pg": "8.20.0", + "@types/smtp-server": "3.5.13", "better-auth": "1.6.23", "pg": "8.22.0", + "smtp-server": "3.19.3", "typescript": "7.0.2", "vitest": "4.1.10" }, diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index e409377..a959a7f 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -79,8 +79,10 @@ export interface ApiRuntimeConfig { * The validated host outbound-email provider, or `undefined` when this installation has none * (ADR 0037). * - * `undefined` is a complete, valid deployment: nothing in the platform sends email yet, and a - * future feature that needs to discovers a structured `MAIL_NOT_CONFIGURED` rather than crashing. + * `undefined` is a valid deployment: the platform starts, every existing feature works, and the + * initial platform owner — created already verified by an operator command — can still sign in + * (ADR 0038). What it cannot do is verify a new account or deliver a password reset; each attempt + * reports a sanitized `MAIL_NOT_CONFIGURED` out of band rather than changing any HTTP response. * These values reach Core's provider and nowhere else — no module, browser bundle, diagnostic, or * API response ever sees the host or the password. */ diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index b8df129..8c1bc76 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -13,7 +13,12 @@ import { import { createDashboardStore } from "@ekklesia/core/dashboard"; import { createDeviceStore } from "@ekklesia/core/devices"; import { type HostEnvironment, loadHostConfiguration } from "@ekklesia/core/host"; -import { createIdentityRuntime, type IdentityRateLimitOverride } from "@ekklesia/core/identity"; +import { + createIdentityNotifications, + createIdentityRuntime, + type IdentityNotificationReporter, + type IdentityRateLimitOverride, +} from "@ekklesia/core/identity"; import { createSmtpMailProvider, type MailProvider } from "@ekklesia/core/mail"; import { createModuleConfigurationStore, @@ -138,6 +143,15 @@ 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; + /** + * Where a verification or reset message that could not be delivered is reported. + * + * Production composition leaves this unset and one fixed sanitized line is written. It exists as a + * seam because the *only* externally observable difference between "the relay is down" and "there + * is no such account" must be this report — every HTTP response is deliberately identical — so a + * test needs somewhere to observe the operational failure the user is never shown. + */ + readonly reportNotificationFailure?: IdentityNotificationReporter; /** Test-only escape hatch: overrides the directory module discovery scans. Never set by the * executable entry point. See `prepareStartupModuleInventory`. */ readonly modulesDirectoryOverride?: string; @@ -430,6 +444,14 @@ export async function startApi( transport: config.databaseTransport, }); + // Both host providers are constructed from validated configuration **before** anything that uses + // them, and neither touches the network while being built: an S3 client opens no connection and a + // Nodemailer transport opens no socket, so an unreachable relay or storage service can never stop + // the platform from booting (ADR 0030, ADR 0037). An installation with neither configured gets + // `undefined` for both, which is a complete, valid deployment. + const storageProvider = config.storage ? createS3StorageProvider(config.storage) : undefined; + const mailProvider = config.mail ? createSmtpMailProvider(config.mail) : undefined; + let services: ModuleRuntimeServices; let runtime: ReturnType; let authorizationStore: ReturnType; @@ -439,11 +461,22 @@ export async function startApi( runtime = createIdentityRuntime({ config: config.identity, db: databaseHandle.identity, + // Identity's two outbound messages, composed here — the one place that knows both the + // validated identity configuration and the host's mail provider. Core's identity code learns + // a recipient, a subject, and two bodies and nothing whatsoever about SMTP (ADR 0038). + notifications: createIdentityNotifications({ + authBaseUrl: config.identity.baseUrl, + dashboardOrigin: config.identity.dashboardOrigin, + mail: mailProvider, + ...(options.reportNotificationFailure ? { report: options.reportNotificationFailure } : {}), + }), ...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}), }); authorizationStore = createAuthorizationStore(databaseHandle.authorization); deviceStore = createDeviceStore(databaseHandle.devices); } catch (error) { + await safeCloseStorage(storageProvider); + await safeCloseMail(mailProvider); await safeCloseDatabase(databaseHandle); throw error; } @@ -460,21 +493,6 @@ export async function startApi( // start, before a single module entrypoint is imported. A failure here fails startup rather than // falling back to "start everything": the platform cannot know what it was told, and starting a // module an administrator disabled is worse than not starting at all. - // The host's object-storage provider, constructed from validated configuration before any module - // runs — and **without touching the network**. Building the client opens no connection, so a - // storage outage can never stop the platform from booting; a bucket is ensured lazily on first - // use instead (ADR 0030). An installation with no storage configured gets `undefined` here, which - // is a complete, valid deployment. - const storageProvider = config.storage ? createS3StorageProvider(config.storage) : undefined; - - // The host's outbound mail provider, constructed from validated configuration and — like the - // storage client above — **without touching the network**. Building a Nodemailer transport opens - // no socket and sends nothing, so an unreachable relay can never stop the platform from booting; - // the first connection happens when something actually sends (ADR 0037). Nothing sends yet: this - // exists so Pass 2 can hand it to Better Auth's verification and reset hooks. An installation with - // no SMTP configured gets `undefined`, which is a complete, valid deployment. - const mailProvider = config.mail ? createSmtpMailProvider(config.mail) : undefined; - const moduleLifecycleStore = createModuleLifecycleStore(databaseHandle.moduleLifecycle); const moduleConfigurationStore = createModuleConfigurationStore( databaseHandle.moduleConfiguration, diff --git a/apps/api/tests/api-http.integration.ts b/apps/api/tests/api-http.integration.ts index 40b2171..1d8adac 100644 --- a/apps/api/tests/api-http.integration.ts +++ b/apps/api/tests/api-http.integration.ts @@ -224,6 +224,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0004_module_lifecycle_state", "core:0005_module_configuration", "core:0006_device_identity", + "core:0007_verified_bootstrap_owners", ]); expect(aggregate.migrations[0]?.checksum).toBe( "f8f2086ef06f7a6943e8ffc4350bb010b37aed04c0a72030d2b153c986b1325f", @@ -522,6 +523,32 @@ function apiOrigin(running: RunningApi): string { return `http://127.0.0.1:${address.port}`; } +/** + * Marks an address verified out of band, the same trusted step the initial-owner bootstrap performs + * (ADR 0038). + * + * Deliberately not a weakening of the policy: it is the test-harness equivalent of an operator with + * database credentials, used only so scenarios that are about something else are not obliged to run + * an SMTP server. That the policy itself is enforced — that an unverified account genuinely cannot + * sign in — is asserted separately, both in this file and in `email-verification.integration.ts`. + */ +async function markEmailVerified(origin: string, email: string): Promise { + const url = databaseUrlByOrigin.get(origin); + if (!url) { + throw harnessError("No database is registered for that API origin."); + } + const client = new Client({ connectionString: url }); + await client.connect(); + try { + await client.query( + 'update "ekklesia_core_user" set "email_verified" = true where lower("email") = $1', + [email.trim().toLowerCase()], + ); + } finally { + await client.end().catch(() => undefined); + } +} + interface StartConfiguredApiOptions { readonly clientAddress?: ClientAddressConfigInput; /** Test-only narrowing of the platform's 30-second graceful HTTP drain deadline. */ @@ -537,6 +564,16 @@ interface StartConfiguredApiOptions { readonly rateLimitOverride?: { readonly max: number; readonly window: number } | undefined; } +/** + * The database each running API in this suite is attached to, keyed by its bound origin. + * + * Needed because Phase 7C Pass 2 made a verified address a precondition for holding a session + * (ADR 0038), so `signUp` below has to mark the account verified before these scenarios — which are + * about cookies, CSRF, principals, sessions, and authorization, not about verification — can sign + * in. Verification itself has its own dedicated end-to-end suite. + */ +const databaseUrlByOrigin = new Map(); + async function startConfiguredApi( url: string, options: StartConfiguredApiOptions = {}, @@ -571,7 +608,7 @@ async function startConfiguredApi( }, { environment }, ); - return startApi(config, { + const running = await startApi(config, { ...("rateLimitOverride" in options ? { ...(options.rateLimitOverride ? { rateLimitOverride: options.rateLimitOverride } : {}) } : { rateLimitOverride: { max: 1000, window: 1 } }), @@ -585,6 +622,8 @@ async function startConfiguredApi( ? {} : { drainTimeoutMsOverride: options.drainTimeoutMsOverride }), }); + databaseUrlByOrigin.set(apiOrigin(running), url); + return running; } async function startMigratedApi( @@ -763,6 +802,12 @@ async function signUp( headers: { "content-type": "application/json", origin: dashboardOrigin, ...headers }, method: "POST", }); + if (response.ok) { + // A verified address is a precondition for holding a session (ADR 0038). Sign-up itself always + // writes `emailVerified: false`, so these scenarios — none of which are about verification — + // complete that step out of band rather than standing up an SMTP server apiece. + await markEmailVerified(origin, email); + } return { body: (await response.clone().json()) as SignUpResult["body"], response }; } diff --git a/apps/api/tests/config.test.ts b/apps/api/tests/config.test.ts index 49dc48b..9d8457c 100644 --- a/apps/api/tests/config.test.ts +++ b/apps/api/tests/config.test.ts @@ -28,6 +28,9 @@ describe("API runtime configuration", () => { host: "127.0.0.1", identity: { baseUrl: "https://identity.example.test", + // Defaults to the identity base URL: the Dashboard and the API share one origin unless a + // deployment separates them (ADR 0038). + dashboardOrigin: "https://identity.example.test", publicSignupEnabled: false, secret: validSecret, trustedOrigins: ["https://identity.example.test"], diff --git a/apps/api/tests/email-verification.integration.ts b/apps/api/tests/email-verification.integration.ts new file mode 100644 index 0000000..9489c18 --- /dev/null +++ b/apps/api/tests/email-verification.integration.ts @@ -0,0 +1,816 @@ +import { createHmac, randomBytes } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + createBootstrapIdentityCreator, + createIdentityDrizzleDatabase, + type IdentityNotificationFailure, +} from "@ekklesia/core/identity"; +import { + applyModuleMigrations, + createPostgresMigrationDatabase, + discoverAllMigrations, +} from "@ekklesia/core/migrations"; +import { Client, type QueryResultRow } from "pg"; +import { SMTPServer } from "smtp-server"; +import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { createApiRuntimeConfig } from "../src/config.js"; +import { type RunningApi, startApi } from "../src/server.js"; + +/** + * Email verification and password recovery, end to end (ADR 0038). + * + * Everything here is real: a disposable PostgreSQL, the actual HTTP transport, the actual Better + * Auth endpoints, the actual SMTP provider, and an actual SMTP server accepting the message. The + * verification and reset links are **extracted from the delivered message** and then followed, which + * is the only way to prove the whole path rather than the pieces — a fake mail function would prove + * that a callback was invoked and nothing about whether the resulting link works. + * + * `smtp-server` runs in-process and is never given a live credential, so nothing here reaches the + * internet and no real email is ever sent. + * + * Public signup is disabled in every scenario, matching production. Accounts that are not the + * bootstrap owner are created through the same trusted server-side creator the bootstrap CLI uses, + * which is deliberately not reachable over HTTP. + */ +const integrationDatabaseUrlEnvironment = "EKKLESIA_MIGRATION_TEST_DATABASE_URL"; +const repositoryRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); +const coreMigrationsDirectory = path.join(repositoryRoot, "core", "drizzle"); +const trackedDatabases = new Set(); +let databaseCounter = 0; + +const dashboardOrigin = "http://dashboard.localhost:5173"; +const integrationSecret = "integration-test-secret-0123456789abcdefgh"; +const initialPassword = "correct horse battery staple"; +const replacementPassword = "an entirely different passphrase"; + +class IntegrationHarnessError extends Error { + constructor(message: string) { + super(message); + this.name = "IntegrationHarnessError"; + this.stack = `${this.name}: ${this.message}`; + } +} + +function baseDatabaseUrl(): string { + const url = process.env[integrationDatabaseUrlEnvironment]; + if (typeof url !== "string" || url.trim() === "") { + throw new IntegrationHarnessError("The integration database URL is not configured."); + } + return url; +} + +function databaseUrl(name: string): string { + const url = new URL(baseDatabaseUrl()); + url.pathname = `/${name}`; + return url.toString(); +} + +async function openAdministrativeClient(): Promise { + const client = new Client({ connectionString: baseDatabaseUrl() }); + await client.connect(); + return client; +} + +async function safeQuery( + client: Client, + text: string, + values: readonly unknown[] = [], +): Promise { + try { + const result = await client.query(text, [...values]); + return result.rows; + } catch { + // Never rethrows the driver error: it can carry SQL, a connection string, and a host. + throw new IntegrationHarnessError("An integration database statement failed."); + } +} + +async function applyCoreMigrations(url: string): Promise { + const emptyModules = await mkdtemp(path.join(tmpdir(), "ekklesia-verify-no-modules-")); + try { + const aggregate = await discoverAllMigrations({ + coreMigrationsDirectory, + moduleDiscovery: { + moduleSdkVersion: "0.1.0", + modulesDirectory: emptyModules, + platformVersion: "0.1.0", + }, + }); + await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); + } finally { + await rm(emptyModules, { force: true, recursive: true }); + } +} + +async function freshInstallation(label: string): Promise { + databaseCounter += 1; + const name = + `ekk_verify_${label}_${databaseCounter}_${randomBytes(4).toString("hex")}`.toLowerCase(); + const admin = await openAdministrativeClient(); + try { + await safeQuery(admin, `create database "${name}"`); + } finally { + await admin.end().catch(() => undefined); + } + trackedDatabases.add(name); + const url = databaseUrl(name); + await applyCoreMigrations(url); + return url; +} + +/** A delivered message, as the SMTP server actually received it. */ +interface DeliveredMessage { + readonly to: readonly string[]; + readonly data: string; +} + +interface MailHarness { + readonly port: number; + readonly delivered: DeliveredMessage[]; + readonly close: () => Promise; +} + +const openResources: Array<() => Promise> = []; + +afterEach(async () => { + for (const close of openResources.splice(0).reverse()) { + await close().catch(() => undefined); + } +}); + +/** + * Picks a free port before the API is configured. + * + * The identity base URL is what the emailed link is built from, so it has to be known *before* + * `startApi` binds. Without this the test could only assert the link's shape and would have to + * rewrite its host before following it — which is exactly the part worth proving. Reserving and + * releasing a port leaves a theoretical race, but nothing else on the loopback interface is + * competing for ephemeral ports inside this suite. + */ +async function reservePort(): Promise { + const { createServer } = await import("node:net"); + const server = createServer(); + const port = await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address() as AddressInfo; + resolve(address.port); + }); + }); + await new Promise((resolve) => server.close(() => resolve())); + return port; +} + +async function startMailServer(): Promise { + const delivered: DeliveredMessage[] = []; + const server = new SMTPServer({ + authOptional: true, + disabledCommands: ["STARTTLS"], + onData(stream, session, callback) { + let data = ""; + stream.on("data", (chunk: Buffer) => { + data += chunk.toString("utf8"); + }); + stream.on("end", () => { + delivered.push({ + data, + to: session.envelope.rcptTo.map((recipient) => recipient.address), + }); + callback(); + }); + }, + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + const address = server.server.address() as AddressInfo; + const harness: MailHarness = { + close: async () => + await new Promise((resolve) => { + server.close(() => resolve()); + }), + delivered, + port: address.port, + }; + openResources.push(harness.close); + return harness; +} + +interface Installation { + readonly url: string; + readonly api: RunningApi; + readonly origin: string; + readonly mail: MailHarness | undefined; + readonly notificationFailures: IdentityNotificationFailure[]; + readonly createIdentity: (input: { + readonly email: string; + readonly password: string; + readonly name: string; + }) => Promise; +} + +/** + * A migrated installation with the API running, optionally with a working SMTP relay. + * + * `plaintext` SMTP and an `http` Dashboard origin are only acceptable because this is explicitly the + * development environment; production configuration refuses both, which `identity-config` and + * `mail-contract` assert separately. + */ +async function installation( + label: string, + options: { readonly mail?: boolean } = {}, +): Promise { + const url = await freshInstallation(label); + const mail = options.mail === false ? undefined : await startMailServer(); + const notificationFailures: IdentityNotificationFailure[] = []; + const identity = createIdentityDrizzleDatabase(url, {}); + openResources.push(async () => { + await identity.close().catch(() => undefined); + }); + + const apiPort = await reservePort(); + const identityBaseUrl = `http://127.0.0.1:${apiPort}`; + const config = createApiRuntimeConfig( + { + databaseUrl: url, + host: "127.0.0.1", + identity: { + baseUrl: identityBaseUrl, + dashboardOrigin, + publicSignupEnabled: false, + secret: integrationSecret, + trustedOrigins: [dashboardOrigin], + }, + ...(mail + ? { + mail: { + fromAddress: "ekklesia@example.test", + fromName: "Ekklesia", + host: "127.0.0.1", + port: String(mail.port), + security: "plaintext", + }, + } + : {}), + port: apiPort, + }, + { environment: "development" }, + ); + const api = await startApi(config, { + rateLimitOverride: { max: 1000, window: 1 }, + reportNotificationFailure: (failure) => notificationFailures.push(failure), + }); + openResources.push(async () => { + await api.shutdown().catch(() => undefined); + }); + + const address = api.server.address(); + if (!address || typeof address !== "object" || address.port !== apiPort) { + throw new IntegrationHarnessError("The API integration server did not bind the reserved port."); + } + return { + api, + createIdentity: createBootstrapIdentityCreator(identity.db), + mail, + notificationFailures, + origin: identityBaseUrl, + url, + }; +} + +async function post( + origin: string, + path: string, + body: unknown, + headers: Readonly> = {}, +): Promise { + return await fetch(`${origin}${path}`, { + body: JSON.stringify(body), + headers: { "content-type": "application/json", origin: dashboardOrigin, ...headers }, + method: "POST", + redirect: "manual", + }); +} + +function firstCookiePair(response: Response): string | undefined { + const setCookie = response.headers.get("set-cookie"); + return setCookie === null ? undefined : setCookie.split(",")[0]?.split(";")[0]?.trim(); +} + +async function signIn( + origin: string, + email: string, + password: string, +): Promise<{ readonly response: Response; readonly cookiePair: string | undefined }> { + const response = await post(origin, "/api/auth/sign-in/email", { email, password }); + return { cookiePair: firstCookiePair(response), response }; +} + +async function principalKind(origin: string, cookiePair: string | undefined): Promise { + const response = await fetch(`${origin}/api/v1/principal`, { + headers: cookiePair ? { cookie: cookiePair } : {}, + }); + const body = (await response.json()) as { readonly kind?: unknown }; + return String(body.kind); +} + +/** + * The one place a test reads a token, and only ever out of a message this suite's own SMTP server + * received. Nothing in the platform exposes one. + * + * Quoted-printable soft line breaks are undone first: a URL longer than 76 characters is split by + * the encoder, and reassembling it here is what makes the extracted link the one a mail client would + * actually resolve. + */ +function extractLink(message: DeliveredMessage, contains: string): URL { + const unwrapped = message.data.replaceAll("=\r\n", "").replaceAll("=\n", ""); + const decoded = unwrapped.replaceAll(/=([0-9A-F]{2})/gu, (_match, hex: string) => + String.fromCharCode(Number.parseInt(hex, 16)), + ); + const match = decoded.match(/https?:\/\/[^\s"<>]+/gu)?.find((url) => url.includes(contains)); + if (!match) { + throw new IntegrationHarnessError(`No ${contains} link was delivered.`); + } + return new URL(match); +} + +function lastMessage(mail: MailHarness): DeliveredMessage { + const message = mail.delivered.at(-1); + if (!message) { + throw new IntegrationHarnessError("No message was delivered."); + } + return message; +} + +async function emailVerifiedFlag(url: string, email: string): Promise { + const client = new Client({ connectionString: url }); + await client.connect(); + try { + const rows = await safeQuery<{ email_verified: boolean }>( + client, + 'select "email_verified" from "ekklesia_core_user" where lower("email") = $1', + [email.toLowerCase()], + ); + return rows[0]?.email_verified; + } finally { + await client.end().catch(() => undefined); + } +} + +/** + * An account that has completed the real verification round trip. + * + * Recovery is only meaningful for a verified account, and building one through the actual flow — + * rather than by writing `email_verified` directly — keeps every recovery test standing on a + * verification path that genuinely works. + */ +async function verifiedAccountFor(install: Installation, email: string): Promise { + await install.createIdentity({ email, name: "Member", password: initialPassword }); + await signIn(install.origin, email, initialPassword); + const link = extractLink(lastMessage(install.mail as MailHarness), "/verify-email"); + await fetch(link, { redirect: "manual" }); + (install.mail as MailHarness).delivered.length = 0; +} + +afterAll(async () => { + const admin = await openAdministrativeClient().catch(() => undefined); + if (!admin) { + return; + } + try { + for (const name of trackedDatabases) { + await safeQuery(admin, `drop database if exists "${name}" with (force)`).catch( + () => undefined, + ); + } + } finally { + await admin.end().catch(() => undefined); + } +}); + +/** + * Forges a verification token that is structurally valid but already expired. + * + * Better Auth's verification token is a plain HS256 JWS over the auth secret (`signJWT`, pinned + * release) — not a database row — so an expired one can be produced here from the same secret this + * suite configured, with no library internals and no waiting an hour. This is the only place any + * test constructs a token rather than reading one out of a delivered message. + */ +function forgeExpiredVerificationToken(email: string): string { + const encode = (value: object): string => + Buffer.from(JSON.stringify(value)).toString("base64url"); + const issued = Math.floor(Date.now() / 1000) - 7200; + const signingInput = `${encode({ alg: "HS256", typ: "JWT" })}.${encode({ + email: email.toLowerCase(), + exp: issued + 3600, + iat: issued, + })}`; + const signature = createHmac("sha256", integrationSecret) + .update(signingInput) + .digest("base64url"); + return `${signingInput}.${signature}`; +} + +describe("email verification, end to end", () => { + it("takes a new human account from unverified to signed in through a real emailed link", async () => { + const install = await installation("flow"); + const email = "member@example.org"; + await install.createIdentity({ email, name: "Member", password: initialPassword }); + + // 1. Unverified. A correct password does not produce a session — the identity system refuses + // before creating one, so there is no half-authenticated state anywhere (ADR 0038). + expect(await emailVerifiedFlag(install.url, email)).toBe(false); + const refused = await signIn(install.origin, email, initialPassword); + expect(refused.response.status).toBe(403); + expect(await refused.response.clone().json()).toMatchObject({ code: "EMAIL_NOT_VERIFIED" }); + expect(refused.cookiePair).toBeUndefined(); + expect(await principalKind(install.origin, refused.cookiePair)).toBe("anonymous"); + + // 2. That attempt delivered a verification message through the real SMTP provider. + const mail = install.mail as MailHarness; + expect(mail.delivered).toHaveLength(1); + expect(lastMessage(mail).to).toEqual([email]); + expect(lastMessage(mail).data).toContain("Verify your Ekklesia email address"); + + // 3. Follow the link exactly as it was delivered. Nothing rewrites it. + const link = extractLink(lastMessage(mail), "/verify-email"); + expect(link.origin).toBe(install.origin); + const verified = await fetch(link, { redirect: "manual" }); + + // 4. Better Auth redirects to the Dashboard landing this deployment configured — never to the + // API origin, and never to anything a request supplied. + expect(verified.status).toBe(302); + const landing = new URL(verified.headers.get("location") ?? ""); + expect(landing.origin).toBe(dashboardOrigin); + expect(landing.searchParams.get("ekklesia_auth")).toBe("verify-email"); + expect(landing.searchParams.get("error")).toBeNull(); + // No session was minted by following an emailed link. + expect(verified.headers.get("set-cookie")).toBeNull(); + expect(await emailVerifiedFlag(install.url, email)).toBe(true); + + // 5. The same credentials now work, and the principal is a fully authenticated human. + const accepted = await signIn(install.origin, email, initialPassword); + expect(accepted.response.status).toBe(200); + expect(accepted.cookiePair).toBeDefined(); + expect(await principalKind(install.origin, accepted.cookiePair)).toBe("authenticated"); + }); + + it("refuses a malformed token and an expired token, each with its own reason", async () => { + const install = await installation("bad-token"); + const email = "expired@example.org"; + await install.createIdentity({ email, name: "Expired", password: initialPassword }); + const callback = encodeURIComponent(`${dashboardOrigin}/?ekklesia_auth=verify-email`); + + for (const [token, reason] of [ + ["not-a-jwt", "INVALID_TOKEN"], + [forgeExpiredVerificationToken(email), "TOKEN_EXPIRED"], + ] as const) { + const response = await fetch( + `${install.origin}/api/auth/verify-email?token=${encodeURIComponent(token)}&callbackURL=${callback}`, + { redirect: "manual" }, + ); + expect(response.status).toBe(302); + const landing = new URL(response.headers.get("location") ?? ""); + // The failure still lands on the host's own screen rather than a raw JSON error page. + expect(landing.origin).toBe(dashboardOrigin); + expect(landing.searchParams.get("error")).toBe(reason); + expect(await emailVerifiedFlag(install.url, email)).toBe(false); + } + }); + + it("is idempotent when a link is followed twice, because the token is a signed claim", async () => { + const install = await installation("replay"); + const email = "replay@example.org"; + await install.createIdentity({ email, name: "Replay", password: initialPassword }); + await signIn(install.origin, email, initialPassword); + const link = extractLink(lastMessage(install.mail as MailHarness), "/verify-email"); + + const first = await fetch(link, { redirect: "manual" }); + const second = await fetch(link, { redirect: "manual" }); + + // Documented honestly (ADR 0038): the verification token is a signed JWT, not a consumed + // database row, so it is **not** single-use — it is valid until it expires. Replaying it is a + // no-op on an already-verified address and mints no session, which is what makes that safe. + // Mail scanners and prefetchers dereference links, so idempotence is a requirement here. + expect(first.status).toBe(302); + expect(second.status).toBe(302); + expect(second.headers.get("set-cookie")).toBeNull(); + expect(await emailVerifiedFlag(install.url, email)).toBe(true); + }); + + it("resends on request without revealing whether the address has an account", async () => { + const install = await installation("resend"); + const email = "resend@example.org"; + await install.createIdentity({ email, name: "Resend", password: initialPassword }); + const mail = install.mail as MailHarness; + + const known = await post(install.origin, "/api/auth/send-verification-email", { email }); + const unknown = await post(install.origin, "/api/auth/send-verification-email", { + email: "nobody@example.org", + }); + const verifiedAlready = await post(install.origin, "/api/auth/send-verification-email", { + email: "member-who-does-not-exist@example.org", + }); + + // Identical answers. The only difference anywhere is that a message was delivered, which the + // requester cannot observe. + expect(known.status).toBe(200); + expect(unknown.status).toBe(200); + expect(verifiedAlready.status).toBe(200); + expect(await known.clone().json()).toEqual(await unknown.clone().json()); + expect(mail.delivered.map((message) => message.to)).toEqual([[email]]); + }); +}); + +describe("password recovery, end to end", () => { + it("replaces the password through a real emailed link and invalidates the old one", async () => { + const install = await installation("reset"); + const email = "reset@example.org"; + await verifiedAccountFor(install, email); + const mail = install.mail as MailHarness; + + const requested = await post(install.origin, "/api/auth/request-password-reset", { email }); + expect(requested.status).toBe(200); + expect(mail.delivered).toHaveLength(1); + expect(lastMessage(mail).to).toEqual([email]); + expect(lastMessage(mail).data).toContain("Reset your Ekklesia password"); + + // Follow the emailed link. Better Auth validates the token and hands the browser on to the + // Dashboard landing with it attached — which is where the host's reset form picks it up. + const link = extractLink(lastMessage(mail), "/reset-password/"); + expect(link.origin).toBe(install.origin); + const handoff = await fetch(link, { redirect: "manual" }); + expect(handoff.status).toBe(302); + const landing = new URL(handoff.headers.get("location") ?? ""); + expect(landing.origin).toBe(dashboardOrigin); + expect(landing.searchParams.get("ekklesia_auth")).toBe("reset-password"); + const token = landing.searchParams.get("token") ?? ""; + expect(token).not.toBe(""); + + const completed = await post(install.origin, "/api/auth/reset-password", { + newPassword: replacementPassword, + token, + }); + expect(completed.status).toBe(200); + + const oldCredential = await signIn(install.origin, email, initialPassword); + expect(oldCredential.response.status).toBe(401); + const newCredential = await signIn(install.origin, email, replacementPassword); + expect(newCredential.response.status).toBe(200); + expect(await principalKind(install.origin, newCredential.cookiePair)).toBe("authenticated"); + }); + + it("consumes the reset token, so a captured link cannot be replayed", async () => { + const install = await installation("reset-replay"); + const email = "replay-reset@example.org"; + await verifiedAccountFor(install, email); + await post(install.origin, "/api/auth/request-password-reset", { email }); + const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/"); + const handoff = await fetch(link, { redirect: "manual" }); + const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? ""; + + const first = await post(install.origin, "/api/auth/reset-password", { + newPassword: replacementPassword, + token, + }); + const second = await post(install.origin, "/api/auth/reset-password", { + newPassword: "yet another different passphrase", + token, + }); + + // Unlike the verification token, this one is a database row Better Auth consumes. + expect(first.status).toBe(200); + expect(second.status).toBe(400); + expect(await second.json()).toMatchObject({ code: "INVALID_TOKEN" }); + expect((await signIn(install.origin, email, replacementPassword)).response.status).toBe(200); + }); + + it("signs out every existing session for that account", async () => { + const install = await installation("reset-sessions"); + const email = "sessions@example.org"; + await verifiedAccountFor(install, email); + const before = await signIn(install.origin, email, initialPassword); + expect(await principalKind(install.origin, before.cookiePair)).toBe("authenticated"); + + await post(install.origin, "/api/auth/request-password-reset", { email }); + const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/"); + const handoff = await fetch(link, { redirect: "manual" }); + const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? ""; + await post(install.origin, "/api/auth/reset-password", { + newPassword: replacementPassword, + token, + }); + + // The whole point of recovery is that it works when the credential is already compromised, so + // an attacker's existing session must not survive it (`revokeSessionsOnPasswordReset`). + expect(await principalKind(install.origin, before.cookiePair)).toBe("anonymous"); + }); + + it("enforces the identity system's own password policy, with no reset-specific rule", async () => { + const install = await installation("reset-policy"); + const email = "policy@example.org"; + await verifiedAccountFor(install, email); + await post(install.origin, "/api/auth/request-password-reset", { email }); + const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/"); + const handoff = await fetch(link, { redirect: "manual" }); + const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? ""; + + const tooShort = await post(install.origin, "/api/auth/reset-password", { + newPassword: "short", + token, + }); + + expect(tooShort.status).toBe(400); + expect(await tooShort.json()).toMatchObject({ code: "PASSWORD_TOO_SHORT" }); + // The old password still works: a rejected reset changes nothing. + expect((await signIn(install.origin, email, initialPassword)).response.status).toBe(200); + }); + + it("answers a request for an address with no account exactly as it answers a real one", async () => { + const install = await installation("enumeration"); + const email = "known@example.org"; + await verifiedAccountFor(install, email); + const mail = install.mail as MailHarness; + + const known = await post(install.origin, "/api/auth/request-password-reset", { email }); + const unknown = await post(install.origin, "/api/auth/request-password-reset", { + email: "stranger@example.org", + }); + + expect(known.status).toBe(unknown.status); + expect(await known.clone().json()).toEqual(await unknown.clone().json()); + expect(JSON.stringify(await unknown.json())).not.toContain("stranger"); + // Only the real account received anything. + expect(mail.delivered.map((message) => message.to)).toEqual([[email]]); + }); +}); + +describe("recovery when the installation has no outbound email", () => { + it("answers identically and records the operational failure out of band", async () => { + const install = await installation("no-mail", { mail: false }); + const email = "nomail@example.org"; + await install.createIdentity({ email, name: "No Mail", password: initialPassword }); + + const reset = await post(install.origin, "/api/auth/request-password-reset", { email }); + const resend = await post(install.origin, "/api/auth/send-verification-email", { email }); + + // The user-facing answer must not change: "we could not send it" for a real address and a + // generic success for an unknown one is an enumeration oracle that appears when SMTP breaks. + expect(reset.status).toBe(200); + expect(resend.status).toBe(200); + // The operator still learns about it, with a sanitized code and no address or token. + expect(install.notificationFailures).toEqual([ + { kind: "password-reset", reason: "MAIL_NOT_CONFIGURED" }, + { kind: "email-verification", reason: "MAIL_NOT_CONFIGURED" }, + ]); + expect(JSON.stringify(install.notificationFailures)).not.toContain("nomail@example.org"); + }); + + it("still starts, serves the API, and refuses an unverified sign-in", async () => { + const install = await installation("no-mail-runs", { mail: false }); + const email = "nomail-signin@example.org"; + await install.createIdentity({ email, name: "No Mail", password: initialPassword }); + + // Absent mail is a complete deployment (ADR 0037) and does **not** relax the verification + // policy — which is exactly why the initial owner is created already verified. + expect((await fetch(`${install.origin}/api/v1/principal`)).status).toBe(200); + expect((await signIn(install.origin, email, initialPassword)).response.status).toBe(403); + }); +}); + +describe("recovery cannot be turned into a redirect or an account factory", () => { + it("refuses an attacker-supplied reset destination", async () => { + const install = await installation("redirect"); + const email = "redirect@example.org"; + await install.createIdentity({ email, name: "Redirect", password: initialPassword }); + + const external = await post(install.origin, "/api/auth/request-password-reset", { + email, + redirectTo: "https://evil.example.test/steal", + }); + + expect(external.status).toBe(403); + expect(await external.json()).toMatchObject({ code: "INVALID_REDIRECT_URL" }); + expect((install.mail as MailHarness).delivered).toHaveLength(0); + }); + + it("refuses an attacker-supplied verification destination", async () => { + const install = await installation("redirect-verify"); + + for (const callbackURL of [ + "https://evil.example.test", + "//evil.example.test", + "/\\evil.example.test", + "javascript:alert(1)", + ]) { + const response = await fetch( + `${install.origin}/api/auth/verify-email?token=x&callbackURL=${encodeURIComponent(callbackURL)}`, + { redirect: "manual" }, + ); + expect(response.status).toBe(403); + expect(response.headers.get("location")).toBeNull(); + } + }); + + it("ignores a supplied destination even where one is accepted, because the link is rebuilt", async () => { + const install = await installation("redirect-ignored"); + const email = "ignored@example.org"; + await verifiedAccountFor(install, email); + + // A *trusted* origin passes Better Auth's own check, so this is the case where the emailed + // link's destination would otherwise be caller-chosen. It is not: the message is built from + // configuration and the supplied value never reaches it. + await post(install.origin, "/api/auth/request-password-reset", { + email, + redirectTo: `${dashboardOrigin}/somewhere-else`, + }); + + const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/"); + const callback = new URL(link.searchParams.get("callbackURL") ?? ""); + expect(callback.origin).toBe(dashboardOrigin); + expect(callback.pathname).toBe("/"); + expect(callback.searchParams.get("ekklesia_auth")).toBe("reset-password"); + expect(link.href).not.toContain("somewhere-else"); + }); + + it("keeps public signup disabled now that verification exists", async () => { + const install = await installation("signup"); + + const response = await post(install.origin, "/api/auth/sign-up/email", { + email: "intruder@example.org", + name: "Intruder", + password: initialPassword, + }); + + // Email verification is not a licence to self-register. Future accounts arrive through + // deliberate administration, which this pass does not build. + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ code: "EMAIL_PASSWORD_SIGN_UP_DISABLED" }); + expect(await emailVerifiedFlag(install.url, "intruder@example.org")).toBeUndefined(); + }); +}); + +describe("managed devices are untouched by human email identity", () => { + it("leaves a paired device intact across a human password reset", async () => { + const install = await installation("devices"); + const email = "device-owner@example.org"; + await verifiedAccountFor(install, email); + + // A device is a row with a credential digest and no email, no password, and no verification + // state (ADR 0036). It is inserted directly here because pairing it through HTTP would prove + // something this test is not about. + const client = new Client({ connectionString: install.url }); + await client.connect(); + try { + await safeQuery( + client, + 'insert into "ekklesia_core_device" ("name", "credential_hash") values ($1, $2)', + ["Sanctuary display", "a".repeat(64)], + ); + + await post(install.origin, "/api/auth/request-password-reset", { email }); + const link = extractLink(lastMessage(install.mail as MailHarness), "/reset-password/"); + const handoff = await fetch(link, { redirect: "manual" }); + const token = new URL(handoff.headers.get("location") ?? "").searchParams.get("token") ?? ""; + await post(install.origin, "/api/auth/reset-password", { + newPassword: replacementPassword, + token, + }); + + const devices = await safeQuery<{ credential_hash: string; revoked_at: Date | null }>( + client, + 'select "credential_hash", "revoked_at" from "ekklesia_core_device"', + ); + expect(devices).toEqual([{ credential_hash: "a".repeat(64), revoked_at: null }]); + + // The device table has no email or verification concept for a human flow to reach. + const columns = await safeQuery<{ column_name: string }>( + client, + "select column_name from information_schema.columns where table_name = 'ekklesia_core_device'", + ); + const names = columns.map((column) => column.column_name).join(" "); + expect(names).not.toContain("email"); + expect(names).not.toContain("verified"); + expect(names).not.toContain("password"); + } finally { + await client.end().catch(() => undefined); + } + }); + + it("sends a device no mail, because a device has no address to send to", async () => { + const install = await installation("devices-mail"); + + await post(install.origin, "/api/auth/request-password-reset", { + email: "sanctuary-display@example.org", + }); + + // Nothing about device identity participates in either flow. + expect((install.mail as MailHarness).delivered).toHaveLength(0); + expect(install.notificationFailures).toEqual([]); + }); +}); diff --git a/apps/api/tests/initial-owner.integration.ts b/apps/api/tests/initial-owner.integration.ts index 7a234fd..aa3d198 100644 --- a/apps/api/tests/initial-owner.integration.ts +++ b/apps/api/tests/initial-owner.integration.ts @@ -170,9 +170,13 @@ function bootstrapAgainst(url: string): BootstrapHandles { }; } -/** The production posture: public signup disabled, exactly as a real deployment must run. */ -async function startApiWithoutSignup(url: string): Promise { - const config = createApiRuntimeConfig( +/** + * The production posture: public signup disabled, exactly as a real deployment must run — and, just + * as deliberately, **no outbound email configured at all**. A fresh installation has no relay yet, + * which is the whole reason the initial owner is created already verified (ADR 0038). + */ +function ownerApiConfig(url: string): ReturnType { + return createApiRuntimeConfig( { databaseUrl: url, host: "127.0.0.1", @@ -186,7 +190,10 @@ async function startApiWithoutSignup(url: string): Promise { }, { environment: "development" }, ); - return startApi(config, { rateLimitOverride: { max: 1000, window: 1 } }); +} + +async function startApiWithoutSignup(url: string): Promise { + return startApi(ownerApiConfig(url), { rateLimitOverride: { max: 1000, window: 1 } }); } function apiOrigin(running: RunningApi): string { @@ -270,6 +277,22 @@ async function countRows(url: string, table: string): Promise { } } +/** Better Auth's own `emailVerified` column — the same value its `/verify-email` endpoint writes. */ +async function emailVerifiedFlag(url: string, email: string): Promise { + const client = new Client({ connectionString: url }); + await client.connect(); + try { + const rows = await safeQuery<{ email_verified: boolean }>( + client, + 'select "email_verified" from "ekklesia_core_user" where lower("email") = $1', + [email.trim().toLowerCase()], + ); + return rows[0]?.email_verified; + } finally { + await client.end().catch(() => undefined); + } +} + afterAll(async () => { const admin = await openAdministrativeClient().catch(() => undefined); if (!admin) { @@ -685,3 +708,141 @@ describe.sequential("initial platform-owner bootstrap", () => { }); }); }); + +describe.sequential("initial platform-owner bootstrap and email verification", () => { + /** + * The trusted-bootstrap verification exception (ADR 0038). + * + * A verified address became a precondition for holding a session in Phase 7C Pass 2. The initial + * owner is created on a freshly provisioned server, by an operator holding the database + * credentials, at a moment when SMTP may not exist — so requiring an email round trip here would + * produce a working installation nobody can ever sign in to. These scenarios exist to prove that + * the exception is real, that it is narrow, and that mail remains genuinely absent throughout. + */ + it("creates the owner already verified, proven in the database and in the browser", async () => { + const url = await freshInstallation("verifiedowner"); + const email = `owner-${randomBytes(4).toString("hex")}@example.test`; + + const bootstrap = bootstrapAgainst(url); + try { + await bootstrap.run(email); + } finally { + await bootstrap.close(); + } + + // Database state: the exact column Better Auth's own verification endpoint writes, so the row + // is indistinguishable from one that verified normally. + expect(await emailVerifiedFlag(url, email)).toBe(true); + + // ...and the browser-facing consequence, which is the only thing that actually matters. + const api = await startApiWithoutSignup(url); + try { + const origin = apiOrigin(api); + const session = await signIn(origin, email, ownerPassword); + expect(session.response.status).toBe(200); + expect(session.cookiePair).toBeDefined(); + } finally { + await api.shutdown(); + } + }); + + it("needs no outbound email configured, at bootstrap or at sign-in", async () => { + const url = await freshInstallation("nomailowner"); + const email = `owner-${randomBytes(4).toString("hex")}@example.test`; + + const bootstrap = bootstrapAgainst(url); + try { + // No SMTP host, no relay, no network: `createBootstrapIdentityCreator` constructs its own + // Better Auth instance with no `emailVerification` configuration at all. + await bootstrap.run(email); + } finally { + await bootstrap.close(); + } + + const api = await startApiWithoutSignup(url); + try { + // The API this suite starts configures no mail either, so nothing in this scenario could have + // sent or received a message. + expect(ownerApiConfig(url).mail).toBeUndefined(); + expect((await signIn(apiOrigin(api), email, ownerPassword)).response.status).toBe(200); + } finally { + await api.shutdown(); + } + }); + + it("marks an adopted identity verified too, so bootstrap never yields an owner who cannot sign in", async () => { + const url = await freshInstallation("adoptverified"); + const email = `owner-${randomBytes(4).toString("hex")}@example.test`; + + // An identity that exists before bootstrap runs — created here through the same trusted creator, + // which always writes `emailVerified: false`. + const first = bootstrapAgainst(url); + try { + await first.run(email); + } finally { + await first.close(); + } + const client = new Client({ connectionString: url }); + await client.connect(); + try { + await safeQuery(client, 'update "ekklesia_core_user" set "email_verified" = false'); + await safeQuery(client, 'delete from "ekklesia_core_platform_owner_bootstrap"'); + await safeQuery(client, 'delete from "ekklesia_core_platform_owner"'); + } finally { + await client.end().catch(() => undefined); + } + expect(await emailVerifiedFlag(url, email)).toBe(false); + + const second = bootstrapAgainst(url); + let adopted: Awaited>; + try { + adopted = await second.run(email); + } finally { + await second.close(); + } + + expect(adopted.identity).toBe("adopted"); + expect(await emailVerifiedFlag(url, email)).toBe(true); + }); + + it("verifies only the owner, leaving every other account subject to the normal policy", async () => { + const url = await freshInstallation("narrowexception"); + const ownerEmail = `owner-${randomBytes(4).toString("hex")}@example.test`; + const otherEmail = `member-${randomBytes(4).toString("hex")}@example.test`; + + const bootstrap = bootstrapAgainst(url); + try { + await bootstrap.run(ownerEmail); + // A second identity created through the identical trusted creator, but never bootstrapped. + const identity = createIdentityDrizzleDatabase(url, {}); + try { + await createBootstrapIdentityCreator(identity.db)({ + email: otherEmail, + name: "Ordinary Member", + password: ownerPassword, + }); + } finally { + await identity.close().catch(() => undefined); + } + } finally { + await bootstrap.close(); + } + + expect(await emailVerifiedFlag(url, ownerEmail)).toBe(true); + expect(await emailVerifiedFlag(url, otherEmail)).toBe(false); + + const api = await startApiWithoutSignup(url); + try { + const origin = apiOrigin(api); + expect((await signIn(origin, ownerEmail, ownerPassword)).response.status).toBe(200); + // The exception did not widen: an ordinary account still has to verify, and on this + // mail-less installation it simply cannot sign in yet. + const refused = await signIn(origin, otherEmail, ownerPassword); + expect(refused.response.status).toBe(403); + expect(await refused.response.json()).toMatchObject({ code: "EMAIL_NOT_VERIFIED" }); + expect(refused.cookiePair).toBeUndefined(); + } finally { + await api.shutdown(); + } + }); +}); diff --git a/apps/api/tests/module-database.integration.ts b/apps/api/tests/module-database.integration.ts index a31aec5..5a91e26 100644 --- a/apps/api/tests/module-database.integration.ts +++ b/apps/api/tests/module-database.integration.ts @@ -224,6 +224,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0004_module_lifecycle_state", "core:0005_module_configuration", "core:0006_device_identity", + "core:0007_verified_bootstrap_owners", ]); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { @@ -253,6 +254,7 @@ async function applyMigrationsWithModule(url: string): Promise { "core:0004_module_lifecycle_state", "core:0005_module_configuration", "core:0006_device_identity", + "core:0007_verified_bootstrap_owners", "db-facade:0000_initial", ]); expect(aggregate.migrations[0]?.checksum).toBe( @@ -319,6 +321,27 @@ async function signUp(origin: string, email: string, password: string): Promise< return { userId: body.user.id }; } +/** + * Marks an address verified out of band, the same trusted step the initial-owner bootstrap performs. + * + * A verified address is a precondition for holding a session (ADR 0038), and this suite is about + * module database access rather than identity, so it completes that step directly rather than + * standing up an SMTP server. That the policy is genuinely enforced is asserted in + * `email-verification.integration.ts`. + */ +async function markEmailVerified(url: string, email: string): Promise { + const client = new Client({ connectionString: url }); + await client.connect(); + try { + await client.query( + 'update "ekklesia_core_user" set "email_verified" = true where lower("email") = $1', + [email.trim().toLowerCase()], + ); + } finally { + await client.end().catch(() => undefined); + } +} + async function signIn(origin: string, email: string, password: string): Promise { const response = await fetch(`${origin}/api/auth/sign-in/email`, { body: JSON.stringify({ email, password }), @@ -507,6 +530,7 @@ describe.sequential("live API — module database access through ModuleRuntimeSe expect(startupRow).toEqual({ id: STARTUP_RECORD_ID, value: "startup-value" }); const signedUp = await signUp(origin, "db-facade-owner@example.test", "correct horse battery"); + await markEmailVerified(database.url, "db-facade-owner@example.test"); ownerCookie = await signIn(origin, "db-facade-owner@example.test", "correct horse battery"); expect(signedUp.userId).toBeDefined(); }); diff --git a/core/drizzle/0007_verified_bootstrap_owners.sql b/core/drizzle/0007_verified_bootstrap_owners.sql new file mode 100644 index 0000000..7764f44 --- /dev/null +++ b/core/drizzle/0007_verified_bootstrap_owners.sql @@ -0,0 +1,20 @@ +-- Email verification becomes a precondition for holding a session (ADR 0038). Any platform owner +-- that already exists was created by the trusted operator-run bootstrap command before that command +-- marked addresses verified, so without this they would be locked out of their own installation by +-- an upgrade. +-- +-- Deliberately narrow. It marks exactly the identities the authorization tables already record as +-- currently-granted platform owners: an authority the deployment established through a command that +-- required database credentials, which is the same authority the bootstrap verification exception +-- rests on. Every other account is left untouched and must verify by email like any other. +-- +-- Data-only and idempotent. It adds no column, drops nothing, and re-running it changes no row that +-- is not already false. +UPDATE "ekklesia_core_user" +SET "email_verified" = true +WHERE "email_verified" = false + AND "id" IN ( + SELECT "user_id" + FROM "ekklesia_core_platform_owner" + WHERE "revoked_at" IS NULL + ); diff --git a/core/drizzle/meta/0007_snapshot.json b/core/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..c660700 --- /dev/null +++ b/core/drizzle/meta/0007_snapshot.json @@ -0,0 +1,1370 @@ +{ + "id": "d435c98f-67b6-4c37-89a9-820036fdf444", + "prevId": "2b1ddb4b-3185-417b-9920-100e35fedb36", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ekklesia_core_account": { + "name": "ekklesia_core_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ekklesia_core_account_user_id_idx": { + "name": "ekklesia_core_account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_account_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_account_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_account", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ekklesia_core_session": { + "name": "ekklesia_core_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ekklesia_core_session_user_id_idx": { + "name": "ekklesia_core_session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_session_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_session_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_session", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ekklesia_core_session_token_unique": { + "name": "ekklesia_core_session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ekklesia_core_user": { + "name": "ekklesia_core_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ekklesia_core_user_email_unique": { + "name": "ekklesia_core_user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ekklesia_core_verification": { + "name": "ekklesia_core_verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ekklesia_core_verification_identifier_idx": { + "name": "ekklesia_core_verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ekklesia_core_authorization_audit": { + "name": "ekklesia_core_authorization_audit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject_user_id": { + "name": "subject_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ekklesia_core_authorization_audit_subject_idx": { + "name": "ekklesia_core_authorization_audit_subject_idx", + "columns": [ + { + "expression": "subject_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_authorization_audit_action_check": { + "name": "ekklesia_core_authorization_audit_action_check", + "value": "\"ekklesia_core_authorization_audit\".\"action\" in ('grant.create', 'grant.revoke', 'platform_owner.grant', 'platform_owner.revoke')" + }, + "ekklesia_core_authorization_audit_actor_type_check": { + "name": "ekklesia_core_authorization_audit_actor_type_check", + "value": "\"ekklesia_core_authorization_audit\".\"actor_type\" in ('user', 'system')" + }, + "ekklesia_core_authorization_audit_scope_check": { + "name": "ekklesia_core_authorization_audit_scope_check", + "value": "\"ekklesia_core_authorization_audit\".\"scope\" is null or length(\"ekklesia_core_authorization_audit\".\"scope\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_grant": { + "name": "ekklesia_core_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_ref": { + "name": "source_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by_actor_type": { + "name": "created_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_actor_id": { + "name": "created_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by_actor_type": { + "name": "revoked_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_by_actor_id": { + "name": "revoked_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ekklesia_core_grant_user_permission_idx": { + "name": "ekklesia_core_grant_user_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_grant_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_grant", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_grant_source_kind_check": { + "name": "ekklesia_core_grant_source_kind_check", + "value": "\"ekklesia_core_grant\".\"source_kind\" in ('explicit', 'relationship')" + }, + "ekklesia_core_grant_created_actor_type_check": { + "name": "ekklesia_core_grant_created_actor_type_check", + "value": "\"ekklesia_core_grant\".\"created_by_actor_type\" in ('user', 'system')" + }, + "ekklesia_core_grant_revoked_actor_type_check": { + "name": "ekklesia_core_grant_revoked_actor_type_check", + "value": "\"ekklesia_core_grant\".\"revoked_by_actor_type\" is null or \"ekklesia_core_grant\".\"revoked_by_actor_type\" in ('user', 'system')" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_platform_owner": { + "name": "ekklesia_core_platform_owner", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "granted_by_actor_type": { + "name": "granted_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_by_actor_id": { + "name": "granted_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by_actor_type": { + "name": "revoked_by_actor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_by_actor_id": { + "name": "revoked_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_platform_owner_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_platform_owner", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_platform_owner_granted_actor_type_check": { + "name": "ekklesia_core_platform_owner_granted_actor_type_check", + "value": "\"ekklesia_core_platform_owner\".\"granted_by_actor_type\" in ('user', 'system')" + }, + "ekklesia_core_platform_owner_revoked_actor_type_check": { + "name": "ekklesia_core_platform_owner_revoked_actor_type_check", + "value": "\"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" is null or \"ekklesia_core_platform_owner\".\"revoked_by_actor_type\" in ('user', 'system')" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_platform_owner_bootstrap": { + "name": "ekklesia_core_platform_owner_bootstrap", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "consumed_for_user_id": { + "name": "consumed_for_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_platform_owner_bootstrap_singleton_check": { + "name": "ekklesia_core_platform_owner_bootstrap_singleton_check", + "value": "\"ekklesia_core_platform_owner_bootstrap\".\"id\" = 'singleton'" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_dashboard_group": { + "name": "ekklesia_core_dashboard_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ekklesia_core_dashboard_group_order_idx": { + "name": "ekklesia_core_dashboard_group_order_idx", + "columns": [ + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_dashboard_group_id_check": { + "name": "ekklesia_core_dashboard_group_id_check", + "value": "length(\"ekklesia_core_dashboard_group\".\"id\") > 0" + }, + "ekklesia_core_dashboard_group_label_check": { + "name": "ekklesia_core_dashboard_group_label_check", + "value": "length(btrim(\"ekklesia_core_dashboard_group\".\"label\")) > 0" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_dashboard_placement": { + "name": "ekklesia_core_dashboard_placement", + "schema": "", + "columns": { + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ekklesia_core_dashboard_placement_group_idx": { + "name": "ekklesia_core_dashboard_placement_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk": { + "name": "ekklesia_core_dashboard_placement_group_id_ekklesia_core_dashboard_group_id_fk", + "tableFrom": "ekklesia_core_dashboard_placement", + "tableTo": "ekklesia_core_dashboard_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_dashboard_placement_module_check": { + "name": "ekklesia_core_dashboard_placement_module_check", + "value": "length(\"ekklesia_core_dashboard_placement\".\"module_id\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_module_state": { + "name": "ekklesia_core_module_state", + "schema": "", + "columns": { + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_module_state_module_check": { + "name": "ekklesia_core_module_state_module_check", + "value": "length(\"ekklesia_core_module_state\".\"module_id\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_module_configuration": { + "name": "ekklesia_core_module_configuration", + "schema": "", + "columns": { + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ekklesia_core_module_configuration_module_idx": { + "name": "ekklesia_core_module_configuration_module_idx", + "columns": [ + { + "expression": "module_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ekklesia_core_module_configuration_module_id_key_pk": { + "name": "ekklesia_core_module_configuration_module_id_key_pk", + "columns": ["module_id", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_module_configuration_module_check": { + "name": "ekklesia_core_module_configuration_module_check", + "value": "length(\"ekklesia_core_module_configuration\".\"module_id\") > 0" + }, + "ekklesia_core_module_configuration_key_check": { + "name": "ekklesia_core_module_configuration_key_check", + "value": "length(\"ekklesia_core_module_configuration\".\"key\") > 0" + }, + "ekklesia_core_module_configuration_value_check": { + "name": "ekklesia_core_module_configuration_value_check", + "value": "length(\"ekklesia_core_module_configuration\".\"value\") <= 2048" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_device": { + "name": "ekklesia_core_device", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_hash": { + "name": "credential_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by_user_id": { + "name": "revoked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ekklesia_core_device_credential_hash_idx": { + "name": "ekklesia_core_device_credential_hash_idx", + "columns": [ + { + "expression": "credential_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ekklesia_core_device_revoked_idx": { + "name": "ekklesia_core_device_revoked_idx", + "columns": [ + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_device_created_by_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_device_created_by_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_device", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["created_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ekklesia_core_device_revoked_by_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_device_revoked_by_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_device", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["revoked_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_device_name_check": { + "name": "ekklesia_core_device_name_check", + "value": "length(\"ekklesia_core_device\".\"name\") between 1 and 128" + }, + "ekklesia_core_device_credential_hash_check": { + "name": "ekklesia_core_device_credential_hash_check", + "value": "length(\"ekklesia_core_device\".\"credential_hash\") = 64" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_device_pairing_request": { + "name": "ekklesia_core_device_pairing_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "claim_secret_hash": { + "name": "claim_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "module_id": { + "name": "module_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "approved_by_user_id": { + "name": "approved_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ekklesia_core_device_pairing_code_idx": { + "name": "ekklesia_core_device_pairing_code_idx", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"ekklesia_core_device_pairing_request\".\"state\" <> 'consumed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "ekklesia_core_device_pairing_claim_idx": { + "name": "ekklesia_core_device_pairing_claim_idx", + "columns": [ + { + "expression": "claim_secret_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_device_pairing_request_approved_by_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_device_pairing_request_approved_by_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_device_pairing_request", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["approved_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ekklesia_core_device_pairing_request_device_id_ekklesia_core_device_id_fk": { + "name": "ekklesia_core_device_pairing_request_device_id_ekklesia_core_device_id_fk", + "tableFrom": "ekklesia_core_device_pairing_request", + "tableTo": "ekklesia_core_device", + "columnsFrom": ["device_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_device_pairing_code_check": { + "name": "ekklesia_core_device_pairing_code_check", + "value": "\"ekklesia_core_device_pairing_request\".\"code\" ~ '^[0-9]{6}$'" + }, + "ekklesia_core_device_pairing_claim_hash_check": { + "name": "ekklesia_core_device_pairing_claim_hash_check", + "value": "length(\"ekklesia_core_device_pairing_request\".\"claim_secret_hash\") = 64" + }, + "ekklesia_core_device_pairing_state_check": { + "name": "ekklesia_core_device_pairing_state_check", + "value": "\"ekklesia_core_device_pairing_request\".\"state\" in ('pending', 'approved', 'consumed')" + } + }, + "isRLSEnabled": false + }, + "public.ekklesia_core_device_permission": { + "name": "ekklesia_core_device_permission", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "permission_id": { + "name": "permission_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "granted_by_user_id": { + "name": "granted_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "ekklesia_core_device_permission_lookup_idx": { + "name": "ekklesia_core_device_permission_lookup_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ekklesia_core_device_permission_device_id_ekklesia_core_device_id_fk": { + "name": "ekklesia_core_device_permission_device_id_ekklesia_core_device_id_fk", + "tableFrom": "ekklesia_core_device_permission", + "tableTo": "ekklesia_core_device", + "columnsFrom": ["device_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ekklesia_core_device_permission_granted_by_user_id_ekklesia_core_user_id_fk": { + "name": "ekklesia_core_device_permission_granted_by_user_id_ekklesia_core_user_id_fk", + "tableFrom": "ekklesia_core_device_permission", + "tableTo": "ekklesia_core_user", + "columnsFrom": ["granted_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ekklesia_core_device_permission_id_check": { + "name": "ekklesia_core_device_permission_id_check", + "value": "length(\"ekklesia_core_device_permission\".\"permission_id\") between 1 and 128" + } + }, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/core/drizzle/meta/_ekklesia.json b/core/drizzle/meta/_ekklesia.json index 7c4486b..792ef37 100644 --- a/core/drizzle/meta/_ekklesia.json +++ b/core/drizzle/meta/_ekklesia.json @@ -43,6 +43,12 @@ "destructive": { "mode": "none" } + }, + { + "tag": "0007_verified_bootstrap_owners", + "destructive": { + "mode": "none" + } } ] } diff --git a/core/drizzle/meta/_journal.json b/core/drizzle/meta/_journal.json index 55e43d5..ca73164 100644 --- a/core/drizzle/meta/_journal.json +++ b/core/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1787543773057, "tag": "0006_device_identity", "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1787543773058, + "tag": "0007_verified_bootstrap_owners", + "breakpoints": true } ] } diff --git a/core/src/authorization/initial-owner.ts b/core/src/authorization/initial-owner.ts index 3e4472f..9cfe0d4 100644 --- a/core/src/authorization/initial-owner.ts +++ b/core/src/authorization/initial-owner.ts @@ -14,6 +14,7 @@ export type InitialPlatformOwnerErrorCode = | "INITIAL_OWNER_EMAIL_INVALID" | "INITIAL_OWNER_IDENTITY_NOT_CREATED" | "INITIAL_OWNER_IDENTITY_UNUSABLE" + | "INITIAL_OWNER_IDENTITY_NOT_VERIFIED" | "INITIAL_OWNER_OWNERSHIP_GRANT_FAILED" | "INITIAL_OWNER_PASSWORD_TOO_LONG" | "INITIAL_OWNER_PASSWORD_TOO_SHORT" @@ -28,6 +29,8 @@ const initialOwnerMessages: Readonly 0, storedEmail: found.email, userId: found.id }; } +/** + * Marks one identity's address verified. + * + * A direct column update rather than an identity-system call, because the pinned release exposes no + * supported way to mark an address verified other than consuming a token it emailed — which is the + * one thing a pre-mail installation cannot do. The column is Better Auth's own `emailVerified`, the + * exact value its `/verify-email` endpoint writes, so the resulting row is indistinguishable from + * one that verified normally; nothing here invents a parallel notion of verification. + */ +async function markIdentityEmailVerified( + db: IdentityDrizzleDatabase, + userId: string, +): Promise { + await db + .update(ekklesiaCoreUser) + .set({ emailVerified: true }) + .where(eq(ekklesiaCoreUser.id, userId)); +} + function translateIdentityCreationError(error: unknown): InitialPlatformOwnerError { if (error instanceof BootstrapIdentityError) { if (error.code === "BOOTSTRAP_IDENTITY_PASSWORD_TOO_SHORT") { @@ -252,6 +274,29 @@ export async function createInitialPlatformOwner( throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_CREATED"); } + /** + * The trusted-bootstrap verification exception (ADR 0038). + * + * Every other human account must verify its address by email before it may hold a session. + * This one cannot: it is created on a freshly provisioned server by an operator who already + * holds the database credentials, at a moment when SMTP may not be configured, may not be + * reachable, or may not yet have a DNS record to send from. Requiring an email round trip here + * would mean a working installation whose administrator can never get in — the platform's + * outbound mail would have to be configured and correct before anyone could sign in to + * configure anything. + * + * The exception is narrow and its authority is the same authority that already makes this + * command safe: possession of the database, not possession of the mailbox. It marks exactly one + * address, exactly once per installation, and it is deliberately performed **before** ownership + * is granted — if it fails, the sentinel and the grant roll back together and re-running the + * command adopts the identity and finishes, rather than leaving an owner who cannot sign in. + */ + try { + await markIdentityEmailVerified(dependencies.identityDb, parsedUserId.data); + } catch { + throw new InitialPlatformOwnerError("INITIAL_OWNER_IDENTITY_NOT_VERIFIED"); + } + try { const grant = dependencies.grantOwnership ?? diff --git a/core/src/diagnostics/doctor.ts b/core/src/diagnostics/doctor.ts index e68b84c..0ac0467 100644 --- a/core/src/diagnostics/doctor.ts +++ b/core/src/diagnostics/doctor.ts @@ -116,6 +116,7 @@ export interface DoctorReport { export interface DoctorIdentityConfigurationOptions { readonly baseUrl?: string | undefined; + readonly dashboardOrigin?: string | undefined; readonly environment?: CreateIdentityConfigOptions["environment"]; readonly publicSignupEnabled?: string | undefined; readonly secret?: string | undefined; @@ -1193,7 +1194,7 @@ export async function diagnoseEkklesia( "skipped", "Outbound email not configured", [ - "No EKKLESIA_SMTP_HOST is set. This is a complete deployment: nothing in the platform sends email yet.", + "No EKKLESIA_SMTP_HOST is set. The platform runs and the bootstrap owner can sign in, but email verification and account recovery cannot deliver.", ], ); } else { diff --git a/core/src/host/configuration.ts b/core/src/host/configuration.ts index 6b07c0e..5e7a8ae 100644 --- a/core/src/host/configuration.ts +++ b/core/src/host/configuration.ts @@ -26,6 +26,9 @@ export type HostEnvironmentName = "development" | "production"; */ export interface HostIdentityEnvironmentInput { readonly baseUrl: string | undefined; + /** Where an emailed verification or reset link sends a browser. Unset means the Dashboard is + * served from `baseUrl`, which is the ordinary same-origin production shape (ADR 0038). */ + readonly dashboardOrigin: string | undefined; readonly publicSignupEnabled: string | undefined; readonly secret: string | undefined; readonly trustedOrigins: readonly string[] | undefined; @@ -118,6 +121,7 @@ export function loadHostConfiguration(environment: HostEnvironment): HostConfigu environment: environment.NODE_ENV === "development" ? "development" : "production", identity: { baseUrl: environment.EKKLESIA_IDENTITY_BASE_URL, + dashboardOrigin: environment.EKKLESIA_DASHBOARD_ORIGIN, publicSignupEnabled: environment.EKKLESIA_PUBLIC_SIGNUP_ENABLED, secret: environment.EKKLESIA_IDENTITY_SECRET, trustedOrigins: splitList(environment.EKKLESIA_IDENTITY_TRUSTED_ORIGINS), diff --git a/core/src/identity/config.ts b/core/src/identity/config.ts index 352dc51..3faeb9c 100644 --- a/core/src/identity/config.ts +++ b/core/src/identity/config.ts @@ -5,6 +5,10 @@ export type IdentityConfigErrorCode = | "BASE_URL_INVALID" | "BASE_URL_LOOPBACK_IN_PRODUCTION" | "BASE_URL_MISSING" + | "DASHBOARD_ORIGIN_INSECURE_IN_PRODUCTION" + | "DASHBOARD_ORIGIN_INVALID" + | "DASHBOARD_ORIGIN_LOOPBACK_IN_PRODUCTION" + | "DASHBOARD_ORIGIN_UNTRUSTED" | "PUBLIC_SIGNUP_ENABLED_INVALID" | "PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION" | "SECRET_LOW_ENTROPY" @@ -127,6 +131,13 @@ const errorMessages: Readonly> = Object. BASE_URL_LOOPBACK_IN_PRODUCTION: "The identity base URL must not resolve to a loopback host in production.", BASE_URL_MISSING: "The identity base URL is required.", + DASHBOARD_ORIGIN_INSECURE_IN_PRODUCTION: "The Dashboard origin must use https in production.", + DASHBOARD_ORIGIN_INVALID: + "The Dashboard origin must be an absolute http or https origin with no path, query, or fragment.", + DASHBOARD_ORIGIN_LOOPBACK_IN_PRODUCTION: + "The Dashboard origin must not resolve to a loopback host in production.", + DASHBOARD_ORIGIN_UNTRUSTED: + "The Dashboard origin must also be listed as a trusted origin, or emailed links will be refused.", PUBLIC_SIGNUP_ENABLED_INVALID: 'Public signup enablement must be an explicit boolean or the exact string "true"/"false".', PUBLIC_SIGNUP_UNSUPPORTED_IN_PRODUCTION: @@ -163,6 +174,21 @@ export class IdentityConfigError extends Error { export interface IdentityConfig { readonly baseUrl: string; + /** + * The browser origin an emailed verification or password-reset link sends a person to. + * + * Defaults to `baseUrl`, which is the ordinary production shape: the Dashboard bundle and the API + * are served from one origin. It exists as its own value because they are not the same *concept* — + * `baseUrl` is where the `/api/auth` transport lives, this is where a human's browser lands — and + * a deployment may separate them (an API at `https://api.example.org`, the Dashboard at + * `https://dash.example.org`). It is deliberately **not** the public church website origin: this + * is the authenticated Dashboard host and nothing else. + * + * Validated to be one of `trustedOrigins` (or `baseUrl`, which Better Auth trusts implicitly), so + * a misconfiguration fails at startup rather than producing emailed links every recipient is + * refused at. + */ + readonly dashboardOrigin: string; /** Whether `POST /api/auth/sign-up/email` accepts new accounts. Defaults to `false` (disabled) — * public signup never becomes reachable merely because this input was omitted, misspelled, or * loosely parsed. Development and test composition may enable it explicitly; production @@ -176,6 +202,8 @@ export interface IdentityConfig { export interface IdentityConfigInput { readonly baseUrl?: unknown; + /** Optional. Absent or empty means the Dashboard is served from `baseUrl`. */ + readonly dashboardOrigin?: unknown; readonly publicSignupEnabled?: unknown; readonly secret?: unknown; readonly trustedOrigins?: unknown; @@ -261,6 +289,45 @@ function validateBaseUrl(baseUrl: unknown, environment: IdentityEnvironment): st return url.origin; } +/** + * Resolves the origin an emailed link points a browser at. + * + * Unset falls back to the identity base URL — the same-origin production shape — rather than + * failing, because a deployment that has not separated its Dashboard from its API has nothing to + * configure. When it *is* set it is held to exactly the base URL's rules and then to one more: it + * must be an origin Better Auth already trusts, because Better Auth validates the emailed callback + * against `trustedOrigins` before redirecting. Checking it here converts a broken link every + * recipient would hit into a startup failure the operator sees once. + */ +function validateDashboardOrigin( + dashboardOrigin: unknown, + baseUrl: string, + trustedOrigins: readonly string[], + environment: IdentityEnvironment, +): string { + if (dashboardOrigin === undefined || dashboardOrigin === null || dashboardOrigin === "") { + return baseUrl; + } + const url = parseCanonicalHttpOrigin(dashboardOrigin); + if (!url) { + throw new IdentityConfigError("DASHBOARD_ORIGIN_INVALID"); + } + if (environment === "production") { + if (url.protocol !== "https:") { + throw new IdentityConfigError("DASHBOARD_ORIGIN_INSECURE_IN_PRODUCTION"); + } + if (isLoopbackOrUnspecifiedHostname(url.hostname)) { + throw new IdentityConfigError("DASHBOARD_ORIGIN_LOOPBACK_IN_PRODUCTION"); + } + } + // `baseUrl` is accepted without appearing in `trustedOrigins` because Better Auth itself adds the + // base URL's origin to its trusted set (`getTrustedOrigins`, pinned release). + if (url.origin !== baseUrl && !trustedOrigins.includes(url.origin)) { + throw new IdentityConfigError("DASHBOARD_ORIGIN_UNTRUSTED"); + } + return url.origin; +} + function validateTrustedOrigin(origin: unknown, environment: IdentityEnvironment): string { if (typeof origin !== "string" || origin.length === 0) { throw new IdentityConfigError("TRUSTED_ORIGIN_INVALID"); @@ -355,7 +422,19 @@ export function createIdentityConfig( const secret = validateSecret(input.secret); const baseUrl = validateBaseUrl(input.baseUrl, environment); const trustedOrigins = validateTrustedOrigins(input.trustedOrigins, environment); + const dashboardOrigin = validateDashboardOrigin( + input.dashboardOrigin, + baseUrl, + trustedOrigins, + environment, + ); const publicSignupEnabled = validatePublicSignupEnabled(input.publicSignupEnabled, environment); - return Object.freeze({ baseUrl, publicSignupEnabled, secret, trustedOrigins }); + return Object.freeze({ + baseUrl, + dashboardOrigin, + publicSignupEnabled, + secret, + trustedOrigins, + }); } diff --git a/core/src/identity/index.ts b/core/src/identity/index.ts index 458c7dd..a95b7a3 100644 --- a/core/src/identity/index.ts +++ b/core/src/identity/index.ts @@ -2,4 +2,5 @@ export * from "./bootstrap-identity.js"; export * from "./client-address.js"; export * from "./config.js"; export * from "./database.js"; +export * from "./notifications.js"; export * from "./runtime.js"; diff --git a/core/src/identity/notifications.ts b/core/src/identity/notifications.ts new file mode 100644 index 0000000..f9e2d92 --- /dev/null +++ b/core/src/identity/notifications.ts @@ -0,0 +1,291 @@ +import { MailError, type MailProvider, validateMailMessage } from "../mail/index.js"; + +/** + * The identity feature's outbound messages, and the seam between Better Auth's callbacks and the + * host's mail transport (ADR 0038). + * + * ## What this owns and what it deliberately does not + * + * It owns the recipient, the subject, and the two bodies. It does **not** know the SMTP host, the + * port, the TLS mode, a credential, or the sender: those are host configuration held by the mail + * provider, and there is no field on the message contract through which a caller could choose any + * of them (ADR 0037). Swapping SMTP for a provider HTTP API would not change a line of this file. + * + * It also does not own tokens. Better Auth mints, persists (for reset), expires, and consumes them; + * this receives one already-minted token and puts it in a link. There is no Ekklesia token table, + * no Ekklesia token hashing, and no Ekklesia expiry clock — a second implementation of any of those + * would be a second thing to get wrong. + * + * ## Why the link is rebuilt rather than used as given + * + * Better Auth hands these callbacks a prebuilt `url`, and that URL embeds whatever `callbackURL` or + * `redirectTo` the *requesting client* supplied. Better Auth does validate those against its trusted + * origins, and that validation is sound — but an emailed link is the one artifact a person is most + * likely to click without looking, so its destination is derived here from validated configuration + * alone and from nothing a request could influence. The token is the only value taken from Better + * Auth. No request parameter can steer where a recipient lands, independently of whether the origin + * check behaves as expected. + */ + +/** What could not be delivered, for an operator — never who it was for, and never the link. */ +export type IdentityNotificationKind = "password-reset" | "email-verification"; + +export interface IdentityNotificationFailure { + readonly kind: IdentityNotificationKind; + /** A sanitized `MailError` code, or `MAIL_SEND_FAILED` for anything unrecognized. */ + readonly reason: string; +} + +/** + * Where a delivery failure is reported. + * + * It exists because these callbacks must **never throw**. Better Auth's own + * `/send-verification-email` rethrows whatever the callback threw, and a thrown error there turns + * "this address has an unverified account" into a distinguishable response — an account-enumeration + * oracle that appears the moment SMTP breaks. So failure is swallowed at this boundary and surfaced + * out of band instead, where no HTTP response shape depends on it. + * + * The default implementation writes one fixed line per failure. It carries the flow and a sanitized + * code and nothing else: no address, no token, no URL, no provider text. + */ +export type IdentityNotificationReporter = (failure: IdentityNotificationFailure) => void; + +export interface IdentityNotificationRecipient { + readonly email: string; + readonly name?: string; +} + +export interface CreateIdentityNotificationsOptions { + /** + * The host mail provider, or `undefined` when this installation has none. + * + * `undefined` is a complete deployment (ADR 0037): the platform still runs, the initial owner + * still signs in, and only verification and recovery are unavailable — reported as + * `MAIL_NOT_CONFIGURED` through the reporter, never as a different HTTP response. + */ + readonly mail: MailProvider | undefined; + /** Where the `/api/auth` transport lives — the origin Better Auth's own endpoints are on. */ + readonly authBaseUrl: string; + /** Where a human's browser lands. Validated to be a trusted origin (`./config.js`). */ + readonly dashboardOrigin: string; + /** Defaults to a single fixed sanitized console line. */ + readonly report?: IdentityNotificationReporter; +} + +export interface IdentityNotifications { + /** Shaped for Better Auth's `emailVerification.sendVerificationEmail`. Never throws. */ + readonly sendVerificationEmail: (data: { + readonly user: IdentityNotificationRecipient; + readonly token: string; + }) => Promise; + /** Shaped for Better Auth's `emailAndPassword.sendResetPassword`. Never throws. */ + readonly sendResetPassword: (data: { + readonly user: IdentityNotificationRecipient; + readonly token: string; + }) => Promise; +} + +/** + * The two query values the Dashboard bundle reads to decide which host-owned auth screen to show. + * + * They are query parameters on the Dashboard's root path rather than distinct paths deliberately: + * the shell is a static single-page bundle, and a deep path would require every deployment's static + * host to rewrite unknown paths to `index.html` before an emailed link worked at all. A query + * parameter works on any static host, including the repository's own development server. + */ +export const DASHBOARD_AUTH_LANDING_PARAMETER = "ekklesia_auth"; +export const DASHBOARD_VERIFY_EMAIL_LANDING = "verify-email"; +export const DASHBOARD_RESET_PASSWORD_LANDING = "reset-password"; + +function landingUrl(dashboardOrigin: string, landing: string): string { + const url = new URL(dashboardOrigin); + url.searchParams.set(DASHBOARD_AUTH_LANDING_PARAMETER, landing); + return url.href; +} + +/** + * Better Auth's own verification URL, rebuilt from configuration. + * + * The shape is the pinned release's protocol exactly — `GET {authBase}/api/auth/verify-email? + * token=...&callbackURL=...` — so Better Auth remains the only thing that inspects, expires, or acts + * on the token. Only the destination is ours. + */ +function verificationUrl(options: CreateIdentityNotificationsOptions, token: string): string { + const callback = landingUrl(options.dashboardOrigin, DASHBOARD_VERIFY_EMAIL_LANDING); + return `${options.authBaseUrl}/api/auth/verify-email?token=${encodeURIComponent( + token, + )}&callbackURL=${encodeURIComponent(callback)}`; +} + +/** Better Auth's reset callback, rebuilt the same way. It validates the token, then redirects the + * browser to the Dashboard landing with the token attached. */ +function resetUrl(options: CreateIdentityNotificationsOptions, token: string): string { + const callback = landingUrl(options.dashboardOrigin, DASHBOARD_RESET_PASSWORD_LANDING); + return `${options.authBaseUrl}/api/auth/reset-password/${encodeURIComponent( + token, + )}?callbackURL=${encodeURIComponent(callback)}`; +} + +/** Escapes the four characters that could otherwise close an element or attribute in the HTML body. + * Only the link and the greeting name are interpolated, and the name comes from the user record. */ +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +/** + * The same rule `validateMailMessage` applies, expressed the same way it expresses it: a code-point + * scan rather than a regular expression, because a regex over a control-character range is itself + * something a linter (rightly) objects to. + */ +function hasControlCharacter(value: string): boolean { + for (const character of value) { + const codePoint = character.codePointAt(0); + if (codePoint === undefined || codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) { + return true; + } + } + return false; +} + +/** + * A greeting that is safe to place in a message body. + * + * A stored display name is not trusted to be free of control characters — the message validator + * would reject the whole message for one stray carriage return, which would turn a cosmetic problem + * into an undeliverable password reset. So an unusable name simply falls back to no greeting. + */ +function greeting(name: string | undefined): string { + if (typeof name !== "string") { + return "Hello,"; + } + const trimmed = name.trim(); + if (trimmed.length === 0 || trimmed.length > 64 || hasControlCharacter(trimmed)) { + return "Hello,"; + } + return `Hello ${trimmed},`; +} + +const EXPIRY_SENTENCE = "This link expires in one hour."; +const UNEXPECTED_SENTENCE = + "If you did not expect this message, you can ignore it and nothing will change."; + +interface MessageBodies { + readonly subject: string; + readonly text: string; + readonly html: string; +} + +function verificationBodies(name: string | undefined, url: string): MessageBodies { + const lead = greeting(name); + return { + subject: "Verify your Ekklesia email address", + html: [ + `

${escapeHtml(lead)}

`, + "

Confirm this email address so it can be used to sign in to Ekklesia.

", + `

Verify this email address

`, + `

${EXPIRY_SENTENCE} ${escapeHtml(UNEXPECTED_SENTENCE)}

`, + ].join("\n"), + text: [ + lead, + "", + "Confirm this email address so it can be used to sign in to Ekklesia:", + "", + url, + "", + EXPIRY_SENTENCE, + UNEXPECTED_SENTENCE, + ].join("\n"), + }; +} + +function resetBodies(name: string | undefined, url: string): MessageBodies { + const lead = greeting(name); + const closing = `${UNEXPECTED_SENTENCE} Your current password stays in place until a new one is chosen.`; + return { + subject: "Reset your Ekklesia password", + html: [ + `

${escapeHtml(lead)}

`, + "

Someone asked to reset the password for this Ekklesia account.

", + `

Choose a new password

`, + `

${EXPIRY_SENTENCE} ${escapeHtml(closing)}

`, + ].join("\n"), + text: [ + lead, + "", + "Someone asked to reset the password for this Ekklesia account. Choose a new one here:", + "", + url, + "", + EXPIRY_SENTENCE, + closing, + ].join("\n"), + }; +} + +function defaultReporter(failure: IdentityNotificationFailure): void { + // One fixed line. The flow and a sanitized code are exactly what an operator needs in order to go + // look at `pnpm doctor`; the address, the token, and the link are exactly what must never be here. + console.error(`[identity] ${failure.kind} email was not delivered: ${failure.reason}`); +} + +function failureReason(error: unknown): string { + return error instanceof MailError ? error.code : "MAIL_SEND_FAILED"; +} + +/** + * Builds the two Better Auth callbacks. + * + * Neither rejects. A delivery failure is reported through `report` and the callback resolves, so the + * HTTP response an unauthenticated caller sees is identical whether the address exists, whether it + * was already verified, and whether the relay was reachable. + */ +export function createIdentityNotifications( + options: CreateIdentityNotificationsOptions, +): IdentityNotifications { + const report = options.report ?? defaultReporter; + + const deliver = async ( + kind: IdentityNotificationKind, + recipient: IdentityNotificationRecipient, + bodies: MessageBodies, + ): Promise => { + if (!options.mail) { + report({ kind, reason: "MAIL_NOT_CONFIGURED" }); + return; + } + try { + await options.mail.send( + validateMailMessage({ + html: bodies.html, + subject: bodies.subject, + text: bodies.text, + to: recipient.email, + }), + ); + } catch (error) { + report({ kind, reason: failureReason(error) }); + } + }; + + const notifications: IdentityNotifications = { + sendResetPassword: async (data) => { + await deliver( + "password-reset", + data.user, + resetBodies(data.user.name, resetUrl(options, data.token)), + ); + }, + sendVerificationEmail: async (data) => { + await deliver( + "email-verification", + data.user, + verificationBodies(data.user.name, verificationUrl(options, data.token)), + ); + }, + }; + return Object.freeze(notifications); +} diff --git a/core/src/identity/runtime.ts b/core/src/identity/runtime.ts index 5f49834..92b0d7c 100644 --- a/core/src/identity/runtime.ts +++ b/core/src/identity/runtime.ts @@ -9,6 +9,7 @@ import { getCookies } from "better-auth/cookies"; import { sql } from "drizzle-orm"; import type { IdentityConfig } from "./config.js"; import type { IdentityDrizzleDatabase } from "./database.js"; +import type { IdentityNotifications } from "./notifications.js"; import { ekklesiaCoreSession, identityAdapterSchema } from "./schema.js"; export type IdentityRuntimeErrorCode = @@ -46,6 +47,16 @@ export const TRUSTED_CLIENT_ADDRESS_HEADER = "x-ekklesia-client-address"; */ const sanitizedBetterAuthLogMessage = "[identity] Better Auth reported an error."; +/** + * One hour for both, matching the pinned release's own defaults but stated rather than inherited. + * + * Short enough that a link sitting in a forwarded or archived mailbox stops working, long enough + * that someone who reads their mail after lunch is not sent back to the start. The message bodies + * state the same hour, so the two can be checked against each other. + */ +const EMAIL_VERIFICATION_TOKEN_TTL_SECONDS = 3600; +const RESET_PASSWORD_TOKEN_TTL_SECONDS = 3600; + /** * Explicitly replaces Better Auth's default logger so its own message text and arguments — which * may include a raw exception, SQL, a connection string, a host/port, a filesystem path, a @@ -82,10 +93,29 @@ 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. + * How verification and password-reset messages are delivered. + * + * Injected rather than constructed here, for the same reason the database is: this file composes + * Better Auth and must not decide what a message says or how it travels. Omitting it is a + * complete, valid deployment — email verification and password recovery are then configured + * exactly as they otherwise are, and each attempt reports `MAIL_NOT_CONFIGURED` out of band while + * every HTTP response keeps its usual shape (ADR 0038). + * + * Note what omitting it does **not** do: it does not relax `requireEmailVerification`. An + * installation with no mail cannot verify a new account, which is why the initial platform owner + * is created already verified by a trusted operator command rather than by an email round trip. + */ + readonly notifications?: IdentityNotifications; + /** + * A narrow, explicit override for the rate-limit windows on the four paths Better Auth treats + * more strictly than its global default, 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 built-in defaults apply unmodified — 3 requests / 10 seconds on + * sign-in and sign-up, and 3 requests / 60 seconds on password-reset requests and verification + * resends, which are the two paths an attacker could otherwise use to send mail repeatedly. + * + * That limiter is per-process and in-memory (ADR 0038): it is a real abuse control for a single + * deployment and is deliberately **not** claimed to be a distributed one. */ readonly rateLimitOverride?: IdentityRateLimitOverride; } @@ -264,9 +294,81 @@ export function createIdentityRuntime( enabled: true, // Pinned explicitly rather than left on Better Auth's fallback default (`|| 8` / `|| 128` // in the pinned release): an implicit default can change silently on a future Better Auth - // upgrade, and Ekklesia's own tests assert this exact 8–128 boundary. + // upgrade, and Ekklesia's own tests assert this exact 8–128 boundary. The reset endpoint + // enforces the identical pair from the same configuration, so there is deliberately no + // separate reset-time password policy. maxPasswordLength: 128, minPasswordLength: 8, + /** + * A person may not hold a session until their address is verified (ADR 0038). + * + * In the pinned release this is enforced *after* the password is checked and *before* any + * session is created: the attempt fails with `403 EMAIL_NOT_VERIFIED` and no cookie is set. + * That is the whole reason no "unverified principal" exists anywhere in this platform — a + * correct password on an unverified account produces no session at all, so there is no + * half-authenticated state for the Dashboard to misread. `/api/v1/principal` continues to + * describe only anonymous, authenticated human, and device. + */ + requireEmailVerification: true, + /** Pinned rather than left on the release's `|| 3600` fallback, for the same reason the + * password bounds are pinned. */ + resetPasswordTokenExpiresIn: RESET_PASSWORD_TOKEN_TTL_SECONDS, + /** + * Every existing session for that account is deleted when its password is reset. + * + * Password reset is the recovery path someone uses precisely when they suspect their + * credential is compromised, and leaving the attacker's session alive would make the recovery + * cosmetic. This is Better Auth's own supported option (`deleteUserSessions` in the pinned + * release), not a Core reach into the session table. + * + * It deletes *all* of that human's sessions, including the browser that performed the reset — + * so the completion state returns to the sign-in form rather than pretending to be signed in. + * Managed device sessions are a separate Ekklesia store keyed by device, hold no email and no + * password, and are entirely unaffected (ADR 0036). + */ + revokeSessionsOnPasswordReset: true, + ...(options.notifications + ? { sendResetPassword: options.notifications.sendResetPassword } + : {}), + }, + /** + * Verification is configured identically whether or not mail is deliverable, so the policy an + * installation enforces never depends on the state of its relay. + */ + emailVerification: { + /** + * No session is minted from following an emailed link. + * + * A verification URL travels through mail infrastructure and is routinely fetched by things + * that are not the recipient — link scanners, prefetchers, corporate mail gateways. Turning + * that GET into a logged-in browser session would hand a session to whoever, or whatever, + * dereferenced the link. Verification therefore only flips the flag, and the person signs in + * afterwards through the ordinary form. + */ + autoSignInAfterVerification: false, + /** Pinned rather than left on the release's `|| 3600` fallback. */ + expiresIn: EMAIL_VERIFICATION_TOKEN_TTL_SECONDS, + /** + * A sign-in attempt against an unverified account re-sends the verification message. + * + * This is the resend path that costs nothing to secure: reaching it requires the correct + * password, so it reveals nothing an attacker did not already know, and it means the common + * case — the first link expired — is handled by the thing the person was already trying to do. + * The standalone `/send-verification-email` endpoint remains available for the case where they + * would rather not retype a password, and is enumeration-safe on its own. + */ + sendOnSignIn: true, + /** + * Pinned rather than left on the release's "follow `requireEmailVerification`" fallback. + * + * It reaches nothing in production, where signup is refused outright — but stating it means a + * development or test deployment that does enable signup behaves the way this platform + * intends, rather than the way an inherited default happens to. + */ + sendOnSignUp: true, + ...(options.notifications + ? { sendVerificationEmail: options.notifications.sendVerificationEmail } + : {}), }, logger: { disabled: false, @@ -293,6 +395,18 @@ export function createIdentityRuntime( ...(rateLimitOverride ? { customRules: { + // The four paths Better Auth applies a stricter-than-global default to. Production + // composition never sets this override, so those defaults — 3 requests / 10 seconds + // on sign-in and sign-up, 3 requests / 60 seconds on the two paths that trigger an + // email — remain in force there, unmodified. + "/request-password-reset": { + max: rateLimitOverride.max ?? 100, + window: rateLimitOverride.window ?? 10, + }, + "/send-verification-email": { + max: rateLimitOverride.max ?? 100, + window: rateLimitOverride.window ?? 10, + }, "/sign-in/email": { max: rateLimitOverride.max ?? 100, window: rateLimitOverride.window ?? 10, diff --git a/core/tests/authorization-store.integration.ts b/core/tests/authorization-store.integration.ts index c4e4bc8..240b01c 100644 --- a/core/tests/authorization-store.integration.ts +++ b/core/tests/authorization-store.integration.ts @@ -177,6 +177,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0004_module_lifecycle_state", "core:0005_module_configuration", "core:0006_device_identity", + "core:0007_verified_bootstrap_owners", ]); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { diff --git a/core/tests/dashboard-store.integration.ts b/core/tests/dashboard-store.integration.ts index 9fd5e2a..393dc58 100644 --- a/core/tests/dashboard-store.integration.ts +++ b/core/tests/dashboard-store.integration.ts @@ -176,6 +176,7 @@ async function applyCoreMigrations(url: string): Promise { "core:0004_module_lifecycle_state", "core:0005_module_configuration", "core:0006_device_identity", + "core:0007_verified_bootstrap_owners", ]); await applyModuleMigrations({ aggregate, database: createPostgresMigrationDatabase(url) }); } finally { diff --git a/core/tests/doctor-report.test.ts b/core/tests/doctor-report.test.ts index 1e5f938..2f00dee 100644 --- a/core/tests/doctor-report.test.ts +++ b/core/tests/doctor-report.test.ts @@ -126,7 +126,7 @@ Object storage Outbound email ○ Outbound email not configured - No EKKLESIA_SMTP_HOST is set. This is a complete deployment: nothing in the platform sends email yet. + No EKKLESIA_SMTP_HOST is set. The platform runs and the bootstrap owner can sign in, but email verification and account recovery cannot deliver. Result: READY WITH WARNINGS`); }); diff --git a/core/tests/host-configuration-boundaries.test.ts b/core/tests/host-configuration-boundaries.test.ts index 99d9762..20c5326 100644 --- a/core/tests/host-configuration-boundaries.test.ts +++ b/core/tests/host-configuration-boundaries.test.ts @@ -62,6 +62,7 @@ const INTERPRETED_VARIABLES = Object.freeze([ "EKKLESIA_CLIENT_ADDRESS_HEADER", "EKKLESIA_CLIENT_ADDRESS_MODE", "EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES", + "EKKLESIA_DASHBOARD_ORIGIN", "EKKLESIA_DATABASE_CA", "EKKLESIA_DATABASE_TLS_MODE", "EKKLESIA_IDENTITY_BASE_URL", diff --git a/core/tests/identity-config.test.ts b/core/tests/identity-config.test.ts index 2c24a4e..6c0cc99 100644 --- a/core/tests/identity-config.test.ts +++ b/core/tests/identity-config.test.ts @@ -26,6 +26,9 @@ describe("Core identity configuration", () => { expect(config).toEqual({ baseUrl: validBaseUrl, + // Unset: the Dashboard is served from the identity base URL, which is the ordinary + // same-origin production shape (ADR 0038). + dashboardOrigin: validBaseUrl, publicSignupEnabled: false, secret: validSecret, trustedOrigins: validTrustedOrigins, @@ -419,3 +422,108 @@ describe("Core identity configuration", () => { } }); }); + +describe("Core identity Dashboard origin", () => { + /** + * Where an emailed verification or reset link sends a browser (ADR 0038). + * + * The rules exist because a link in an email is the one artifact nobody inspects before clicking: + * it must land on the deployment's own Dashboard, over https in production, at an origin Better + * Auth will actually accept as a redirect target — and a misconfiguration must fail at startup + * rather than produce a link every recipient is refused at. + */ + it("defaults to the identity base URL, the ordinary same-origin production shape", () => { + expect(createIdentityConfig(validInput()).dashboardOrigin).toBe(validBaseUrl); + expect(createIdentityConfig({ ...validInput(), dashboardOrigin: "" }).dashboardOrigin).toBe( + validBaseUrl, + ); + }); + + it("accepts a separate Dashboard origin that is also a trusted origin", () => { + const config = createIdentityConfig({ + ...validInput(), + dashboardOrigin: "https://dashboard.example.test", + }); + + expect(config.dashboardOrigin).toBe("https://dashboard.example.test"); + // Separate from the API origin, deliberately: the two are different concepts and a deployment + // may split them. + expect(config.dashboardOrigin).not.toBe(config.baseUrl); + }); + + it("refuses an origin Better Auth would not accept as a redirect target", () => { + // The failure this prevents is silent and total: Better Auth validates the emailed callback + // against its trusted origins, so an untrusted Dashboard origin breaks every link it sends. + try { + createIdentityConfig({ ...validInput(), dashboardOrigin: "https://elsewhere.example.test" }); + throw new Error("expected the untrusted Dashboard origin to be refused"); + } catch (error) { + expect(error).toMatchObject({ code: "DASHBOARD_ORIGIN_UNTRUSTED" }); + } + }); + + it("refuses anything that is not a canonical origin", () => { + for (const value of [ + "dashboard.example.test", + "https://dashboard.example.test/", + "https://dashboard.example.test/dashboard", + "https://user:pass@dashboard.example.test", + "https://dashboard.example.test?a=b", + "javascript:alert(1)", + "//dashboard.example.test", + " https://dashboard.example.test", + 42, + ]) { + try { + createIdentityConfig({ ...validInput(), dashboardOrigin: value }); + throw new Error(`expected ${String(value)} to be refused`); + } catch (error) { + expect(error).toMatchObject({ code: "DASHBOARD_ORIGIN_INVALID" }); + } + } + }); + + it("requires https and a non-loopback host in production, and relaxes both in development", () => { + try { + createIdentityConfig( + // Structural and policy validity are checked before membership, so a plaintext origin is + // reported as plaintext rather than as "untrusted" — which would send an operator looking + // in the wrong place. + { ...validInput(), dashboardOrigin: "http://dashboard.example.test" }, + { environment: "production" }, + ); + throw new Error("expected plaintext to be refused in production"); + } catch (error) { + expect(error).toMatchObject({ code: "DASHBOARD_ORIGIN_INSECURE_IN_PRODUCTION" }); + } + + try { + createIdentityConfig( + { ...validInput(), dashboardOrigin: "https://localhost" }, + { environment: "production" }, + ); + throw new Error("expected loopback to be refused in production"); + } catch (error) { + expect(error).toMatchObject({ code: "DASHBOARD_ORIGIN_LOOPBACK_IN_PRODUCTION" }); + } + + const development = createIdentityConfig( + { + ...developmentInput(), + dashboardOrigin: "http://localhost:5173", + trustedOrigins: ["http://localhost:3000", "http://localhost:5173"], + }, + { environment: "development" }, + ); + expect(development.dashboardOrigin).toBe("http://localhost:5173"); + }); + + it("never carries the offending origin in the thrown error", () => { + try { + createIdentityConfig({ ...validInput(), dashboardOrigin: "https://evil.example.test" }); + throw new Error("expected a refusal"); + } catch (error) { + expect(JSON.stringify({ message: (error as Error).message })).not.toContain("evil"); + } + }); +}); diff --git a/core/tests/identity-notifications.test.ts b/core/tests/identity-notifications.test.ts new file mode 100644 index 0000000..4837cdf --- /dev/null +++ b/core/tests/identity-notifications.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createIdentityNotifications, + type IdentityNotificationFailure, +} from "../src/identity/notifications.js"; +import { MailError, type MailProvider, type ValidatedMailMessage } from "../src/mail/index.js"; + +/** + * The identity feature's two outbound messages (ADR 0038). + * + * The properties that matter here are all negative ones: that a link is built from configuration + * rather than from anything a request supplied, that a token never reaches a log, that a delivery + * failure never becomes an observable difference between "this address has an account" and "it does + * not", and that identity code learns nothing about SMTP. + */ +const authBaseUrl = "https://api.example.test"; +const dashboardOrigin = "https://dashboard.example.test"; +const token = "verification-token-abc123"; + +interface Harness { + readonly sent: ValidatedMailMessage[]; + readonly failures: IdentityNotificationFailure[]; + readonly notifications: ReturnType; +} + +function harness( + options: { readonly mail?: MailProvider | undefined; readonly configured?: boolean } = {}, +): Harness { + const sent: ValidatedMailMessage[] = []; + const failures: IdentityNotificationFailure[] = []; + const provider: MailProvider = options.mail ?? { + close: async () => undefined, + send: async (message) => { + sent.push(message); + return { messageId: "provider-message-id" }; + }, + verify: async () => undefined, + }; + return { + failures, + notifications: createIdentityNotifications({ + authBaseUrl, + dashboardOrigin, + mail: options.configured === false ? undefined : provider, + report: (failure) => failures.push(failure), + }), + sent, + }; +} + +function only(sent: readonly ValidatedMailMessage[]): ValidatedMailMessage { + expect(sent).toHaveLength(1); + return sent[0] as ValidatedMailMessage; +} + +/** Both bodies, so an assertion can never pass because it only looked at the plaintext one. */ +function bodies(message: ValidatedMailMessage): string { + return `${message.text ?? ""}\n${message.html ?? ""}`; +} + +describe("identity verification email", () => { + it("sends one message to the account's own address, with both bodies", async () => { + const test = harness(); + + await test.notifications.sendVerificationEmail({ + token, + user: { email: "person@example.org", name: "Sam" }, + }); + + const message = only(test.sent); + expect(message.to).toEqual(["person@example.org"]); + expect(message.subject).toBe("Verify your Ekklesia email address"); + expect(message.text).toContain("Hello Sam,"); + expect(message.html).toContain("

Hello Sam,

"); + expect(bodies(message)).toContain("This link expires in one hour."); + expect(bodies(message)).toContain("you can ignore it"); + }); + + it("builds the link from configuration alone, on the Dashboard origin", async () => { + const test = harness(); + + await test.notifications.sendVerificationEmail({ token, user: { email: "p@example.org" } }); + + const text = only(test.sent).text ?? ""; + const link = text.split("\n").find((line) => line.startsWith("https://")) ?? ""; + const url = new URL(link); + // Better Auth's own endpoint, so Better Auth remains the only thing that acts on the token. + expect(url.origin).toBe(authBaseUrl); + expect(url.pathname).toBe("/api/auth/verify-email"); + expect(url.searchParams.get("token")).toBe(token); + // ...and the destination it will redirect to is the Dashboard, never the API origin. + const callback = new URL(url.searchParams.get("callbackURL") ?? ""); + expect(callback.origin).toBe(dashboardOrigin); + expect(callback.searchParams.get("ekklesia_auth")).toBe("verify-email"); + }); + + it("never names the sender, the relay, or any transport detail", async () => { + const test = harness(); + + await test.notifications.sendVerificationEmail({ token, user: { email: "p@example.org" } }); + + // The message contract has no `from` field at all, so identity code cannot choose a sender. + expect(Object.keys(only(test.sent))).not.toContain("from"); + const serialized = JSON.stringify(only(test.sent)).toLowerCase(); + for (const forbidden of ["smtp", "starttls", "password", "587", "465"]) { + expect(serialized).not.toContain(forbidden); + } + }); +}); + +describe("identity password-reset email", () => { + it("sends one message with a link on Better Auth's reset callback", async () => { + const test = harness(); + + await test.notifications.sendResetPassword({ + token: "reset-token-xyz", + user: { email: "person@example.org", name: "Sam" }, + }); + + const message = only(test.sent); + expect(message.to).toEqual(["person@example.org"]); + expect(message.subject).toBe("Reset your Ekklesia password"); + const link = (message.text ?? "").split("\n").find((line) => line.startsWith("https://")) ?? ""; + const url = new URL(link); + expect(url.origin).toBe(authBaseUrl); + expect(url.pathname).toBe("/api/auth/reset-password/reset-token-xyz"); + const callback = new URL(url.searchParams.get("callbackURL") ?? ""); + expect(callback.origin).toBe(dashboardOrigin); + expect(callback.searchParams.get("ekklesia_auth")).toBe("reset-password"); + }); + + it("carries no password, hash, or session material", async () => { + const test = harness(); + + await test.notifications.sendResetPassword({ + token: "reset-token-xyz", + user: { email: "person@example.org" }, + }); + + const serialized = bodies(only(test.sent)).toLowerCase(); + expect(serialized).not.toContain("hash"); + expect(serialized).not.toContain("session"); + // It does say the current password still works, which is the reassurance the flow needs. + expect(serialized).toContain("current password stays in place"); + }); +}); + +describe("identity notification failures", () => { + it("resolves rather than throwing when the relay fails, and reports a sanitized code", async () => { + const test = harness({ + mail: { + close: async () => undefined, + send: async () => { + throw new MailError("MAIL_PROVIDER_UNAVAILABLE"); + }, + verify: async () => undefined, + }, + }); + + // Resolving is the security property: Better Auth's own `/send-verification-email` rethrows + // whatever this throws, which would make "account exists" observable the moment SMTP broke. + await expect( + test.notifications.sendVerificationEmail({ token, user: { email: "p@example.org" } }), + ).resolves.toBeUndefined(); + expect(test.failures).toEqual([ + { kind: "email-verification", reason: "MAIL_PROVIDER_UNAVAILABLE" }, + ]); + }); + + it("collapses an unrecognized thrown value rather than forwarding it", async () => { + const test = harness({ + mail: { + close: async () => undefined, + send: async () => { + throw new Error("connect ECONNREFUSED 10.40.0.2:587 user=relay@example.org pass=hunter2"); + }, + verify: async () => undefined, + }, + }); + + await test.notifications.sendResetPassword({ token: "t", user: { email: "p@example.org" } }); + + expect(test.failures).toEqual([{ kind: "password-reset", reason: "MAIL_SEND_FAILED" }]); + const serialized = JSON.stringify(test.failures); + expect(serialized).not.toContain("hunter2"); + expect(serialized).not.toContain("10.40.0.2"); + }); + + it("reports MAIL_NOT_CONFIGURED, and still resolves, when the installation has no mail", async () => { + const test = harness({ configured: false }); + + await test.notifications.sendVerificationEmail({ token, user: { email: "p@example.org" } }); + await test.notifications.sendResetPassword({ token: "t", user: { email: "p@example.org" } }); + + expect(test.failures).toEqual([ + { kind: "email-verification", reason: "MAIL_NOT_CONFIGURED" }, + { kind: "password-reset", reason: "MAIL_NOT_CONFIGURED" }, + ]); + expect(test.sent).toHaveLength(0); + }); + + it("writes one fixed line by default, carrying neither the address nor the token", async () => { + const errors = vi.spyOn(console, "error").mockImplementation(() => undefined); + try { + const notifications = createIdentityNotifications({ + authBaseUrl, + dashboardOrigin, + mail: undefined, + }); + await notifications.sendResetPassword({ + token: "reset-token-xyz", + user: { email: "secret-person@example.org" }, + }); + + expect(errors).toHaveBeenCalledTimes(1); + const line = String(errors.mock.calls[0]?.[0]); + expect(line).toBe("[identity] password-reset email was not delivered: MAIL_NOT_CONFIGURED"); + expect(line).not.toContain("secret-person"); + expect(line).not.toContain("reset-token-xyz"); + } finally { + errors.mockRestore(); + } + }); +}); + +describe("identity notification message safety", () => { + it("escapes a display name rather than letting it close an element", async () => { + const test = harness(); + + await test.notifications.sendVerificationEmail({ + token, + user: { email: "p@example.org", name: 'Sam">' }, + }); + + const message = only(test.sent); + expect(message.html).not.toContain("