diff --git a/.changeset/sign-up-mail-after-response.md b/.changeset/sign-up-mail-after-response.md index 03d538cc50c..1d4c47d6d49 100644 --- a/.changeset/sign-up-mail-after-response.md +++ b/.changeset/sign-up-mail-after-response.md @@ -9,4 +9,4 @@ Metadata-protocol: none - **The rule.** `POST /api/v1/auth/sign-up/email` answers without waiting for any mail send, whether or not the address already has an account: the verification mail, and the notice to an address that already has an account, are built and sent after the response, as the password-reset mail already is. - **A send that fails is logged on the server**, at error (`verification email was not delivered`; the notice keeps its own error line), and never reaches the caller, whose answer is the one a sent mail gets, as before. -- **Unchanged.** The answer's status and body; the sign-up rate limits; the mails themselves; the `/send-verification-email` resend, which still awaits its send and still reports one that failed; and a server-side `auth.api.signUpEmail` call, which carries no request and still awaits its sends. +- **Unchanged.** The answer's status and body; the sign-up rate limits; the mails themselves; a signed-in caller's `/send-verification-email` resend, which still awaits its send and still reports one that failed; and a server-side `auth.api.signUpEmail` call, which carries no request and still awaits its sends. diff --git a/.changeset/visitor-verification-resend.md b/.changeset/visitor-verification-resend.md new file mode 100644 index 00000000000..aa528a88e24 --- /dev/null +++ b/.changeset/visitor-verification-resend.md @@ -0,0 +1,12 @@ +--- +'@objectstack/plugin-auth': patch +--- + +fix(plugin-auth): a verification-email resend from a visitor who is not signed in answers the same whatever the send does + +Clause-②: no +Metadata-protocol: none + +- **The rule.** `POST /api/v1/auth/send-verification-email` from a visitor who is not signed in answers the same whatever the send does. While the email transport is live it answers `{ "status": true }`: the verification mail is built and sent after the response, as the sign-up's verification mail is, and a send that fails is logged on the server at error (`verification email was not delivered`). +- **While the email transport is not live** (no email service, or one whose `isConfigured()` does not answer `true`, such as the `log` transport), every such resend is refused with `400 EMAIL_SERVICE_REQUIRED`, the code `/change-email` and the identity import already answer for a deployment with no email delivery. Its message names the fix (Settings → Mail), and the verify screens show it. +- **Unchanged.** A signed-in caller's resend for their own address, by session cookie or by bearer token, still awaits its send, still reports one that failed, and is not refused while the transport is not live. A server-side `auth.api.sendVerificationEmail` call, which carries no request, still awaits its send. diff --git a/packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts b/packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts index e1d0c458ab3..b414df8a6f9 100644 --- a/packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts +++ b/packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts @@ -70,36 +70,44 @@ import { vendorClientAddressOptions } from './client-address-vendor.js'; import { PLATFORM_IDP_PROVIDER_ID, refuseImplicitAccountLink } from './implicit-account-linking.js'; import { isPlaceholderEmail } from './placeholder-email.js'; import { logAfterResponseSendFailure, sendAfterResponseOnPublicDoor } from './run-after-response.js'; +import { isVisitorResendInEndpointContext } from './visitor-verification-resend.js'; /** The path better-auth declares for the email sign-up endpoint. */ const SIGN_UP_EMAIL_PATH = '/sign-up/email'; /** - * `send`, with the one call the sign-up endpoint makes handed to - * {@link sendAfterResponseOnPublicDoor}: a sign-up answers without waiting for - * any mail send, whether or not the address already has an account. Every - * other call is `send` itself, awaited exactly as before — the - * `/send-verification-email` resend among them, which reports a send that - * failed to its caller. + * `send`, with two of its calls handed to {@link sendAfterResponseOnPublicDoor}: + * + * - the one the sign-up endpoint makes: a sign-up answers without waiting for + * any mail send, whether or not the address already has an account; + * - the one the `/send-verification-email` resend makes for a visitor who is + * not signed in: that resend answers the same whatever the send does + * (`visitor-verification-resend.ts` states the rule). + * + * Their failure is logged on the server, never handed to the caller. Every + * other call is `send` itself, awaited exactly as before — a signed-in + * caller's resend among them, which reports a send that failed to its caller. * * The endpoint is the one better-auth runs the call inside, read by its * declared path from the endpoint context (as the hooks read `ctx.path`), - * never from the request's URL. Outside any endpoint context the call is - * awaited. + * never from the request's URL; whether the caller is signed in is the session + * that endpoint resolved before it called. Outside any endpoint context the + * call is awaited. */ -function sendAfterResponseOnSignUp( +function sendAfterResponseOnSignUpAndVisitorResend( send: (data: D, request?: unknown) => Promise, onFailure: (error: unknown) => void, ): (data: D, request?: unknown) => Promise { const afterResponse = sendAfterResponseOnPublicDoor(send, onFailure); return (data, request) => { - let path: unknown; + let answersFirst = false; try { - path = tryGetCurrentAuthEndpointContext()?.path; + const endpoint = tryGetCurrentAuthEndpointContext(); + answersFirst = endpoint?.path === SIGN_UP_EMAIL_PATH || isVisitorResendInEndpointContext(endpoint); } catch { - path = undefined; + answersFirst = false; } - return path === SIGN_UP_EMAIL_PATH ? afterResponse(data, request) : (send(data, request) as Promise); + return answersFirst ? afterResponse(data, request) : (send(data, request) as Promise); }; } @@ -583,16 +591,22 @@ export async function createAuthInstance(this: AuthInstanceHost): Promise { + // failure is logged on the server. The `/send-verification-email` + // resend of a visitor who is not signed in is sent the same way, so + // it answers the same whatever the send does + // (`visitor-verification-resend.ts`). Every other call is awaited as + // before (`sendAfterResponseOnSignUpAndVisitorResend`) — a signed-in + // caller's resend among them. + sendVerificationEmail: sendAfterResponseOnSignUpAndVisitorResend(async ({ user, url, token }: { user: { id: string; email: string; name?: string }; url: string; token: string }, request?: unknown) => { const email = this.getEmailService(); if (!email) { // Verification is enabled (this callback only exists when it is) // but no email transport is wired — a misconfiguration, not a - // transient blip. THROW so the explicit `/send-verification-email` - // resend endpoint (which awaits this) surfaces a real error - // instead of a false "email sent" success. Sign-up stays + // transient blip. THROW so a signed-in caller's + // `/send-verification-email` resend (which awaits this) surfaces a + // real error instead of a false "email sent" success; a visitor's + // resend is refused before it gets here while no transport is + // live (`EMAIL_SERVICE_REQUIRED`, the before-hook). Sign-up stays // resilient regardless: better-auth runs the sendOnSignUp call // through `runInBackgroundOrAwait`, which logs (never rethrows) // a failure, so the account is still created and the user lands @@ -606,7 +620,8 @@ export async function createAuthInstance(this: AuthInstanceHost): Promise this.hasExistingUserFor(...a), isAuthGateActive: (...a) => this.isAuthGateActive(...a), isBootstrapCreation: (...a) => this.isBootstrapCreation(...a), + isEmailDeliveryLive: (...a) => this.isEmailDeliveryLive(...a), isOperatorProvisioning: (...a) => this.isOperatorProvisioning(...a), isPlatformAdminUserId: (...a) => this.isPlatformAdminUserId(...a), resolveActor: (...a) => this.resolveActor(...a), diff --git a/packages/plugins/plugin-auth/src/auth-manager-before-hook.ts b/packages/plugins/plugin-auth/src/auth-manager-before-hook.ts index f1f1fec3362..1890b28c0e1 100644 --- a/packages/plugins/plugin-auth/src/auth-manager-before-hook.ts +++ b/packages/plugins/plugin-auth/src/auth-manager-before-hook.ts @@ -72,6 +72,7 @@ import { import { ORGANIZATION_DELETE_PATH } from './organization-delete.js'; import { carriesPrincipalSession } from './principal-session-mint.js'; import { snapshotRoleChangeGrants, UPDATE_MEMBER_ROLE_PATH } from './role-change-granted-set.js'; +import { refuseVisitorResendWithoutLiveTransport } from './visitor-verification-resend.js'; /** * The `AuthManager` members the function below reads — every one of them, and @@ -95,6 +96,7 @@ export interface BeforeHookHost { hasExistingUserFor: AuthManager['hasExistingUserFor']; isAuthGateActive: AuthManager['isAuthGateActive']; isBootstrapCreation: AuthManager['isBootstrapCreation']; + isEmailDeliveryLive: AuthManager['isEmailDeliveryLive']; isOperatorProvisioning: AuthManager['isOperatorProvisioning']; isPlatformAdminUserId: AuthManager['isPlatformAdminUserId']; resolveActor: AuthManager['resolveActor']; @@ -671,6 +673,19 @@ export async function globalBeforeHook( if (roleChangeDeps) await snapshotRoleChangeGrants(ctx, roleChangeDeps); } + // ── The verification-email resend of a visitor who is not signed in ── + // While no email transport is live, every such resend is refused here, + // before the endpoint runs, with one coded answer whatever the address + // (400 `EMAIL_SERVICE_REQUIRED`). While one is live the resend answers + // the same whatever the send does: its send runs after the response + // (`sendAfterResponseOnSignUpAndVisitorResend`). A signed-in caller's + // resend passes through untouched. `visitor-verification-resend.ts` + // states the rule and how a visitor is told apart. + await refuseVisitorResendWithoutLiveTransport(ctx, { + isEmailDeliveryLive: () => this.isEmailDeliveryLive(), + resolveActor: (c) => this.resolveActor(c), + }); + // ── ADR-0069 D2: account lockout (gate) ───────────────────── // Reject a sign-in for a locked identity BEFORE better-auth checks // the password — a lock must hold even against the correct password. diff --git a/packages/plugins/plugin-auth/src/signup-existing-address-success-shape.test.ts b/packages/plugins/plugin-auth/src/signup-existing-address-success-shape.test.ts index 5251bf1cd98..2266c257072 100644 --- a/packages/plugins/plugin-auth/src/signup-existing-address-success-shape.test.ts +++ b/packages/plugins/plugin-auth/src/signup-existing-address-success-shape.test.ts @@ -33,11 +33,19 @@ * kinds of address get the same answer while every send is still open, and * the mails still go out after the response; a verification send that fails * is logged on the server and answered like one that is sent; the - * server-side sign-up call (no `Request`) still awaits its sends, and the - * `/send-verification-email` resend still reports a send that failed. + * server-side sign-up call (no `Request`) still awaits its sends, and a + * signed-in caller's `/send-verification-email` resend still reports a send + * that failed; + * - ⑧ a verification-email resend from a visitor who is not signed in answers + * the same whatever the send does: while the transport is live, every + * address gets the success answer before the send settles, and a send that + * fails is logged on the server; while it is not live, every address gets + * the same `EMAIL_SERVICE_REQUIRED` refusal and nothing is sent. A signed-in + * caller, by cookie or by bearer token, is not refused, and a server-side + * call still awaits its send. * - * The mail of ①, ⑤ and ⑥ is sent after the response, so those cases wait for - * it to arrive before reading it. + * The mail of ①, ⑤, ⑥ and ⑧ is sent after the response, so those cases wait + * for it to arrive before reading it. * * Real engine for the same reason the sibling file gives: the uniqueness probe * and the row the vendor finds both live below any double. @@ -439,42 +447,45 @@ describe('[#25060] ⑥ a send that fails after the decision', () => { }); }); +/** A promise the case settles by hand. */ +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +/** + * A live email service whose every send waits on `gate` and then answers + * `status()` (`'throws'` makes the send throw); it records each send it + * settled, as `[template, address]`. + */ +function gatedEmail(gate: () => Promise, status: () => 'sent' | 'failed' | 'throws' = () => 'sent') { + const settled: Array<[string, string]> = []; + const service = { + async send() { return { id: 'e', status: 'sent' }; }, + async sendTemplate(input: any) { + try { + await gate(); + const outcome = status(); + if (outcome === 'throws') throw new Error('smtp connection reset (simulated)'); + return outcome === 'failed' + ? { id: 'e', status: 'failed', error: 'smtp refused (simulated)' } + : { id: 'e', status: 'sent' }; + } finally { + settled.push([input.template, input.to.address]); + } + }, + isConfigured: () => true, + }; + return { settled, service }; +} + describe('⑦ a sign-up answers without waiting for any mail send, whether or not the address already has an account', () => { const NEW = 'dave@corp.example'; const VERIFY = 'auth.verify_email'; - /** A promise the case settles by hand. */ - function deferred(): { promise: Promise; resolve: () => void } { - let resolve!: () => void; - const promise = new Promise((r) => { - resolve = r; - }); - return { promise, resolve }; - } - - /** - * A live email service whose every send waits on `gate` and then answers - * `status()`; it records each send it settled, as `[template, address]`. - */ - function gatedEmail(gate: () => Promise, status: () => 'sent' | 'failed' = () => 'sent') { - const settled: Array<[string, string]> = []; - const service = { - async send() { return { id: 'e', status: 'sent' }; }, - async sendTemplate(input: any) { - try { - await gate(); - return status() === 'failed' - ? { id: 'e', status: 'failed', error: 'smtp refused (simulated)' } - : { id: 'e', status: 'sent' }; - } finally { - settled.push([input.template, input.to.address]); - } - }, - isConfigured: () => true, - }; - return { settled, service }; - } - const sorted = (pairs: ReadonlyArray) => pairs.map((p) => p.join(' ')).sort(); for (const [label, sendOnSignUp, mails] of [ @@ -538,7 +549,7 @@ describe('⑦ a sign-up answers without waiting for any mail send, whether or no expect(sorted(email.settled)).toEqual(sorted([[VERIFY, NEW], [NOTICE, EXISTING]])); }); - it('the /send-verification-email resend still awaits its send and reports one that failed', async () => { + it("a signed-in caller's /send-verification-email resend still awaits its send and reports one that failed", async () => { // A deployment where a new sign-up is signed in at once, so the resend // runs for the caller's own session. const engine = await bootEngine(); @@ -568,3 +579,120 @@ describe('⑦ a sign-up answers without waiting for any mail send, whether or no expect(email.settled).toEqual([[VERIFY, NEW], [VERIFY, NEW]]); }); }); + +describe('⑧ a verification-email resend from a visitor who is not signed in answers the same whatever the send does', () => { + const NEW = 'dave@corp.example'; + const VERIFY = 'auth.verify_email'; + const SERVICE_REQUIRED = 'EMAIL_SERVICE_REQUIRED'; + /** + * One address of each kind: no account; an account whose address is + * confirmed; and Alice's, whose confirmation is still outstanding — the one + * a resend mails. + */ + const NO_ACCOUNT = 'nobody@corp.example'; + const CONFIRMED = 'carol@corp.example'; + const ADDRESSES = [NO_ACCOUNT, CONFIRMED, EXISTING] as const; + + async function bootWithConfirmedAddress(): Promise { + const engine = await bootEngine(); + await engine.insert('sys_user', { id: 'usr_carol', email: CONFIRMED, name: 'Carol', email_verified: true }, SYSTEM); + return engine; + } + + /** The resend, asked over HTTP; with no credential header the caller is a visitor. */ + function resend(manager: AuthManager, email: string, headers: Record = {}): Promise { + return manager.handleRequest( + new Request(`${AUTH}/send-verification-email`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: BASE, ...headers }, + body: JSON.stringify({ email }), + }), + ); + } + + /** Every address's answer, as status and parsed body, asked all at once. */ + async function answersFor(manager: AuthManager): Promise> { + const responses = await Promise.all(ADDRESSES.map((address) => resend(manager, address))); + return Promise.all(responses.map(async (res) => ({ status: res.status, body: await res.json() }))); + } + + for (const failure of ['failed', 'throws'] as const) { + it(`while the transport is live, a send that ${failure === 'failed' ? 'reports failure' : 'throws'} leaves every address the same success answer, given before the send settles, and is logged on the server at error`, async () => { + const engine = await bootWithConfirmedAddress(); + const open = deferred(); + const email = gatedEmail(() => open.promise, () => failure); + const { errors, logger } = recordingLogger(); + const manager = makeManager(engine, email, { ...OPEN, logger }); + + const answers = await answersFor(manager); + + expect(answers).toEqual(ADDRESSES.map(() => ({ status: 200, body: { status: true } }))); + // Every answer arrived while the send was still open. + expect(email.settled).toEqual([]); + + // The control: the send still runs, after the response, and what it did + // is the server's to log. + open.resolve(); + await vi.waitFor(() => expect(email.settled).toEqual([[VERIFY, EXISTING]])); + await vi.waitFor(() => expect(errors).toHaveLength(1)); + expect(errors[0]).toContain('verification email was not delivered'); + }); + } + + for (const [label, transport] of [ + ['the log transport: isConfigured() answers false', () => recordingEmail(false)], + ['an email service without isConfigured()', () => recordingEmail('absent')], + ['no email service at all', () => undefined], + ] as const) { + it(`while the transport is not live (${label}), every address gets the same ${SERVICE_REQUIRED} refusal and nothing is sent`, async () => { + const engine = await bootWithConfirmedAddress(); + const email = transport(); + const manager = makeManager(engine, email, { ...OPEN, emailVerification: { sendOnSignUp: false } }); + + const answers = await answersFor(manager); + + expect(answers[0].status, JSON.stringify(answers[0].body)).toBe(400); + expect((answers[0].body as { code?: string }).code).toBe(SERVICE_REQUIRED); + expect(answers).toEqual(ADDRESSES.map(() => answers[0])); + expect(email?.sent ?? []).toEqual([]); + }); + } + + it('while the transport is not live, a signed-in caller is not refused: their resend, by cookie or by bearer token, still sends', async () => { + const engine = await bootEngine(); + const email = recordingEmail(false); + // A deployment where a new sign-up is signed in at once, so the resend + // runs for the caller's own session. + const manager = makeManager(engine, email, { + ...OPEN, + emailAndPassword: { requireEmailVerification: false }, + emailVerification: { sendOnSignUp: false }, + }); + const signedUp = await signUp(manager, NEW); + expect(signedUp.status, await signedUp.clone().text()).toBe(200); + const cookie = signedUp.headers.getSetCookie().map((c) => c.split(';')[0]).join('; '); + const bearer = signedUp.headers.get('set-auth-token'); + expect(bearer, 'the sign-up handed out no bearer token').toBeTruthy(); + + const byCookie = await resend(manager, NEW, { cookie }); + const byBearer = await resend(manager, NEW, { authorization: `Bearer ${bearer}` }); + + for (const res of [byCookie, byBearer]) { + expect(res.status, await res.clone().text()).toBe(200); + expect(await res.json()).toEqual({ status: true }); + } + expect(email.sent.map((s) => [s.template, s.to.address])).toEqual([[VERIFY, NEW], [VERIFY, NEW]]); + }); + + it('a server-side call, which carries no Request, still awaits its send and reports one that failed', async () => { + const engine = await bootEngine(); + const email = gatedEmail(async () => {}, () => 'failed'); + const manager = makeManager(engine, email, { ...OPEN }); + const api = (await manager.getApi()) as unknown as { + sendVerificationEmail(input: { body: { email: string } }): Promise; + }; + + await expect(api.sendVerificationEmail({ body: { email: EXISTING } })).rejects.toThrow(/smtp refused \(simulated\)/); + expect(email.settled).toEqual([[VERIFY, EXISTING]]); + }); +}); diff --git a/packages/plugins/plugin-auth/src/verification-email-failure-propagation.test.ts b/packages/plugins/plugin-auth/src/verification-email-failure-propagation.test.ts index 002423805e0..4978b595fd1 100644 --- a/packages/plugins/plugin-auth/src/verification-email-failure-propagation.test.ts +++ b/packages/plugins/plugin-auth/src/verification-email-failure-propagation.test.ts @@ -17,12 +17,16 @@ * logs `Failed to run background task` and returns normally * (`better-auth/dist/context/create-context.mjs`). The `/send-verification-email` * route does NOT — it awaits `sendVerificationEmailFn` directly and rethrows — - * so the resend path is honest today and must stay that way. + * so a signed-in caller's resend is honest today and must stay that way. A + * resend from a visitor who is not signed in answers the same whatever the + * send does: its send runs after the response and this rejection is logged on + * the server, and while no transport is live the resend is refused up front + * with `EMAIL_SERVICE_REQUIRED` (`visitor-verification-resend.ts`). * - * Hence these assertions: they are the contract the cloud verify-email screen - * reads through the resend endpoint, and the reason the *first* half of #14318 - * is a configuration-time refusal in plugin-email rather than another layer of - * error plumbing here. + * Hence these assertions: they are the contract a signed-in caller's resend + * reads, and the server log a visitor's resend leaves, and the reason the + * *first* half of #14318 is a configuration-time refusal in plugin-email + * rather than another layer of error plumbing here. */ import { describe, it, expect, vi } from 'vitest'; diff --git a/packages/plugins/plugin-auth/src/visitor-verification-resend.ts b/packages/plugins/plugin-auth/src/visitor-verification-resend.ts new file mode 100644 index 00000000000..f2da38b2822 --- /dev/null +++ b/packages/plugins/plugin-auth/src/visitor-verification-resend.ts @@ -0,0 +1,126 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The verification-email resend, `POST /send-verification-email`, asked by a + * visitor who is not signed in. + * + * ## The rule + * + * A verification-email resend from a visitor who is not signed in answers the + * same whatever the send does, and the same whatever the address: + * + * - **While the email transport is live** (`AuthManager.isEmailDeliveryLive`), + * it answers better-auth's success, `{ status: true }`. The send runs after + * the response (`sendAfterResponseOnPublicDoor`, wired in + * `auth-manager-auth-instance.ts`), and a send that fails is logged on the + * server at error (`verification email was not delivered`). The caller is + * never handed it. + * - **While the transport is not live**, every such resend is refused before + * the endpoint runs, with one coded answer + * ({@link refuseVisitorResendWithoutLiveTransport}): 400 + * `EMAIL_SERVICE_REQUIRED`, the code this package already answers for the + * same deployment condition on `/change-email` and in the identity import. + * A deployment that cannot mail still says so to whoever asks. + * + * Unchanged: a signed-in caller's resend, for their own address (the only one + * better-auth lets a signed-in caller name), still awaits its send and still + * reports one that failed; and a server-side `auth.api.sendVerificationEmail` + * call, which carries no request, still awaits its send. + * + * ## Who is a visitor + * + * Read from the endpoint, never from the request's URL. better-auth runs each + * endpoint, and its hooks, inside an endpoint context, and gives each request + * its own `context.session`, `null` until a session is resolved. The resend + * resolves its caller's session first (`getSessionFromCtx`) and records what it + * found on that field: the session, or `null` for a caller who has none. That + * is all before it calls the send, so the send callback reads the field from + * the endpoint context it runs in ({@link isVisitorResendInEndpointContext}). + * (better-auth 1.7.12: `dist/api/dispatch.mjs` sets the field per request, + * `dist/api/routes/session.mjs` `getSessionFromCtx` records it, + * `dist/api/routes/email-verification.mjs` reads the session before the send.) + * + * The before-hook runs before the endpoint, so that field cannot answer there + * yet, and neither can `getSessionFromCtx` for every caller: better-auth's + * `bearer()` plugin turns an `Authorization: Bearer` credential into the + * session cookie in a before-hook of its own, applied only once every + * before-hook has run, so a caller signed in by bearer token reads as no + * session there. The before-hook therefore asks `AuthManager.resolveActor`, + * the actor reader every before-hook gate already uses, which reads the cookie + * and the bearer token alike ({@link refuseVisitorResendWithoutLiveTransport}). + * + * A call that carries no `Request` is a server-side call, not a visitor's, and + * is left as it was. + */ + +import type { RegisteredErrorCode } from '@objectstack/spec/api'; + +/** The path better-auth declares for the verification-email resend. */ +export const SEND_VERIFICATION_EMAIL_PATH = '/send-verification-email'; + +/** + * The code a visitor's resend is refused with while the transport is not live: + * plugin-auth's ledgered code for a deployment with no email delivery (ADR-0112). + */ +export const VISITOR_RESEND_REFUSAL_CODE: RegisteredErrorCode = 'EMAIL_SERVICE_REQUIRED'; + +/** + * Its message: the capability and the fix, the same for every address. The + * verify screens show it as the reason the resend did not go out. + */ +export const VISITOR_RESEND_REFUSAL_MESSAGE = + 'Verification emails cannot be sent: this deployment has no configured email delivery service. ' + + 'An administrator can configure one under Settings → Mail.'; + +interface EndpointLike { + path?: unknown; + request?: unknown; + context?: { session?: unknown } | null; +} + +/** The resend, asked over HTTP (the call carries a `Request`). */ +function isResendOverHttp(ctx: EndpointLike | undefined): boolean { + return ctx?.path === SEND_VERIFICATION_EMAIL_PATH && ctx.request !== undefined && ctx.request !== null; +} + +/** + * Is `endpointCtx` — the endpoint context a send callback runs in, from + * `tryGetCurrentAuthEndpointContext()` — the resend, asked over HTTP by a + * caller the endpoint found no session for? Read inside the endpoint only: + * before it, the field is `null` for every caller. + */ +export function isVisitorResendInEndpointContext(endpointCtx: unknown): boolean { + const ctx = endpointCtx as EndpointLike | undefined; + return isResendOverHttp(ctx) && !ctx?.context?.session; +} + +/** + * The before-hook's half of the rule: while the transport is not live, refuse + * the resend of a visitor who is not signed in, with one answer for every + * address. A no-op on every other path, on a call without a `Request`, while + * the transport is live, and for a signed-in caller. + * + * The caller is asked only while the transport is not live, so a deployment + * that mails reads nothing extra here. A read that fails counts as no caller: + * the refusal is the answer every visitor gets anyway. Should this read and + * the endpoint's ever disagree, the endpoint's decides how the send runs + * ({@link isVisitorResendInEndpointContext}), so the resend still answers the + * same whatever the send does. + */ +export async function refuseVisitorResendWithoutLiveTransport( + ctx: any, + deps: { + isEmailDeliveryLive: () => boolean; + resolveActor: (ctx: any) => Promise<{ userId: string } | null>; + }, +): Promise { + if (!isResendOverHttp(ctx)) return; + if (deps.isEmailDeliveryLive()) return; + const actor = await deps.resolveActor(ctx).catch(() => null); + if (actor?.userId) return; + const { APIError } = await import('better-auth/api'); + throw new APIError('BAD_REQUEST', { + code: VISITOR_RESEND_REFUSAL_CODE, + message: VISITOR_RESEND_REFUSAL_MESSAGE, + }); +}