Files
Ekklesia/core/tests/identity-verification-boundaries.test.ts
ekklesiaandClaude Opus 5 ea28f8aa2e
CI / verify (pull_request) Successful in 42s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m42s
feat: verify email addresses and recover forgotten passwords
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 <[email protected]>
2026-08-25 07:21:17 -05:00

193 lines
8.0 KiB
TypeScript

import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
/**
* Structural guarantees about email verification and password recovery that no runtime assertion can
* make (ADR 0038).
*
* These read the repository itself, because what matters here is what code *exists* and what depends
* on what: that identity never learns an SMTP detail, that no token store was reimplemented
* alongside Better Auth's, that a device identity has no route into a human email flow, and that
* nothing in a browser bundle can reach any of it.
*/
const repositoryRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
async function readTypeScriptFiles(
directory: string,
): Promise<ReadonlyArray<{ readonly path: string; readonly text: string }>> {
const results: Array<{ path: string; text: string }> = [];
const walk = async (current: string): Promise<void> => {
for (const entry of await readdir(current, { withFileTypes: true })) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
if (entry.name === "node_modules" || entry.name === "dist") {
continue;
}
await walk(full);
continue;
}
if (entry.name.endsWith(".ts") || entry.name.endsWith(".tsx")) {
results.push({ path: full, text: await readFile(full, "utf8") });
}
}
};
await walk(path.join(repositoryRoot, directory));
return results;
}
/** Strips block comments and whole-line `//` comments: these assertions are about code, and prose
* explaining a rule inevitably quotes the token the rule forbids. */
function withoutComments(text: string): string {
return text
.replaceAll(/\/\*[\s\S]*?\*\//gu, "")
.split("\n")
.filter((line) => !line.trimStart().startsWith("//"))
.join("\n");
}
describe("identity email flows stay above the mail transport", () => {
it("names no SMTP or provider detail anywhere in the identity feature", async () => {
for (const file of await readTypeScriptFiles(path.join("core", "src", "identity"))) {
const code = withoutComments(file.text).toLowerCase();
for (const forbidden of [
"nodemailer",
"smtpserver",
"starttls",
"createtransport",
"587",
"465",
]) {
expect(code, `${file.path} names ${forbidden}`).not.toContain(forbidden);
}
}
});
it("reaches the transport only through the provider seam", async () => {
const notifications = await readFile(
path.join(repositoryRoot, "core", "src", "identity", "notifications.ts"),
"utf8",
);
// The whole mail surface identity may use: a validated message and a `send`. Not a config, not
// a transport, not a credential.
expect(notifications).toContain('from "../mail/index.js"');
expect(withoutComments(notifications)).not.toContain("MailProviderConfig");
expect(withoutComments(notifications)).not.toContain("createSmtpMailProvider");
});
});
describe("Better Auth owns every token", () => {
it("adds no Ekklesia table, column, or store for a verification or reset token", async () => {
const schemas = [
path.join("core", "src", "identity", "schema.ts"),
path.join("core", "src", "authorization", "schema.ts"),
path.join("core", "src", "devices", "schema.ts"),
];
for (const relative of schemas) {
const code = withoutComments(
await readFile(path.join(repositoryRoot, relative), "utf8"),
).toLowerCase();
// A parallel token table would be a second expiry clock, a second consumption rule, and a
// second thing to get wrong. `ekklesia_core_verification` is Better Auth's own table.
expect(code, relative).not.toContain("reset_token");
expect(code, relative).not.toContain("verification_token");
expect(code, relative).not.toContain("password_reset");
}
});
it("never hashes, stores, or compares a token itself", async () => {
for (const file of await readTypeScriptFiles(path.join("core", "src", "identity"))) {
const code = withoutComments(file.text);
expect(code, file.path).not.toContain("createHash");
expect(code, file.path).not.toContain("timingSafeEqual");
}
});
it("interpolates no token, link, address, or message body into anything it logs", async () => {
for (const file of await readTypeScriptFiles(path.join("core", "src", "identity"))) {
const code = withoutComments(file.text);
for (const call of code.matchAll(/console\.\w+\(([^\n]*)/gu)) {
// Fixed prose in a log line is fine; a **substituted value** is what could carry a secret.
// Only the interpolations are examined, so "email was not delivered" passes while
// `${user.email}` could not.
for (const substitution of (call[1] ?? "").matchAll(/\$\{([^}]*)\}/gu)) {
const expression = (substitution[1] ?? "").toLowerCase();
for (const forbidden of ["token", "url", "html", "text", "email", "recipient", "body"]) {
expect(expression, `${file.path} logs ${forbidden}`).not.toContain(forbidden);
}
}
}
}
});
});
describe("managed devices have no part in human email identity", () => {
it("gives a device no address, password, or verification state", async () => {
for (const file of await readTypeScriptFiles(path.join("core", "src", "devices"))) {
const code = withoutComments(file.text).toLowerCase();
for (const forbidden of ["email", "password", "emailverified", "sendresetpassword"]) {
expect(code, `${file.path} names ${forbidden}`).not.toContain(forbidden);
}
}
});
it("keeps the device store out of the identity notification path entirely", async () => {
const notifications = await readFile(
path.join(repositoryRoot, "core", "src", "identity", "notifications.ts"),
"utf8",
);
expect(notifications).not.toContain("devices");
expect(notifications).not.toContain("Device");
});
});
describe("the browser shell reaches none of it", () => {
it("imports neither Better Auth nor Core's mail from any browser source", async () => {
for (const file of await readTypeScriptFiles(path.join("surfaces", "shell", "src"))) {
expect(file.text, file.path).not.toContain("better-auth");
expect(file.text, file.path).not.toContain("@ekklesia/core/mail");
expect(file.text, file.path).not.toContain("nodemailer");
}
});
it("declares no server identity or mail dependency in the shell package", async () => {
const manifest = JSON.parse(
await readFile(path.join(repositoryRoot, "surfaces", "shell", "package.json"), "utf8"),
) as Record<string, Record<string, string> | undefined>;
const declared = Object.keys({
...(manifest.dependencies ?? {}),
...(manifest.devDependencies ?? {}),
});
expect(declared).not.toContain("better-auth");
expect(declared).not.toContain("nodemailer");
expect(declared).not.toContain("@ekklesia/core");
});
it("never puts a reset token in browser storage", async () => {
for (const file of await readTypeScriptFiles(path.join("surfaces", "shell", "src"))) {
const code = withoutComments(file.text);
// A reset token is a bearer credential for one account; the shell holds it in React state for
// the life of one form and nowhere else.
expect(code, file.path).not.toContain("localStorage");
expect(code, file.path).not.toContain("sessionStorage");
expect(code, file.path).not.toContain("document.cookie");
}
});
});
describe("public signup stays gone", () => {
it("offers no registration path from any browser source", async () => {
for (const file of await readTypeScriptFiles(path.join("surfaces", "shell", "src"))) {
const code = withoutComments(file.text);
// Email verification is not a licence to self-register (ADR 0038).
expect(code, file.path).not.toContain("/sign-up");
expect(code, file.path).not.toContain("signUp");
}
});
});