From ea28f8aa2e77647e34da83da85f6c5a55003dd53 Mon Sep 17 00:00:00 2001 From: ekklesia Date: Tue, 25 Aug 2026 07:21:17 -0500 Subject: [PATCH] feat: verify email addresses and recover forgotten passwords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ekklesia could authenticate a person and, since Pass 1, send a transactional email — but it had no way to establish that an address belonged to whoever claimed it, and no way back in for someone who forgot their password. A verified address is now a precondition for holding a session. Better Auth enforces that after the password check and before any session exists, so an unverified sign-in returns 403 with no cookie. That is why no "unverified principal" was needed: a correct password on an unverified address carries exactly the authority of no password, and /api/v1/principal is unchanged. Better Auth keeps every token. Ekklesia adds no token table, no hashing, and no expiry clock. It supplies a recipient, a subject, and two bodies, and the mail provider supplies everything about SMTP. Verification and reset links are rebuilt from a validated Dashboard origin, taking only the token — never the URL Better Auth prebuilds from a caller-supplied callbackURL — so no request can steer where a recipient lands. Following a verification link mints no session, because mail infrastructure dereferences links. Reset revokes that person's other sessions and leaves managed devices alone. 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: created already verified by an operator command with no mail prerequisite, so a fresh server can be signed in to before a relay exists. Migration 0007 extends that to owners of installations that already exist. Public signup stays disabled; verification is not a licence to self-register. Co-Authored-By: Claude Opus 5 --- .env.example | 15 + README.md | 36 +- apps/api/package.json | 2 + apps/api/src/config.ts | 6 +- apps/api/src/server.ts | 50 +- apps/api/tests/api-http.integration.ts | 47 +- apps/api/tests/config.test.ts | 3 + .../tests/email-verification.integration.ts | 816 ++++++++++ apps/api/tests/initial-owner.integration.ts | 169 +- apps/api/tests/module-database.integration.ts | 24 + .../0007_verified_bootstrap_owners.sql | 20 + core/drizzle/meta/0007_snapshot.json | 1370 +++++++++++++++++ core/drizzle/meta/_ekklesia.json | 6 + core/drizzle/meta/_journal.json | 7 + core/src/authorization/initial-owner.ts | 45 + core/src/diagnostics/doctor.ts | 3 +- core/src/host/configuration.ts | 4 + core/src/identity/config.ts | 81 +- core/src/identity/index.ts | 1 + core/src/identity/notifications.ts | 291 ++++ core/src/identity/runtime.ts | 124 +- core/tests/authorization-store.integration.ts | 1 + core/tests/dashboard-store.integration.ts | 1 + core/tests/doctor-report.test.ts | 2 +- .../host-configuration-boundaries.test.ts | 1 + core/tests/identity-config.test.ts | 108 ++ core/tests/identity-notifications.test.ts | 269 ++++ core/tests/identity-runtime.test.ts | 59 +- .../identity-verification-boundaries.test.ts | 192 +++ core/tests/mail-boundaries.test.ts | 34 +- .../module-configuration-store.integration.ts | 1 + .../module-lifecycle-store.integration.ts | 1 + ...mail-verification-and-password-recovery.md | 181 +++ docs/architecture/decisions/README.md | 1 + docs/development/api.md | 27 +- docs/development/email.md | 47 +- docs/development/identity.md | 140 +- docs/development/repository-workflow.md | 2 +- docs/development/security.md | 48 +- docs/development/testing.md | 47 + pnpm-lock.yaml | 6 + surfaces/shell/src/auth-protocol.ts | 187 +++ surfaces/shell/src/browser/AuthCard.tsx | 155 ++ .../shell/src/browser/ForgotPasswordForm.tsx | 103 ++ surfaces/shell/src/browser/HostApp.tsx | 107 +- surfaces/shell/src/browser/LoginForm.tsx | 176 +-- .../shell/src/browser/ResetPasswordForm.tsx | 145 ++ .../shell/src/browser/VerificationScreens.tsx | 182 +++ surfaces/shell/src/browser/main.tsx | 36 +- surfaces/shell/tests/auth-recovery.test.tsx | 481 ++++++ surfaces/shell/tests/host-app.test.tsx | 11 +- vitest.migrations.integration.config.ts | 1 + 52 files changed, 5675 insertions(+), 197 deletions(-) create mode 100644 apps/api/tests/email-verification.integration.ts create mode 100644 core/drizzle/0007_verified_bootstrap_owners.sql create mode 100644 core/drizzle/meta/0007_snapshot.json create mode 100644 core/src/identity/notifications.ts create mode 100644 core/tests/identity-notifications.test.ts create mode 100644 core/tests/identity-verification-boundaries.test.ts create mode 100644 docs/architecture/decisions/0038-email-verification-and-password-recovery.md create mode 100644 surfaces/shell/src/browser/AuthCard.tsx create mode 100644 surfaces/shell/src/browser/ForgotPasswordForm.tsx create mode 100644 surfaces/shell/src/browser/ResetPasswordForm.tsx create mode 100644 surfaces/shell/src/browser/VerificationScreens.tsx create mode 100644 surfaces/shell/tests/auth-recovery.test.tsx 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("