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]>
180 lines
7.9 KiB
TypeScript
180 lines
7.9 KiB
TypeScript
import { readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
/**
|
|
* Structural guarantees about outbound email that no runtime assertion can make (ADR 0037).
|
|
*
|
|
* These read the repository itself, because the properties that matter here are about what code
|
|
* *exists* and what depends on what — a mail credential that never reaches a browser bundle, and a
|
|
* platform that offers no route anyone could use as an open relay.
|
|
*/
|
|
const repositoryRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
|
|
async function readJson(relativePath: string): Promise<Record<string, unknown>> {
|
|
return JSON.parse(await readFile(path.join(repositoryRoot, relativePath), "utf8")) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
}
|
|
|
|
async function readTextFiles(directory: string): Promise<Array<{ path: string; text: string }>> {
|
|
const { readdir } = await import("node:fs/promises");
|
|
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;
|
|
}
|
|
|
|
describe("outbound email stays server-only", () => {
|
|
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 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",
|
|
]) {
|
|
const parsed = await readJson(manifest);
|
|
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 () => {
|
|
for (const directory of [
|
|
"packages/contracts/src",
|
|
"packages/module-sdk/src",
|
|
"packages/module-ui-sdk/src",
|
|
"packages/module-ui-host/src",
|
|
"surfaces/shell/src",
|
|
]) {
|
|
for (const file of await readTextFiles(directory)) {
|
|
expect(file.text, file.path).not.toContain("nodemailer");
|
|
expect(file.text, file.path).not.toContain("@ekklesia/core/mail");
|
|
}
|
|
}
|
|
});
|
|
|
|
it("gives module code no way to send email at all", async () => {
|
|
// Deliberate scope: Core sends, modules do not (yet). `ModuleRuntimeServices` is the one place a
|
|
// module capability would appear, and it is added one service at a time when something real
|
|
// needs it — nothing does here (ADR 0037).
|
|
const services = await readFile(
|
|
path.join(repositoryRoot, "packages/contracts/src/module-database.ts"),
|
|
"utf8",
|
|
);
|
|
expect(services).not.toMatch(/mail|email/iu);
|
|
|
|
for (const directory of ["packages/module-sdk/src", "packages/module-ui-sdk/src"]) {
|
|
for (const file of await readTextFiles(directory)) {
|
|
expect(file.text.toLowerCase(), file.path).not.toContain("sendmail");
|
|
}
|
|
}
|
|
});
|
|
|
|
it("exposes no HTTP route that could send mail", async () => {
|
|
// An authenticated relay endpoint is an open relay with extra steps. Nothing in the transport
|
|
// layer may reference the mail provider at all.
|
|
for (const file of await readTextFiles("apps/api/src")) {
|
|
if (file.path.endsWith("server.ts") || file.path.endsWith("config.ts")) {
|
|
// The composition root legitimately constructs and closes the provider.
|
|
continue;
|
|
}
|
|
expect(file.text, file.path).not.toContain("@ekklesia/core/mail");
|
|
expect(file.text.toLowerCase(), file.path).not.toContain("sendmail");
|
|
}
|
|
|
|
for (const file of await readTextFiles("apps/api/src")) {
|
|
expect(file.text, file.path).not.toMatch(/["'`][^"'`]*send-email[^"'`]*["'`]/u);
|
|
expect(file.text, file.path).not.toMatch(/["'`][^"'`]*\/api\/v1\/mail[^"'`]*["'`]/u);
|
|
}
|
|
});
|
|
|
|
it("maps SMTP environment in exactly one place, and never inside the mail provider", async () => {
|
|
// The mail provider takes validated configuration and nothing else — it never reads the
|
|
// environment, under any name.
|
|
for (const file of await readTextFiles("core/src/mail")) {
|
|
expect(file.text, file.path).not.toContain("process.env");
|
|
expect(file.text, file.path).not.toContain("EKKLESIA_SMTP_");
|
|
}
|
|
// The variable names live in the shared host-configuration loader, which both `startApi` and
|
|
// `pnpm doctor` call with their own environment. Two executables mapping these names
|
|
// independently is what let Doctor and the runtime disagree about object storage.
|
|
const loader = await readFile(
|
|
path.join(repositoryRoot, "core/src/host/configuration.ts"),
|
|
"utf8",
|
|
);
|
|
expect(loader).toContain("EKKLESIA_SMTP_HOST");
|
|
expect(loader).toContain("EKKLESIA_SMTP_PASSWORD");
|
|
// That the loader itself reads no environment is asserted in
|
|
// `host-configuration-boundaries.test.ts`, which strips comments first — this file's plain
|
|
// substring match would trip over the loader's own prose about the rule.
|
|
});
|
|
|
|
it("keeps no SMTP password out of the committed environment example", async () => {
|
|
const example = await readFile(path.join(repositoryRoot, ".env.example"), "utf8");
|
|
// Documented, but never with a value: a bootstrap credential in a committed file is a
|
|
// credential in every clone.
|
|
expect(example).toContain("EKKLESIA_SMTP_HOST");
|
|
expect(example).not.toMatch(/^EKKLESIA_SMTP_PASSWORD=.+$/mu);
|
|
expect(example).not.toContain("smtp-relay.gmail.com");
|
|
});
|
|
|
|
it("hardcodes no provider hostname in Core", async () => {
|
|
// Google Workspace is a deployment target, not the architecture.
|
|
for (const file of await readTextFiles("core/src/mail")) {
|
|
expect(file.text.toLowerCase(), file.path).not.toContain("gmail");
|
|
expect(file.text.toLowerCase(), file.path).not.toContain("smtp-relay");
|
|
expect(file.text.toLowerCase(), file.path).not.toContain("resend");
|
|
expect(file.text.toLowerCase(), file.path).not.toContain("brevo");
|
|
}
|
|
});
|
|
|
|
it("never disables TLS certificate verification", async () => {
|
|
for (const file of await readTextFiles("core/src/mail")) {
|
|
expect(file.text, file.path).not.toContain("rejectUnauthorized");
|
|
expect(file.text, file.path).not.toContain("NODE_TLS_REJECT_UNAUTHORIZED");
|
|
}
|
|
});
|
|
});
|