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]>
155 lines
6.1 KiB
TypeScript
155 lines
6.1 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 where deployment configuration is read, which no runtime assertion can
|
|
* make.
|
|
*
|
|
* The defect this pass fixed was structural: two executables each read their own subset of the same
|
|
* environment variables, and the subsets drifted until `pnpm doctor` reported a correctly configured
|
|
* installation as having no object storage. Restoring parity once is not enough — the arrangement
|
|
* that allowed the drift has to stay gone.
|
|
*/
|
|
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 what the *code* does, and prose that explains a rule inevitably quotes
|
|
* the very token the rule forbids. Trailing comments are deliberately left alone so a URL inside a
|
|
* string literal is never truncated.
|
|
*/
|
|
function withoutComments(text: string): string {
|
|
return text
|
|
.replaceAll(/\/\*[\s\S]*?\*\//gu, "")
|
|
.split("\n")
|
|
.filter((line) => !line.trimStart().startsWith("//"))
|
|
.join("\n");
|
|
}
|
|
|
|
/**
|
|
* Every variable the shared loader *interprets* — parses, defaults, splits, or classifies.
|
|
*
|
|
* `DATABASE_URL` is deliberately absent: it is passed through verbatim, and the small migration and
|
|
* owner-bootstrap commands legitimately read it directly. Interpretation is what drifts.
|
|
*/
|
|
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",
|
|
"EKKLESIA_IDENTITY_SECRET",
|
|
"EKKLESIA_IDENTITY_TRUSTED_ORIGINS",
|
|
"EKKLESIA_PUBLIC_SIGNUP_ENABLED",
|
|
"EKKLESIA_SMTP_FROM_ADDRESS",
|
|
"EKKLESIA_SMTP_FROM_NAME",
|
|
"EKKLESIA_SMTP_HOST",
|
|
"EKKLESIA_SMTP_PASSWORD",
|
|
"EKKLESIA_SMTP_PORT",
|
|
"EKKLESIA_SMTP_SECURITY",
|
|
"EKKLESIA_SMTP_USERNAME",
|
|
"NODE_ENV",
|
|
"S3_ACCESS_KEY",
|
|
"S3_BUCKET_PREFIX",
|
|
"S3_DELIVERY_ENDPOINT",
|
|
"S3_ENDPOINT",
|
|
"S3_FORCE_PATH_STYLE",
|
|
"S3_REGION",
|
|
"S3_SECRET_KEY",
|
|
]);
|
|
|
|
describe("host configuration is read in exactly one place", () => {
|
|
it("never reads the process environment from the shared loader itself", async () => {
|
|
for (const file of await readTypeScriptFiles(path.join("core", "src", "host"))) {
|
|
// The loader is reusable Core code: it takes the environment as an argument, exactly as
|
|
// `createEnvironmentModuleSecretSource` does, so both executables can hand it theirs.
|
|
expect(withoutComments(file.text), file.path).not.toContain("process.env");
|
|
}
|
|
});
|
|
|
|
it("interprets each host infrastructure variable in exactly one file", async () => {
|
|
const sources = [
|
|
...(await readTypeScriptFiles(path.join("core", "src"))),
|
|
...(await readTypeScriptFiles(path.join("apps", "api", "src"))),
|
|
];
|
|
|
|
for (const file of sources) {
|
|
const code = withoutComments(file.text);
|
|
for (const variable of INTERPRETED_VARIABLES) {
|
|
// Reading one of these anywhere else means a second, independently drifting interpretation
|
|
// of the same deployment — which is exactly the defect this pass fixed. Operator-facing
|
|
// message text that *names* a variable is untouched: only an actual read is forbidden.
|
|
expect(code, `${file.path} reads ${variable}`).not.toContain(`process.env.${variable}`);
|
|
expect(code, `${file.path} reads ${variable}`).not.toContain(`process.env["${variable}"]`);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("leaves the two executables reading nothing but their own listener address", async () => {
|
|
const doctorCli = withoutComments(
|
|
await readFile(path.join(repositoryRoot, "core", "src", "cli", "doctor.ts"), "utf8"),
|
|
);
|
|
const server = withoutComments(
|
|
await readFile(path.join(repositoryRoot, "apps", "api", "src", "server.ts"), "utf8"),
|
|
);
|
|
|
|
for (const source of [doctorCli, server]) {
|
|
expect(source).toContain("loadHostConfiguration");
|
|
expect(source).not.toContain("DATABASE_URL");
|
|
}
|
|
// `EKKLESIA_API_HOST`/`EKKLESIA_API_PORT` describe this listener rather than the host's
|
|
// infrastructure, so Doctor has no use for them and they stay with the process that binds.
|
|
expect(server).toContain("EKKLESIA_API_HOST");
|
|
expect(doctorCli).not.toContain("EKKLESIA_API_");
|
|
});
|
|
|
|
it("reads the environment only from executables, never from reusable Core", async () => {
|
|
for (const file of await readTypeScriptFiles(path.join("core", "src"))) {
|
|
if (file.path.includes(`${path.sep}cli${path.sep}`)) {
|
|
continue;
|
|
}
|
|
expect(withoutComments(file.text), file.path).not.toContain("process.env");
|
|
}
|
|
});
|
|
|
|
it("gives Doctor the same object-storage and secret inputs the runtime receives", async () => {
|
|
const doctorCli = await readFile(
|
|
path.join(repositoryRoot, "core", "src", "cli", "doctor.ts"),
|
|
"utf8",
|
|
);
|
|
|
|
// The two options whose absence was the bug. A future edit that drops either fails here rather
|
|
// than in a support conversation about storage that Doctor says is missing.
|
|
expect(doctorCli).toContain("storageConfiguration: host.storage");
|
|
expect(doctorCli).toContain("moduleSecrets: host.moduleSecrets");
|
|
});
|
|
});
|