feat: verify email addresses and recover forgotten passwords
CI / verify (pull_request) Successful in 42s
CI / infrastructure-smoke (pull_request) Successful in 20s
CI / migration-smoke (pull_request) Successful in 1m42s

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]>
This commit is contained in:
ekklesia
2026-08-25 07:21:17 -05:00
co-authored by Claude Opus 5
parent 3581ad7c8e
commit ea28f8aa2e
52 changed files with 5675 additions and 197 deletions
@@ -177,6 +177,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"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 {
@@ -176,6 +176,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"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 {
+1 -1
View File
@@ -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`);
});
@@ -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",
+108
View File
@@ -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:[email protected]",
"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");
}
});
});
+269
View File
@@ -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<typeof createIdentityNotifications>;
}
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: "[email protected]", name: "Sam" },
});
const message = only(test.sent);
expect(message.to).toEqual(["[email protected]"]);
expect(message.subject).toBe("Verify your Ekklesia email address");
expect(message.text).toContain("Hello Sam,");
expect(message.html).toContain("<p>Hello Sam,</p>");
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: "[email protected]" } });
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: "[email protected]" } });
// 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: "[email protected]", name: "Sam" },
});
const message = only(test.sent);
expect(message.to).toEqual(["[email protected]"]);
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: "[email protected]" },
});
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: "[email protected]" } }),
).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 [email protected] pass=hunter2");
},
verify: async () => undefined,
},
});
await test.notifications.sendResetPassword({ token: "t", user: { email: "[email protected]" } });
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: "[email protected]" } });
await test.notifications.sendResetPassword({ token: "t", user: { email: "[email protected]" } });
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: "[email protected]" },
});
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: "[email protected]", name: 'Sam"><script>alert(1)</script>' },
});
const message = only(test.sent);
expect(message.html).not.toContain("<script>");
expect(message.html).toContain("&lt;script&gt;");
});
it("drops an unusable display name instead of failing the whole message", async () => {
const test = harness();
// A stored name carrying a control character would be refused by the message validator, which
// would turn a cosmetic problem into an undeliverable password reset.
await test.notifications.sendResetPassword({
token: "t",
user: { email: "[email protected]", name: "Sam\r\nBcc: [email protected]" },
});
const message = only(test.sent);
expect(message.text).toContain("Hello,");
expect(message.text).not.toContain("[email protected]");
expect(test.failures).toHaveLength(0);
});
it("refuses to send to an address the mail contract rejects, without throwing", async () => {
const test = harness();
await test.notifications.sendVerificationEmail({
token,
user: { email: "[email protected], [email protected]" },
});
expect(test.sent).toHaveLength(0);
expect(test.failures).toEqual([
{ kind: "email-verification", reason: "MAIL_RECIPIENT_INVALID" },
]);
});
});
+57 -2
View File
@@ -20,6 +20,7 @@ import { identityAdapterSchema } from "../src/identity/schema.js";
function validConfig(overrides: Partial<IdentityConfig> = {}): IdentityConfig {
return Object.freeze({
baseUrl: "https://identity.example.test",
dashboardOrigin: "https://identity.example.test",
publicSignupEnabled: false,
secret: "correct-horse-battery-staple-0123456789",
trustedOrigins: Object.freeze(["https://identity.example.test"]),
@@ -109,18 +110,21 @@ describe("Core identity runtime construction boundary", () => {
expect(options).toEqual({ provider: "pg", schema: identityAdapterSchema });
});
it("enables exactly email/password (autoSignIn disabled) and rate limiting, nothing else", () => {
it("enables exactly email/password, verification, and rate limiting, nothing else", () => {
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
createIdentityRuntime({ config: validConfig(), db: fakeDb() });
expect(betterAuthMock).toHaveBeenCalledTimes(1);
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
// Exhaustive on purpose: a new top-level Better Auth feature has to be added here deliberately,
// never by inheriting a default.
expect(Object.keys(options).sort()).toEqual([
"advanced",
"baseURL",
"database",
"emailAndPassword",
"emailVerification",
"logger",
"onAPIError",
"rateLimit",
@@ -133,13 +137,60 @@ describe("Core identity runtime construction boundary", () => {
enabled: true,
maxPasswordLength: 128,
minPasswordLength: 8,
// A verified address is a precondition for holding a session (ADR 0038).
requireEmailVerification: true,
resetPasswordTokenExpiresIn: 3600,
// Password reset revokes that human's other sessions, so recovering from a compromise is not
// cosmetic.
revokeSessionsOnPasswordReset: true,
});
expect(options.emailVerification).toEqual({
// Following an emailed link never mints a session: mail infrastructure dereferences links.
autoSignInAfterVerification: false,
expiresIn: 3600,
sendOnSignIn: true,
sendOnSignUp: true,
});
// No `customRules`: production inherits Better Auth's own stricter per-path defaults.
expect(options.rateLimit).toEqual({ enabled: true, max: 100, window: 10 });
expect(options.baseURL).toBe(validConfig().baseUrl);
expect(options.secret).toBe(validConfig().secret);
expect(options.trustedOrigins).toEqual([...validConfig().trustedOrigins]);
});
it("wires the injected notifier into both delivery callbacks, and neither without it", () => {
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
const sendResetPassword = vi.fn(async () => undefined);
const sendVerificationEmail = vi.fn(async () => undefined);
createIdentityRuntime({
config: validConfig(),
db: fakeDb(),
notifications: { sendResetPassword, sendVerificationEmail },
});
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
expect((options.emailAndPassword as Record<string, unknown>).sendResetPassword).toBe(
sendResetPassword,
);
expect((options.emailVerification as Record<string, unknown>).sendVerificationEmail).toBe(
sendVerificationEmail,
);
betterAuthMock.mockClear();
createIdentityRuntime({ config: validConfig(), db: fakeDb() });
const [withoutMail] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
// An installation with no mail configures the identical policy and simply cannot deliver.
// Crucially it does **not** relax `requireEmailVerification`.
expect(withoutMail.emailAndPassword).toMatchObject({ requireEmailVerification: true });
expect(
(withoutMail.emailAndPassword as Record<string, unknown>).sendResetPassword,
).toBeUndefined();
expect(
(withoutMail.emailVerification as Record<string, unknown>).sendVerificationEmail,
).toBeUndefined();
});
it("enables sign-up (disableSignUp: false) only when publicSignupEnabled is explicitly true", () => {
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
@@ -254,7 +305,7 @@ describe("Core identity runtime construction boundary", () => {
}
});
it("only widens the sign-in/sign-up rate-limit window when a narrow override is explicitly injected", () => {
it("only widens the four strict rate-limit windows when a narrow override is explicitly injected", () => {
betterAuthMock.mockReturnValue({ api: { getSession: vi.fn() }, handler: vi.fn() });
createIdentityRuntime({
@@ -266,6 +317,10 @@ describe("Core identity runtime construction boundary", () => {
const [options] = betterAuthMock.mock.calls[0] as [Record<string, unknown>];
expect(options.rateLimit).toEqual({
customRules: {
// The two paths that trigger an email are included, so a test suite driving many recovery
// requests is not throttled by Better Auth's 3-per-60-seconds default for them.
"/request-password-reset": { max: 1000, window: 1 },
"/send-verification-email": { max: 1000, window: 1 },
"/sign-in/email": { max: 1000, window: 1 },
"/sign-up/email": { max: 1000, window: 1 },
},
@@ -0,0 +1,192 @@
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");
}
});
});
+23 -11
View File
@@ -42,30 +42,42 @@ async function readTextFiles(directory: string): Promise<Array<{ path: string; t
}
describe("outbound email stays server-only", () => {
it("declares the SMTP dependency in Core alone", async () => {
it("declares the SMTP transport in Core alone", async () => {
const core = await readJson("core/package.json");
expect(Object.keys((core.dependencies ?? {}) as object)).toContain("nodemailer");
const mailPackages = (names: readonly string[]): readonly string[] =>
names.filter((name) => /nodemailer|smtp|mailgun|sendgrid|postmark|resend/iu.test(name));
// Every package a browser can end up executing, plus the contracts barrel a surface must not
// reach. None may carry a mail transport.
// reach. None may carry anything mail-related at all, in either dependency list.
for (const manifest of [
"packages/contracts/package.json",
"packages/module-sdk/package.json",
"packages/module-ui-sdk/package.json",
"packages/module-ui-host/package.json",
"surfaces/shell/package.json",
"apps/api/package.json",
]) {
const parsed = await readJson(manifest);
const dependencies = {
...((parsed.dependencies ?? {}) as Record<string, string>),
...((parsed.devDependencies ?? {}) as Record<string, string>),
};
const mailDependencies = Object.keys(dependencies).filter((name) =>
/nodemailer|smtp|mailgun|sendgrid|postmark|resend/iu.test(name),
);
expect(mailDependencies, manifest).toEqual([]);
expect(
mailPackages([
...Object.keys((parsed.dependencies ?? {}) as object),
...Object.keys((parsed.devDependencies ?? {}) as object),
]),
manifest,
).toEqual([]);
}
// The API executable ships no mail package of any kind: it reaches SMTP only through Core's
// provider. Its *test* dependencies may include `smtp-server`, which is a receiving server and
// has no capability to send anything — that is what lets the verification and recovery
// integration suites capture a real message instead of asserting against a fake (ADR 0038).
const api = await readJson("apps/api/package.json");
expect(mailPackages(Object.keys((api.dependencies ?? {}) as object))).toEqual([]);
expect(mailPackages(Object.keys((api.devDependencies ?? {}) as object))).toEqual([
"@types/smtp-server",
"smtp-server",
]);
});
it("never imports the transport outside Core's own mail module", async () => {
@@ -178,6 +178,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"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 {
@@ -181,6 +181,7 @@ async function applyCoreMigrations(url: string): Promise<void> {
"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 {