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]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3581ad7c8e
commit
ea28f8aa2e
@@ -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
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,12 @@
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tag": "0007_verified_bootstrap_owners",
|
||||
"destructive": {
|
||||
"mode": "none"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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<Record<InitialPlatformOwnerErrorCode, strin
|
||||
"The identity system reported success but no account exists for that address. Nothing was changed; the bootstrap is still available.",
|
||||
INITIAL_OWNER_IDENTITY_UNUSABLE:
|
||||
"An account already exists for that address but has no password and could never sign in. Choose a different address, or remove that account before retrying.",
|
||||
INITIAL_OWNER_IDENTITY_NOT_VERIFIED:
|
||||
"The identity exists but could not be marked as a verified address, so it could not sign in. Nothing was granted; the bootstrap is still available.",
|
||||
INITIAL_OWNER_OWNERSHIP_GRANT_FAILED:
|
||||
"The identity exists but platform ownership was not granted. Bootstrap is still available: run this command again with the same email address to finish it.",
|
||||
INITIAL_OWNER_PASSWORD_TOO_LONG: "The password is longer than the identity system allows.",
|
||||
@@ -131,6 +134,25 @@ async function findIdentityByEmail(
|
||||
return { hasCredential: credentials.length > 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<void> {
|
||||
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 ??
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<Record<IdentityConfigErrorCode, string>> = 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<void>;
|
||||
/** Shaped for Better Auth's `emailAndPassword.sendResetPassword`. Never throws. */
|
||||
readonly sendResetPassword: (data: {
|
||||
readonly user: IdentityNotificationRecipient;
|
||||
readonly token: string;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: [
|
||||
`<p>${escapeHtml(lead)}</p>`,
|
||||
"<p>Confirm this email address so it can be used to sign in to Ekklesia.</p>",
|
||||
`<p><a href="${escapeHtml(url)}">Verify this email address</a></p>`,
|
||||
`<p>${EXPIRY_SENTENCE} ${escapeHtml(UNEXPECTED_SENTENCE)}</p>`,
|
||||
].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: [
|
||||
`<p>${escapeHtml(lead)}</p>`,
|
||||
"<p>Someone asked to reset the password for this Ekklesia account.</p>",
|
||||
`<p><a href="${escapeHtml(url)}">Choose a new password</a></p>`,
|
||||
`<p>${EXPIRY_SENTENCE} ${escapeHtml(closing)}</p>`,
|
||||
].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<void> => {
|
||||
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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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("<script>");
|
||||
});
|
||||
|
||||
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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user