From 8557d3cafd9379c96bf8a695ca923b2c8ecec530 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:46:18 +0000 Subject: [PATCH 1/6] feat(plugin-auth): a sign-up for an existing address answers like a success and mails its holder under open and email_domain, while mail is delivered Ruling 1B on #25040. The /sign-up/email before-hook keeps the explicit refusal unless the posture permits self-registration, the vendor's anti-enumeration shield is on and IEmailService.isConfigured() answers true; the vendor's onExistingUserSignUp hook sends the new auth.existing_account_sign_up template (plugin-email, four locales) in the recipient's stored locale, then the deployment default. Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W Co-authored-by: Claude --- .../plugins/plugin-auth/src/auth-manager.ts | 187 ++++++++++++++++-- .../src/templates/auth-templates.ts | 165 ++++++++++++++++ 2 files changed, 336 insertions(+), 16 deletions(-) diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 32d727067e6..c97d2152a36 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -2132,6 +2132,20 @@ export class AuthManager { ); } }), + // [#25060, ruling 1B on #25040] The mail half of "a sign-up for an + // address that already has an account answers like a success". The + // vendor calls this only when the address already has a row AND its + // anti-enumeration shield is on, after its timing-equalizing password + // hash, and awaits it inline (no background-task handler is set); + // what it throws is caught and logged by the vendor, and the answer is + // its generic success either way (better-auth 1.7.12 + // `routes/sign-up.mjs`, re-read for #25060). So the DECISION cannot + // live here: it is made first, in the `/sign-up/email` before-hook, + // which keeps the explicit refusal wherever this mail could not be + // promised. This hands over the existing row, which is what the + // recipient's stored-locale rung needs. + onExistingUserSignUp: async ({ user }: { user: { id: string; email: string; name?: string | null } }) => + this.sendExistingAccountSignUpNotice(user), }; })(), @@ -2768,10 +2782,11 @@ export class AuthManager { // reason the audience refusal above is — and STRICTLY after it. // // Same shield, its other arm. `shouldReturnGenericDuplicateResponse` - // (sign-up.mjs:163) is on whenever `requireEmailVerification` is on - // OR `autoSignIn === false`, and it guards TWO sites: the 403 catch - // at :235 (what the audience block above steps around) and the - // duplicate pre-check at :199. On the second, the vendor finds the + // (better-auth 1.7.12 sign-up.mjs:155) is on whenever + // `requireEmailVerification` is on OR `autoSignIn === false`, and it + // guards TWO sites: the 403 catch at :227 (what the audience block + // above steps around) and the duplicate pre-check at :192–204. On + // the second, the vendor finds the // existing row, logs it, and returns `buildGenericDuplicateResponse()` // — a 200 carrying a freshly `generateId()`-ed user that is never // written — INSTEAD of throwing USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL. @@ -2797,19 +2812,29 @@ export class AuthManager { // the gate, `invite_only` is untouched: a stranger still gets // SELF_REGISTRATION_CLOSED and learns nothing. // - // UNCONDITIONAL, not a mirror of the vendor's predicate: the platform - // owns this refusal at one seam, so "an address that already has a - // `sys_user` row is refused" is one fact under every posture and every - // verification setting — rather than a contract that is a function of - // a vendor internal, and that a widened shield would silently reopen. - // Nothing is lost when the shield is off and the vendor would have - // answered: the code and message are the vendor's OWN constant, so the - // two lanes are byte-identical by construction rather than by copying. - // (The vendor's `onExistingUserSignUp` hook is not wired anywhere in - // this repo, and its timing-equalizing password hash equalizes against - // an oracle this 422 states outright.) + // [#25060, ruling 1B on #25040] ONE exception, decided here and + // nowhere else ({@link existingAddressSignUpAnswersSuccess}): under + // `open` and `email_domain`, where the vendor's shield is on and a + // mail transport is live, the request FALLS THROUGH. The vendor then + // finds the row, answers its generic success — the same shape a new + // sign-up gets there, and no row, credential or session is written — + // and its `onExistingUserSignUp` hook mails the address's holder + // ({@link sendExistingAccountSignUpNotice}). Every other case keeps + // the explicit refusal: `invite_only`, a shield that is off (a new + // sign-up's answer there carries a session, which an existing + // address cannot mirror without one for someone else's account), and + // a transport that is not live (a success with no mail is the black + // hole this refusal replaced). The refusal is the platform's at this + // one seam, so it never depends on the vendor also refusing; where + // the vendor would have answered, its code and message are the + // vendor's OWN constant, so the two lanes are byte-identical by + // construction rather than by copying. const signUpAddress = typeof ctx?.body?.email === 'string' ? ctx.body.email : ''; - if (signUpAddress && (await this.hasExistingUserFor(signUpAddress))) { + if ( + signUpAddress + && (await this.hasExistingUserFor(signUpAddress)) + && !this.existingAddressSignUpAnswersSuccess(ctx?.context?.options?.emailAndPassword) + ) { const { APIError, BASE_ERROR_CODES } = await import('@better-auth/core/error'); throw APIError.from( 'UNPROCESSABLE_ENTITY', @@ -5563,6 +5588,136 @@ export class AuthManager { } } + /** + * [#25060, ruling 1B on #25040] May a sign-up for an address that already has + * an account answer like a successful sign-up, instead of the explicit + * refusal? Asked by the `/sign-up/email` before-hook only once the audience + * gate admitted the caller and {@link hasExistingUserFor} found the row. All + * three must hold, and each one that fails keeps today's refusal: + * + * 1. **The posture permits self-registration** (`open`, `email_domain`). + * `invite_only` is unchanged by the ruling, and so is every other path a + * posture admits there. + * 2. **The vendor's anti-enumeration shield is on** — read off the options + * the vendor itself reads for it (`requireEmailVerification` on, or + * `autoSignIn` off). Only there is a new sign-up's answer free of a + * session, so only there can an existing address get the same shape; and + * only there does the vendor call `onExistingUserSignUp`, the mail half. + * Under `open` with verification declared off a new sign-up is signed in + * at once, and mirroring that would mean a session for someone else's + * account. (Where the shield is off the vendor would refuse identically + * anyway; the refusal stays ours so it never rests on that.) + * 3. **Mail is actually delivered** ({@link existingAddressSignUpMailable}). + * The ruling's precondition: the success answer is honest only because a + * mail follows it, and a success with no mail is the black hole the + * refusal replaced. + * + * A pure read: nothing is cached, so a posture or transport change between + * two requests is honoured on the next one. + */ + private existingAddressSignUpAnswersSuccess( + vendorEmailAndPassword: { requireEmailVerification?: boolean; autoSignIn?: boolean } | undefined, + ): boolean { + const shieldOn = + vendorEmailAndPassword?.requireEmailVerification === true || vendorEmailAndPassword?.autoSignIn === false; + return shieldOn && this.existingAddressSignUpMailable(); + } + + /** + * [#25060] The posture and transport half of + * {@link existingAddressSignUpAnswersSuccess}, which the mail send asks again + * on its own: the vendor's hook also fires on the one path that never reached + * that decision (an unanswerable {@link hasExistingUserFor} falls through to + * the vendor), and there it must not mail under `invite_only` either. + */ + private existingAddressSignUpMailable(): boolean { + return audiencePermitsSelfRegistration(this.getAudience().posture) && this.isEmailDeliveryLive(); + } + + /** + * [#25060] Is mail sent through the wired email service actually delivered? + * The one reader of `IEmailService.isConfigured()` — the deliverability fact + * the contract gives the package that owns the transport (plugin-email + * answers `false` for its `log` transport in every environment, and follows a + * transport swapped from the mail settings at runtime). Read at call time, + * never cached at boot. + * + * ⛔ An implementation WITHOUT the member is NOT live: the contract says an + * absent member is unknown, and the flow asking — a success answer that only + * a mail makes honest — must fail to its refusal on unknown. Hence not + * {@link hasEmailTransport} / {@link isEmailServiceAvailable}, which answer + * "a service object is wired" — true on every default boot, whose transport + * logs instead of sending. A throwing member is unknown too. + */ + private isEmailDeliveryLive(): boolean { + const email = this.getEmailService(); + if (!email || typeof email.isConfigured !== 'function') return false; + try { + return email.isConfigured() === true; + } catch { + return false; + } + } + + /** + * [#25060, ruling 1B on #25040] Tell the holder of an address that someone + * tried to sign up with it — the mail that makes the success answer + * {@link existingAddressSignUpAnswersSuccess} allowed honest. Wired as the + * vendor's `emailAndPassword.onExistingUserSignUp`, which hands over the + * existing row. + * + * - **Template:** `auth.existing_account_sign_up`, declared beside every + * other auth mail in plugin-email's built-in templates (four locales), so a + * deployment overrides it the same way. + * - **Locale:** the recipient's stored `sys_user.locale`, then the deployment + * default — ⛔ no request rung: the requester is not the recipient, and an + * anonymous caller's browser language must not choose the language of a + * mail to someone else (the invitation sends' ladder). + * - **Links:** the Console's sign-in and forgot-password pages. No token is + * minted: a reset link here would be a credential sent because a stranger + * asked. + * + * ⛔ Never throws. The answer is already decided and the vendor would only + * log a throw; a send that fails is reported here at error level, once per + * request, because the caller was told success on the promise of this mail. + * That residual is the same one a failed verification send leaves for a new + * address. + */ + private async sendExistingAccountSignUpNotice(user: { id: string; email: string; name?: string | null }): Promise { + if (!this.existingAddressSignUpMailable()) return; + // A placeholder address (a phone-only account) has no mailbox to tell. + if (!user?.email || isPlaceholderEmail(user.email)) return; + const email = this.getEmailService(); + if (!email) return; + try { + const storedLocale = user.id ? await this.storedRecipientLocale({ id: user.id }) : undefined; + const result = await email.sendTemplate({ + template: 'auth.existing_account_sign_up', + to: { address: user.email, ...(user.name ? { name: user.name } : {}) }, + ...this.emailLocaleArg(undefined, storedLocale), + data: { + user: { name: user.name || user.email, email: user.email, ...(user.id ? { id: user.id } : {}) }, + signInUrl: this.getConsolePageUrl('/login'), + resetPasswordUrl: this.getConsolePageUrl('/forgot-password'), + appName: this.getAppName(), + }, + relatedObject: 'sys_user', + ...(user.id ? { relatedId: user.id } : {}), + // [ADR-0131 D7, Q2 A] A notice to the account holder: identity mail. + identityMail: true, + }); + if (result?.status === 'failed') throw new Error(result.error ?? 'delivery failed'); + } catch (error) { + this.audienceLogError( + '[auth] a sign-up for an address that already has an account was answered as a success, but ' + + 'the mail telling that account\'s holder could NOT be sent — they will not learn of the attempt, ' + + 'and the person signing up gets no mail pointing them to sign-in or password reset. Check the ' + + 'mail transport (Setup › Mail, "Send test email").', + { userId: user.id, error: error instanceof Error ? error.message : String(error) }, + ); + } + } + /** * [ADR-0131 D3/D4] The declared set would GRANT: the security catalog holds * it and the activation ledger does not switch it off — the resolver's own diff --git a/packages/plugins/plugin-email/src/templates/auth-templates.ts b/packages/plugins/plugin-email/src/templates/auth-templates.ts index 83201c307fa..7041c87cdeb 100644 --- a/packages/plugins/plugin-email/src/templates/auth-templates.ts +++ b/packages/plugins/plugin-email/src/templates/auth-templates.ts @@ -800,6 +800,170 @@ export const AUTH_EMAIL_CHANGE_NOTICE_TEMPLATES: EmailTemplate[] = [ AUTH_EMAIL_CHANGE_NOTICE_TEMPLATE_ES_ES, ]; +// ─────────────────────────────────────────────────────────────────────────── +// auth.existing_account_sign_up — a sign-up for an address that has an account +// ─────────────────────────────────────────────────────────────────────────── +/** + * Sent to an address that already has an account when someone signs up with + * it, under the `open` and `email_domain` audience postures (ruling 1B on + * #25040). The sign-up answers exactly as a new one would, so it does not tell + * the caller the address is registered; this mail tells the address's holder + * instead, and points them to sign-in or password reset. `@objectstack/plugin-auth` + * sends it only while the mail transport really delivers — otherwise the + * sign-up keeps its explicit refusal. + * + * What the wording is bound by: + * + * - **It reports an attempt, nothing more.** No account was created and + * nothing on the existing one changed, and the body says both, because the + * reader who did not sign up needs to know their account is untouched. + * - ⛔ **No token, no one-click reset.** `{{resetPasswordUrl}}` is the + * Console's forgot-password PAGE, where the holder asks for a reset + * themselves: a reset credential mailed because a stranger typed this + * address would be a reset nobody asked for. + * - The default footer stands ("if this wasn't you, you can safely ignore + * this message") because it is true here: ignoring it changes nothing. + * + * `{{user.email}}` is the RECIPIENT — the existing account's address. + */ +const EXISTING_ACCOUNT_SIGN_UP_VARIABLES: EmailTemplate['variables'] = [ + { name: 'user.name', type: 'string', required: false, description: 'Recipient display name' }, + { name: 'user.email', type: 'string', required: true, description: 'The existing account\'s email address — the recipient' }, + { name: 'signInUrl', type: 'url', required: true, description: 'The sign-in page' }, + { name: 'resetPasswordUrl', type: 'url', required: true, description: 'The forgot-password page (no token)' }, + { name: 'appName', type: 'string', required: false, description: 'Product/app name (brand override)' }, +]; + +const linkStyles = 'color:#2563eb'; + +export const AUTH_EXISTING_ACCOUNT_SIGN_UP_TEMPLATE: EmailTemplate = { + name: 'auth.existing_account_sign_up', + label: 'Sign-up Attempt for an Existing Account', + locale: 'en-US', + subject: 'You already have a {{appName}} account', + bodyHtml: wrap('You already have an account', ` +

Hi {{user.name}},

+

Someone just tried to create a new {{appName}} account with {{user.email}}. This address already has an account, so no new account was created and nothing on your account has changed.

+

If it was you, sign in with your existing account:

+

Sign in

+

Forgotten your password? Reset it here.

+

Or copy and paste these URLs into your browser:
Sign in: {{signInUrl}}
Reset your password: {{resetPasswordUrl}}

+`), + bodyText: `Hi {{user.name}}, + +Someone just tried to create a new {{appName}} account with {{user.email}}. +This address already has an account, so no new account was created and nothing +on your account has changed. + +If it was you, sign in with your existing account: +{{signInUrl}} + +Forgotten your password? Reset it here: +{{resetPasswordUrl}} + +If this wasn't you, you can safely ignore this message.`, + variables: EXISTING_ACCOUNT_SIGN_UP_VARIABLES, + active: true, + description: 'Sent to an address that already has an account when someone signs up with it (audience postures open and email_domain, while mail is delivered), so the sign-up answer does not reveal the account and its holder learns of the attempt.', +}; + +export const AUTH_EXISTING_ACCOUNT_SIGN_UP_TEMPLATE_ZH_CN: EmailTemplate = { + ...AUTH_EXISTING_ACCOUNT_SIGN_UP_TEMPLATE, + locale: 'zh-CN', + label: '已有账号的注册尝试', + subject: '您已拥有 {{appName}} 账号', + bodyHtml: wrap('您已拥有账号', ` +

{{user.name}} 您好:

+

刚刚有人尝试使用 {{user.email}} 注册新的 {{appName}} 账号。该邮箱地址已有账号,因此没有创建新账号,您的账号也没有任何变化。

+

如果这是您本人的操作,请使用现有账号登录:

+

登录

+

忘记密码?点此重置密码。

+

或将以下链接复制到浏览器中打开:
登录:{{signInUrl}}
重置密码:{{resetPasswordUrl}}

+`, FOOTER_ZH_CN), + bodyText: `{{user.name}} 您好: + +刚刚有人尝试使用 {{user.email}} 注册新的 {{appName}} 账号。该邮箱地址已有账号, +因此没有创建新账号,您的账号也没有任何变化。 + +如果这是您本人的操作,请使用现有账号登录: +{{signInUrl}} + +忘记密码?请在此重置密码: +{{resetPasswordUrl}} + +如果这不是您本人的操作,可以放心忽略本邮件。`, + description: '当有人使用已有账号的邮箱地址注册时发送至该地址(open 与 email_domain 准入模式,且邮件确实可投递时),使注册应答不透露该账号的存在,并让账号持有人得知这次尝试。', +}; + +export const AUTH_EXISTING_ACCOUNT_SIGN_UP_TEMPLATE_JA_JP: EmailTemplate = { + ...AUTH_EXISTING_ACCOUNT_SIGN_UP_TEMPLATE, + locale: 'ja-JP', + label: '既存アカウントのアドレスでの登録の試み', + subject: '{{appName}} のアカウントはすでにお持ちです', + bodyHtml: wrap('アカウントはすでにお持ちです', ` +

{{user.name}} 様

+

先ほど、{{user.email}} で {{appName}} の新しいアカウントを作成しようとする操作がありました。このアドレスにはすでにアカウントがあるため、新しいアカウントは作成されておらず、お客様のアカウントにも変更はありません。

+

ご本人による操作の場合は、既存のアカウントでサインインしてください。

+

サインイン

+

パスワードをお忘れの場合は、こちらから再設定してください。

+

または、次の URL をブラウザーに貼り付けてください:
サインイン: {{signInUrl}}
パスワードの再設定: {{resetPasswordUrl}}

+`, FOOTER_JA_JP), + bodyText: `{{user.name}} 様 + +先ほど、{{user.email}} で {{appName}} の新しいアカウントを作成しようとする操作が +ありました。このアドレスにはすでにアカウントがあるため、新しいアカウントは作成 +されておらず、お客様のアカウントにも変更はありません。 + +ご本人による操作の場合は、既存のアカウントでサインインしてください: +{{signInUrl}} + +パスワードをお忘れの場合は、こちらから再設定してください: +{{resetPasswordUrl}} + +心当たりがない場合は、本メールを破棄していただいて問題ありません。`, + description: '既存アカウントのメールアドレスで登録が試みられたときに、そのアドレス宛に送信されます(オーディエンスのポスチャーが open または email_domain で、メールが実際に配信される場合)。登録の応答からアカウントの存在が分からないようにしつつ、アカウントの所有者に試みを知らせます。', +}; + +export const AUTH_EXISTING_ACCOUNT_SIGN_UP_TEMPLATE_ES_ES: EmailTemplate = { + ...AUTH_EXISTING_ACCOUNT_SIGN_UP_TEMPLATE, + locale: 'es-ES', + label: 'Intento de registro con una cuenta existente', + subject: 'Ya tienes una cuenta de {{appName}}', + bodyHtml: wrap('Ya tienes una cuenta', ` +

Hola {{user.name}}:

+

Alguien acaba de intentar crear una cuenta nueva de {{appName}} con {{user.email}}. Esta dirección ya tiene una cuenta, así que no se ha creado ninguna cuenta nueva y no ha cambiado nada en tu cuenta.

+

Si has sido tú, inicia sesión con tu cuenta actual:

+

Iniciar sesión

+

¿Has olvidado tu contraseña? Restablécela aquí.

+

O copia y pega estas URL en tu navegador:
Iniciar sesión: {{signInUrl}}
Restablecer la contraseña: {{resetPasswordUrl}}

+`, FOOTER_ES_ES), + bodyText: `Hola {{user.name}}: + +Alguien acaba de intentar crear una cuenta nueva de {{appName}} con +{{user.email}}. Esta dirección ya tiene una cuenta, así que no se ha creado +ninguna cuenta nueva y no ha cambiado nada en tu cuenta. + +Si has sido tú, inicia sesión con tu cuenta actual: +{{signInUrl}} + +¿Has olvidado tu contraseña? Restablécela aquí: +{{resetPasswordUrl}} + +Si no has sido tú, puedes ignorar este mensaje con tranquilidad.`, + description: 'Se envía a una dirección que ya tiene una cuenta cuando alguien se registra con ella (posturas de audiencia open y email_domain, mientras el correo se entrega de verdad), para que la respuesta del registro no revele la cuenta y su titular sepa del intento.', +}; + +/** + * Every locale row of the existing-account sign-up notice, in one list so the + * `(name, locale)` set is enumerable rather than re-derived per call site. + */ +export const AUTH_EXISTING_ACCOUNT_SIGN_UP_TEMPLATES: EmailTemplate[] = [ + AUTH_EXISTING_ACCOUNT_SIGN_UP_TEMPLATE, + AUTH_EXISTING_ACCOUNT_SIGN_UP_TEMPLATE_ZH_CN, + AUTH_EXISTING_ACCOUNT_SIGN_UP_TEMPLATE_JA_JP, + AUTH_EXISTING_ACCOUNT_SIGN_UP_TEMPLATE_ES_ES, +]; + /** * Every built-in auth template, in every supported locale (#8195). * @@ -817,4 +981,5 @@ export const BUILTIN_AUTH_TEMPLATES: EmailTemplate[] = [ ...AUTH_ACCOUNT_INVITATION_TEMPLATES, ...AUTH_TWO_FACTOR_OTP_TEMPLATES, ...AUTH_EMAIL_CHANGE_NOTICE_TEMPLATES, + ...AUTH_EXISTING_ACCOUNT_SIGN_UP_TEMPLATES, ]; From 60aec2a856fca003f007f3674538ab8b8c14c4ca Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 22:51:55 +0000 Subject: [PATCH 2/6] test(plugin-auth): pin ruling 1B's three cases on a real engine; the after-hook stamps nothing on the answer's rowless user id Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W Co-authored-by: Claude --- .../plugins/plugin-auth/src/auth-manager.ts | 28 +- ...nup-existing-address-success-shape.test.ts | 421 ++++++++++++++++++ 2 files changed, 438 insertions(+), 11 deletions(-) create mode 100644 packages/plugins/plugin-auth/src/signup-existing-address-success-shape.test.ts diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index c97d2152a36..13a334a39e8 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -2830,16 +2830,18 @@ export class AuthManager { // vendor's OWN constant, so the two lanes are byte-identical by // construction rather than by copying. const signUpAddress = typeof ctx?.body?.email === 'string' ? ctx.body.email : ''; - if ( - signUpAddress - && (await this.hasExistingUserFor(signUpAddress)) - && !this.existingAddressSignUpAnswersSuccess(ctx?.context?.options?.emailAndPassword) - ) { - const { APIError, BASE_ERROR_CODES } = await import('@better-auth/core/error'); - throw APIError.from( - 'UNPROCESSABLE_ENTITY', - BASE_ERROR_CODES.USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL, - ); + if (signUpAddress && (await this.hasExistingUserFor(signUpAddress))) { + if (!this.existingAddressSignUpAnswersSuccess(ctx?.context?.options?.emailAndPassword)) { + const { APIError, BASE_ERROR_CODES } = await import('@better-auth/core/error'); + throw APIError.from( + 'UNPROCESSABLE_ENTITY', + BASE_ERROR_CODES.USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL, + ); + } + // The answer will carry a user id that no row holds: tell the + // after-hook, so it stamps nothing on that id (it would report a + // lost write for a user that was never created). + if (ctx?.context) ctx.context.__osExistingAddressSignUp = true; } // fall through — the vendor still decides everything it owns } @@ -3053,7 +3055,11 @@ export class AuthManager { } catch { signupOk = !(ctx?.context?.returned instanceof Error); } - if (signupOk && typeof newUserId === 'string') await this.stampPasswordChangedAt(newUserId); + // [#25060] Not for an existing address answered as a success: that + // answer's user id belongs to no row, and nothing was created. + if (signupOk && typeof newUserId === 'string' && ctx?.context?.__osExistingAddressSignUp !== true) { + await this.stampPasswordChangedAt(newUserId); + } } const ep = ctx?.context?.options?.emailAndPassword; if (ep && ctx.context.__osDisableSignUpOrig !== undefined) { 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 new file mode 100644 index 00000000000..5c77244504c --- /dev/null +++ b/packages/plugins/plugin-auth/src/signup-existing-address-success-shape.test.ts @@ -0,0 +1,421 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25060, ruling 1B on #25040] Under the `open` and `email_domain` postures, a + * sign-up for an address that already has an account answers the same shape + * as a successful sign-up, and that address receives a mail saying it already + * has an account — WHILE THE MAIL TRANSPORT IS LIVE. Without a live transport + * the explicit refusal stays; `invite_only` is unchanged. + * + * `signup-existing-address-refusal.test.ts` keeps holding the refusal on every + * deployment that wires no email service at all (its harness wires none); this + * file owns the rule's three named pins and the cases that bound them: + * + * - ① an existing address gets the success shape plus the mail (both + * postures), and the shape is measured against a NEW sign-up's on the same + * deployment rather than written down, so a vendor change in either answer + * reds here; + * - ② no live transport keeps the refusal: `isConfigured()` false (the `log` + * transport), the member absent (an implementation that predates it), or a + * member that throws — and a transport swapped at runtime is honoured on the + * next request, because the reader is asked at sign-up time; + * - ③ `invite_only` is unchanged: an invited holder of an existing address + * is still refused with the shield on, and a stranger still learns nothing; + * - ④ `open` with verification declared off keeps the refusal: a new sign-up + * is signed in at once there, and no session may be issued for someone + * else's account; + * - ⑤ the mail's language: the recipient's stored locale, then the + * deployment default — never the requester's browser language; + * - ⑥ a send that fails after the decision still answers the success (the + * accepted residual) and says so at error level. + * + * Real engine for the same reason the sibling file gives: the uniqueness probe + * and the row the vendor finds both live below any double. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { AuthManager } from './auth-manager.js'; +import { authIdentityObjects } from './manifest.js'; +import { SELF_REGISTRATION_CLOSED } from './audience-posture.js'; +import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; +import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; + +const BASE = 'http://localhost:3000'; +const AUTH = `${BASE}/api/v1/auth`; +const SECRET = 'test-secret-at-least-32-chars-long-25060'; +const PASSWORD = 'S3cure!Passw0rd-25060'; +const EXISTING = 'alice@corp.example'; +const ALREADY_EXISTS = 'USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL'; +const NOTICE = 'auth.existing_account_sign_up'; + +/** Declared locally with only the columns the audience gate reads (the sibling file's precedent). */ +const sysPermissionSet = { + name: 'sys_permission_set', + label: 'Permission Set', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + name: { name: 'name', type: 'text' as const }, + label: { name: 'label', type: 'text' as const }, + active: { name: 'active', type: 'boolean' as const }, + }, +}; +const sysUserPermissionSet = { + name: 'sys_user_permission_set', + label: 'User Permission Set', + fields: { + id: { name: 'id', type: 'text' as const, primaryKey: true }, + user_id: { name: 'user_id', type: 'text' as const }, + permission_set_id: { name: 'permission_set_id', type: 'text' as const }, + organization_id: { name: 'organization_id', type: 'text' as const }, + }, +}; + +const engines: ObjectQL[] = []; +afterEach(async () => { + while (engines.length) { + const engine = engines.pop(); + try { + await (engine as unknown as { destroy?(): Promise })?.destroy?.(); + } catch { + /* noop */ + } + } +}); + +const SYSTEM = { context: { isSystem: true } } as never; + +async function bootEngine(): Promise { + const engine = new ObjectQL(); + engines.push(engine); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + for (const object of authIdentityObjects) { + engine.registry.registerObject(object as never, '@objectstack/plugin-auth'); + } + engine.registry.registerObject(sysPermissionSet as never, '@objectstack/plugin-security'); + engine.registry.registerObject(sysUserPermissionSet as never, '@objectstack/plugin-security'); + await engine.syncSchemas(); + await relaxEveryRegisteredTenantColumn(engine); + const bound = await bindTestSecurityCatalog(engine, { permissions: [{ name: 'member_default' }] }); + // Alice carries a stored language; Bob carries none. + await engine.insert('sys_user', { id: 'usr_alice', email: EXISTING, name: 'Alice', locale: 'ja-JP' }, SYSTEM); + await engine.insert('sys_user', { id: 'usr_bob', email: 'bob@corp.example', name: 'Bob' }, SYSTEM); + await (engine as any).getDriver('sys_permission_set').create( + 'sys_permission_set', + { id: 'ps_member_default', name: 'member_default', label: 'member_default', active: true }, + ); + return bound; +} + +async function seedPendingInvitation(engine: ObjectQL, email: string): Promise { + await engine.insert( + 'sys_invitation', + { + id: `inv_${Math.random().toString(36).slice(2, 10)}`, + email, + status: 'pending', + organization_id: 'org_25060', + role: 'member', + inviter_id: 'usr_bob', + expires_at: new Date(Date.now() + 3_600_000), + }, + SYSTEM, + ); +} + +const EMAIL_DOMAIN = { + audience: { posture: 'email_domain', allowedEmailDomains: ['corp.example'], selfRegistrationPermissionSet: 'member_default' }, +}; +const OPEN = { + audience: { posture: 'open', selfRegistrationPermissionSet: 'member_default' }, +}; + +/** + * A recording email service. `live` is what `isConfigured()` answers, read on + * every call so a test can swap it between requests; `'absent'` leaves the + * member off (an implementation that predates it), `'throws'` makes it throw. + */ +function recordingEmail(initial: boolean | 'absent' | 'throws', sendStatus: 'sent' | 'failed' = 'sent') { + const sent: any[] = []; + const state = { live: initial }; + const service: Record = { + async send() { return { id: 'e', status: 'sent' }; }, + async sendTemplate(input: any) { + sent.push(input); + return sendStatus === 'failed' + ? { id: `e${sent.length}`, status: 'failed', error: 'smtp refused (simulated)' } + : { id: `e${sent.length}`, status: 'sent' }; + }, + }; + if (initial !== 'absent') { + service.isConfigured = () => { + if (state.live === 'throws') throw new Error('transport state unreadable (simulated)'); + return state.live === true; + }; + } + return { sent, state, service }; +} + +function makeManager(engine: ObjectQL, email: { service: unknown } | undefined, config: Record = {}) { + const manager = new AuthManager({ secret: SECRET, baseUrl: BASE, dataEngine: engine as never, ...config } as never); + if (email) manager.setEmailService(email.service as never); + return manager; +} + +function signUp(manager: AuthManager, email: string, headers: Record = {}): Promise { + return manager.handleRequest( + new Request(`${AUTH}/sign-up/email`, { + method: 'POST', + headers: { 'content-type': 'application/json', origin: BASE, ...headers }, + body: JSON.stringify({ email, password: PASSWORD, name: 'Someone' }), + }), + ); +} + +async function readAll(engine: ObjectQL, object: string): Promise[]> { + const rows = await engine.find(object, { limit: 100 }, SYSTEM); + return (Array.isArray(rows) ? rows : []) as Record[]; +} + +/** Count every insert attempt that reaches the engine, by object name. */ +function instrumentInserts(engine: ObjectQL): string[] { + const calls: string[] = []; + const original = (engine as any).insert.bind(engine); + (engine as any).insert = (...args: any[]) => { + calls.push(String(args[0])); + return original(...args); + }; + return calls; +} + +/** Status, header names, and the body reduced to its members and their types. */ +async function shapeOf(res: Response) { + const body = (await res.clone().json()) as Record; + const typeOf = (v: unknown) => (v === null ? 'null' : typeof v); + const userShape = Object.fromEntries( + Object.entries((body.user ?? {}) as Record).map(([k, v]) => [k, typeOf(v)]).sort(), + ); + return { + status: res.status, + headerNames: [...res.headers.keys()].sort(), + keys: Object.keys(body).sort(), + token: body.token, + userShape, + emailVerified: body.user?.emailVerified, + }; +} + +/** A logger that records what the manager writes at error and at warn level. */ +function recordingLogger(): { errors: string[]; warnings: string[]; logger: Record } { + const errors: string[] = []; + const warnings: string[] = []; + return { + errors, + warnings, + logger: { + error: (m: string) => { errors.push(String(m)); }, + warn: (m: string) => { warnings.push(String(m)); }, + info: () => {}, + debug: () => {}, + }, + }; +} + +async function expectRefused(res: Response) { + expect(res.status, `expected the explicit refusal, got: ${await res.clone().text()}`).toBe(422); + expect(((await res.json()) as { code?: string }).code).toBe(ALREADY_EXISTS); +} + +describe('[#25060] ① an existing address gets the success shape plus the mail', () => { + for (const [label, posture] of [['email_domain', EMAIL_DOMAIN], ['open (verification on by default)', OPEN]] as const) { + it(`${label}: the same shape a new sign-up gets, nothing written, one mail to the address's holder`, async () => { + const engine = await bootEngine(); + const email = recordingEmail(true); + const { errors, warnings, logger } = recordingLogger(); + const manager = makeManager(engine, email, { ...posture, logger }); + await manager.getAuthInstance(); + + // The reference: a NEW address on the same deployment. + const fresh = await signUp(manager, 'dave@corp.example'); + expect(fresh.status, `a new registrant was refused: ${await fresh.clone().text()}`).toBeLessThan(300); + const freshShape = await shapeOf(fresh); + expect(email.sent.map((s) => s.template)).toEqual(['auth.verify_email']); + email.sent.length = 0; + errors.length = 0; + warnings.length = 0; + + const inserts = instrumentInserts(engine); + const existing = await signUp(manager, EXISTING); + const existingShape = await shapeOf(existing); + + // The answer cannot be told apart from a new sign-up's by its shape. + expect(existingShape).toEqual(freshShape); + expect(existingShape.token).toBeNull(); + expect(existingShape.headerNames).not.toContain('set-cookie'); + + // Nothing was written for the existing address: no row, no credential, no session. + expect(inserts).toEqual([]); + expect((await readAll(engine, 'sys_user')).filter((u) => u.email === EXISTING).map((u) => u.id)).toEqual(['usr_alice']); + expect((await readAll(engine, 'sys_account')).map((a) => a.user_id)).not.toContain('usr_alice'); + expect((await readAll(engine, 'sys_session')).map((s) => s.user_id)).not.toContain('usr_alice'); + + // Exactly one mail, to the existing address, through the template path. + expect(email.sent).toHaveLength(1); + const mail = email.sent[0]; + expect(mail.template).toBe(NOTICE); + expect(mail.to.address).toBe(EXISTING); + expect(mail.relatedObject).toBe('sys_user'); + expect(mail.relatedId).toBe('usr_alice'); + expect(mail.identityMail).toBe(true); + expect(mail.data.user.email).toBe(EXISTING); + expect(mail.data.signInUrl).toBe(`${BASE}/_console/login`); + expect(mail.data.resetPasswordUrl).toBe(`${BASE}/_console/forgot-password`); + // No credential rides this mail. + expect(JSON.stringify(mail.data)).not.toMatch(/token/i); + // And the success path is a normal outcome, not a degradation: nothing + // downstream of the answer (the sign-up after-hook included) reports a + // failure for the user id that belongs to no row. + expect(errors, `logged: ${JSON.stringify(errors)}`).toEqual([]); + expect(warnings, `logged: ${JSON.stringify(warnings)}`).toEqual([]); + }); + } + + it('the shield\'s other trigger: open with verification declared off but autoSignIn off also answers the success shape', async () => { + // A new sign-up's answer carries no session there either, so the shape + // can be mirrored — the predicate is the vendor's shield, not the flag. + const engine = await bootEngine(); + const email = recordingEmail(true); + const manager = makeManager(engine, email, { + ...OPEN, + emailAndPassword: { requireEmailVerification: false, autoSignIn: false }, + }); + + const fresh = await signUp(manager, 'dave@corp.example'); + const existing = await signUp(manager, EXISTING); + + expect(await shapeOf(existing)).toEqual(await shapeOf(fresh)); + expect(email.sent.filter((s) => s.template === NOTICE).map((s) => s.to.address)).toEqual([EXISTING]); + }); +}); + +describe('[#25060] ② no live transport keeps the refusal', () => { + for (const [label, live] of [ + ['isConfigured() answers false (the log transport)', false], + ['the service has no isConfigured member', 'absent'], + ['isConfigured() throws', 'throws'], + ] as const) { + it(`${label}: refused explicitly, and nothing is mailed`, async () => { + const engine = await bootEngine(); + const email = recordingEmail(live); + const manager = makeManager(engine, email, EMAIL_DOMAIN); + + await expectRefused(await signUp(manager, EXISTING)); + expect(email.sent).toEqual([]); + }); + } + + it('a transport swapped at runtime is honoured on the next request, in both directions', async () => { + const engine = await bootEngine(); + const email = recordingEmail(false); + const manager = makeManager(engine, email, OPEN); + + await expectRefused(await signUp(manager, EXISTING)); + email.state.live = true; + const live = await signUp(manager, EXISTING); + expect(live.status, await live.clone().text()).toBeLessThan(300); + expect(email.sent.map((s) => s.template)).toEqual([NOTICE]); + email.state.live = false; + await expectRefused(await signUp(manager, EXISTING)); + expect(email.sent).toHaveLength(1); + }); +}); + +describe('[#25060] ③ invite_only is unchanged', () => { + it('an invited holder of an existing address is refused with the shield on and a live transport, and nothing is mailed', async () => { + const engine = await bootEngine(); + await seedPendingInvitation(engine, EXISTING); + const email = recordingEmail(true); + const manager = makeManager(engine, email, { emailAndPassword: { requireEmailVerification: true } }); + + await expectRefused(await signUp(manager, EXISTING)); + expect(email.sent).toEqual([]); + }); + + it('an uninvited stranger still learns nothing: a registered and an unknown address answer alike', async () => { + const engine = await bootEngine(); + const email = recordingEmail(true); + const manager = makeManager(engine, email, { emailAndPassword: { requireEmailVerification: true } }); + + const real = await signUp(manager, EXISTING); + const unknown = await signUp(manager, 'nobody@corp.example'); + + expect(real.status).toBe(403); + expect(unknown.status).toBe(403); + expect(((await real.json()) as { code?: string }).code).toBe(SELF_REGISTRATION_CLOSED); + expect(((await unknown.json()) as { code?: string }).code).toBe(SELF_REGISTRATION_CLOSED); + expect(email.sent).toEqual([]); + }); +}); + +describe('[#25060] ④ open with verification declared off keeps the refusal', () => { + it('refused explicitly: no session is issued for the existing account and nothing is mailed', async () => { + const engine = await bootEngine(); + const email = recordingEmail(true); + const manager = makeManager(engine, email, { ...OPEN, emailAndPassword: { requireEmailVerification: false } }); + + // The configuration this case is about: a new sign-up is signed in at once. + const fresh = await signUp(manager, 'dave@corp.example'); + expect(typeof ((await fresh.clone().json()) as { token?: unknown }).token).toBe('string'); + + const res = await signUp(manager, EXISTING); + expect(res.headers.get('set-cookie')).toBeNull(); + await expectRefused(res); + expect((await readAll(engine, 'sys_session')).map((s) => s.user_id)).not.toContain('usr_alice'); + expect(email.sent.filter((s) => s.template === NOTICE)).toEqual([]); + }); +}); + +describe('[#25060] ⑤ the mail is in the recipient\'s language', () => { + it('the recipient\'s stored locale outranks the deployment default and the requester\'s Accept-Language', async () => { + const engine = await bootEngine(); + const email = recordingEmail(true); + const manager = makeManager(engine, email, EMAIL_DOMAIN); + manager.setDefaultEmailLocale('zh-CN'); + + await signUp(manager, EXISTING, { 'accept-language': 'es-ES,es;q=0.9' }); + + expect(email.sent.map((s) => [s.template, s.locale])).toEqual([[NOTICE, 'ja-JP']]); + }); + + it('with no stored locale it is the deployment default — never the requester\'s Accept-Language', async () => { + const engine = await bootEngine(); + const email = recordingEmail(true); + const manager = makeManager(engine, email, EMAIL_DOMAIN); + manager.setDefaultEmailLocale('zh-CN'); + + await signUp(manager, 'bob@corp.example', { 'accept-language': 'es-ES,es;q=0.9' }); + + expect(email.sent.map((s) => [s.template, s.to.address, s.locale])).toEqual([[NOTICE, 'bob@corp.example', 'zh-CN']]); + }); +}); + +describe('[#25060] ⑥ a send that fails after the decision', () => { + it('still answers the success shape, and says at error level that the mail was lost', async () => { + const engine = await bootEngine(); + const email = recordingEmail(true, 'failed'); + const { errors, logger } = recordingLogger(); + const manager = makeManager(engine, email, { ...EMAIL_DOMAIN, logger }); + + const res = await signUp(manager, EXISTING); + + expect(res.status, await res.clone().text()).toBeLessThan(300); + expect(email.sent.map((s) => s.template)).toEqual([NOTICE]); + const named = errors.filter((l) => l.includes('could NOT be sent')); + expect(named, `logged: ${JSON.stringify(errors)}`).toHaveLength(1); + }); +}); From 193af6398220daf593c84a726fc6b6450ce4bf40 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 23:18:25 +0000 Subject: [PATCH 3/6] test(plugin-email): auth.existing_account_sign_up in all four locales; changeset Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W Co-authored-by: Claude --- .changeset/25060-signup-existing-address.md | 22 ++++ .../signup-existing-address-refusal.test.ts | 9 ++ .../src/auth-templates-locales.test.ts | 118 ++++++++++++++++++ .../src/templates/auth-templates.ts | 2 +- 4 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 .changeset/25060-signup-existing-address.md diff --git a/.changeset/25060-signup-existing-address.md b/.changeset/25060-signup-existing-address.md new file mode 100644 index 00000000000..29a6b2817fe --- /dev/null +++ b/.changeset/25060-signup-existing-address.md @@ -0,0 +1,22 @@ +--- +'@objectstack/plugin-auth': patch +'@objectstack/plugin-email': patch +--- + +feat(plugin-auth, plugin-email): under the `open` and `email_domain` postures, a sign-up for an address that already has an account answers like a new sign-up and mails that address, while mail is really delivered + +Clause-②: yes +Metadata-protocol: none + +The public email-and-password sign-up changes its answer in one case. Under the `open` and `email_domain` audience postures, a sign-up for an address that already has an account was refused explicitly. Now, when the deployment's mail transport really delivers, it answers exactly as a successful sign-up does on that deployment, and the address's holder receives a mail saying the address already has an account, with a link to sign in and a link to reset the password. Nothing is written: no new user, credential or session. The caller can no longer tell from the answer whether the address was registered. + +- **When the explicit refusal stays, unchanged.** + - The mail would not be delivered: the email service says its transport is not live (`IEmailService.isConfigured()` answers `false`, which plugin-email answers for its `log` transport in every environment), the email service does not implement `isConfigured()`, or no email service is wired. A success with no mail behind it is what the refusal replaced, so it is never answered. + - The posture is `invite_only`. A stranger still learns nothing there, and an invited person whose address already has an account is still refused. + - The posture is `open` with email verification declared off (and auto sign-in left on). A new sign-up there is signed in at once, and an existing address cannot get that answer without a session for someone else's account. +- **Read at sign-up time.** The transport is asked on every sign-up, so a transport saved from the Mail settings applies to the next sign-up without a restart. +- **The mail.** plugin-email ships a new built-in template, `auth.existing_account_sign_up`, in `en-US`, `zh-CN`, `ja-JP` and `es-ES`. It is registered like the other built-in auth templates, so an app declaration or a Studio save overrides it. Its variables are `user.name` (optional), `user.email`, `signInUrl`, `resetPasswordUrl` and `appName` (optional). The two links are the Console's sign-in and forgot-password pages and carry no token. `BUILTIN_AUTH_TEMPLATES` grows from 28 rows to 32. +- **The mail's language.** The recipient's stored language (`sys_user.locale`), then the deployment default. The language of the browser that asked is never used, because the person signing up may not be the address's holder. +- **For a client of the sign-up door.** In the configurations above, a screen that showed an "already registered" message on the refusal now receives the success answer instead, and should show its usual "check your email" step. +- **A send that fails after the answer.** The answer is still the success, and the server logs one error-level line saying the mail was not sent. A verification mail that fails for a new address leaves the same gap. +- **Unchanged.** A sign-up for a new address, every refusal the audience posture raises, and every other auth mail. diff --git a/packages/plugins/plugin-auth/src/signup-existing-address-refusal.test.ts b/packages/plugins/plugin-auth/src/signup-existing-address-refusal.test.ts index 58a891f704f..e82a154a808 100644 --- a/packages/plugins/plugin-auth/src/signup-existing-address-refusal.test.ts +++ b/packages/plugins/plugin-auth/src/signup-existing-address-refusal.test.ts @@ -35,6 +35,15 @@ * synthetic 200 — which is what makes "before the uniqueness refusal" a * measurement rather than a reading of vendor source. * + * ## Where the refusal stopped being the whole rule + * + * Ruling 1B on #25040 (#25060) answers an existing address like a success — + * plus a mail to it — under `open` and `email_domain` WHILE the mail transport + * is live (`IEmailService.isConfigured()`). Every case here wires no email + * service at all, which is exactly the deployment that ruling keeps on this + * refusal, so they stand unchanged; the live-transport side, and the + * not-live variants, are `signup-existing-address-success-shape.test.ts`. + * * ## Why a real engine * * The population predicate and the uniqueness check both live below the fake diff --git a/packages/plugins/plugin-email/src/auth-templates-locales.test.ts b/packages/plugins/plugin-email/src/auth-templates-locales.test.ts index 07e44c039ea..2127c7c4405 100644 --- a/packages/plugins/plugin-email/src/auth-templates-locales.test.ts +++ b/packages/plugins/plugin-email/src/auth-templates-locales.test.ts @@ -369,3 +369,121 @@ describe('#24154 — the invitation names the role label, and both faces say the expect(named(text), `${locale} text body`).toEqual(declared); }); }); + +// ── #25060 — auth.existing_account_sign_up, in all four locales ──────────── + +/** + * The mail an address's holder receives when someone signs up with an address + * that already has an account (ruling 1B on #25040; `plugin-auth`'s + * `signup-existing-address-success-shape.test.ts` owns the sender half). Same + * discipline as above: every expectation is an independent literal. + */ +const EXISTING_ACCOUNT = 'auth.existing_account_sign_up'; +const SIGN_IN_URL = 'https://acme.test/_console/login'; +const RESET_PAGE_URL = 'https://acme.test/_console/forgot-password'; +const EXISTING_ACCOUNT_DATA = { + user: { name: 'Ada', email: 'ada@acme.test', id: 'u1' }, + signInUrl: SIGN_IN_URL, + resetPasswordUrl: RESET_PAGE_URL, + appName: 'Acme', +}; + +async function renderExistingAccount(locale: Locale | undefined, data: Record = EXISTING_ACCOUNT_DATA) { + const transport = new CaptureTransport(); + const svc = new EmailService({ transport, defaultFrom: { address: 'no-reply@acme.test' }, templateLoader: seededLoader }); + await svc.sendTemplate({ template: EXISTING_ACCOUNT, to: 'ada@acme.test', ...(locale ? { locale } : {}), data }); + return transport; +} + +const EXISTING_ACCOUNT_SUBJECT: Record = { + 'en-US': 'You already have an account on Acme', + 'zh-CN': '您已拥有 Acme 账号', + 'ja-JP': 'Acme のアカウントはすでにお持ちです', + 'es-ES': 'Ya tienes una cuenta de Acme', +}; + +/** What happened, and that nothing changed — the reader who did not sign up needs both. */ +const EXISTING_ACCOUNT_PHRASES: Record = { + 'en-US': ['Someone just tried to create a new Acme account with', 'no new account was created and nothing on your account has changed'], + 'zh-CN': ['刚刚有人尝试使用', '没有创建新账号,您的账号也没有任何变化'], + 'ja-JP': ['の新しいアカウントを作成しようとする操作がありました', '新しいアカウントは作成されておらず、お客様のアカウントにも変更はありません'], + 'es-ES': ['Alguien acaba de intentar crear una cuenta nueva de Acme con', 'no se ha creado ninguna cuenta nueva y no ha cambiado nada en tu cuenta'], +}; + +const EXISTING_ACCOUNT_BUTTON: Record = { + 'en-US': 'Sign in', + 'zh-CN': '登录', + 'ja-JP': 'サインイン', + 'es-ES': 'Iniciar sesión', +}; + +describe('#25060 — auth.existing_account_sign_up is a registered built-in in all four locales', () => { + it('has exactly one row per supported locale, each a valid EmailTemplateDefinition', () => { + const rows = BUILTIN_AUTH_TEMPLATES.filter((t) => t.name === EXISTING_ACCOUNT); + expect(rows.map((t) => t.locale).sort()).toEqual([...SUPPORTED_LOCALES].sort()); + for (const t of rows) { + const parsed = EmailTemplateDefinitionSchema.safeParse(t); + expect(parsed.success, `${t.locale}: ${parsed.error?.message}`).toBe(true); + } + }); + + it('declares exactly what the sender holds — two page links and no token', () => { + for (const t of BUILTIN_AUTH_TEMPLATES.filter((r) => r.name === EXISTING_ACCOUNT)) { + expect((t.variables ?? []).map((v) => [v.name, v.type, v.required === true]), t.locale).toEqual([ + ['user.name', 'string', false], + ['user.email', 'string', true], + ['signInUrl', 'url', true], + ['resetPasswordUrl', 'url', true], + ['appName', 'string', false], + ]); + } + }); +}); + +describe('#25060 — auth.existing_account_sign_up renders its own text in each locale', () => { + for (const locale of SUPPORTED_LOCALES) { + it(`${locale}: subject, what happened, both links and the footer`, async () => { + const sent = (await renderExistingAccount(locale)).sent[0]!; + expect(sent.subject).toBe(EXISTING_ACCOUNT_SUBJECT[locale]); + const html = String(sent.html); + const text = String(sent.text); + const prose = [sent.subject, html, text].join('\n'); + for (const phrase of EXISTING_ACCOUNT_PHRASES[locale]) expect(prose, phrase).toContain(phrase); + expect(prose).toContain('ada@acme.test'); + expect(html).toContain(EXISTING_ACCOUNT_BUTTON[locale]); + expect(html).toContain(`href="${SIGN_IN_URL}"`); + expect(html).toContain(`href="${RESET_PAGE_URL}"`); + expect(text).toContain(SIGN_IN_URL); + expect(text).toContain(RESET_PAGE_URL); + expect(html).toContain(EXPECTED_FOOTER[locale]); + expect(prose).not.toContain('{{'); + }); + } + + it('a zh-CN, ja-JP or es-ES send carries none of the en-US text', async () => { + for (const locale of ['zh-CN', 'ja-JP', 'es-ES'] as const) { + const sent = (await renderExistingAccount(locale)).sent[0]!; + const prose = [sent.subject, sent.html, sent.text].join('\n'); + expect(prose, locale).not.toContain(EXISTING_ACCOUNT_SUBJECT['en-US']); + for (const phrase of EXISTING_ACCOUNT_PHRASES['en-US']) expect(prose, `${locale}: ${phrase}`).not.toContain(phrase); + expect(prose, locale).not.toContain(EXPECTED_FOOTER['en-US']); + } + }); + + it('a send naming no locale resolves en-US — the documented default', async () => { + const sent = (await renderExistingAccount(undefined)).sent[0]!; + expect(sent.subject).toBe(EXISTING_ACCOUNT_SUBJECT['en-US']); + }); + + it.each(['user.email', 'signInUrl', 'resetPasswordUrl'])('without %s the send is refused and nothing reaches the transport', async (name) => { + const data = structuredClone(EXISTING_ACCOUNT_DATA) as Record; + if (name === 'user.email') delete data.user.email; + else delete data[name]; + const transport = new CaptureTransport(); + const svc = new EmailService({ transport, defaultFrom: { address: 'no-reply@acme.test' }, templateLoader: seededLoader }); + await expect( + svc.sendTemplate({ template: EXISTING_ACCOUNT, to: 'ada@acme.test', locale: 'en-US', data }), + ).rejects.toThrow(new RegExp(`^MISSING_VARIABLES: ${name.replace('.', '\\.')}$`)); + expect(transport.sent).toEqual([]); + }); +}); diff --git a/packages/plugins/plugin-email/src/templates/auth-templates.ts b/packages/plugins/plugin-email/src/templates/auth-templates.ts index 7041c87cdeb..bce567cb40a 100644 --- a/packages/plugins/plugin-email/src/templates/auth-templates.ts +++ b/packages/plugins/plugin-email/src/templates/auth-templates.ts @@ -840,7 +840,7 @@ export const AUTH_EXISTING_ACCOUNT_SIGN_UP_TEMPLATE: EmailTemplate = { name: 'auth.existing_account_sign_up', label: 'Sign-up Attempt for an Existing Account', locale: 'en-US', - subject: 'You already have a {{appName}} account', + subject: 'You already have an account on {{appName}}', bodyHtml: wrap('You already have an account', `

Hi {{user.name}},

Someone just tried to create a new {{appName}} account with {{user.email}}. This address already has an account, so no new account was created and nothing on your account has changed.

From f5bdd4843744a5052e033d00eeed8d1fdc2ab6f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 23:26:56 +0000 Subject: [PATCH 4/6] test(plugin-email): pin the new template's registry render beside the 28 seeded-row renders Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W Co-authored-by: Claude --- .../src/builtin-auth-render-identity.test.ts | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/plugins/plugin-email/src/builtin-auth-render-identity.test.ts b/packages/plugins/plugin-email/src/builtin-auth-render-identity.test.ts index 6aa8419a2a7..177592dc10b 100644 --- a/packages/plugins/plugin-email/src/builtin-auth-render-identity.test.ts +++ b/packages/plugins/plugin-email/src/builtin-auth-render-identity.test.ts @@ -40,6 +40,8 @@ const DATA = { magicLinkUrl: 'https://example.test/magic?token=m1&next=/home', acceptUrl: 'https://example.test/accept?invite=i1&next=/home', setPasswordUrl: 'https://example.test/reset-password/s1?callbackURL=%2Fset&next=/home', + signInUrl: 'https://example.test/_console/login?next=/home&from=signup', + resetPasswordUrl: 'https://example.test/_console/forgot-password?next=/home&from=signup', expiresInMinutes: 30, otp: '424242', newEmail: 'ada+new@example.test', @@ -201,6 +203,29 @@ const RENDERED: Record html: 'cc817fe06bbf628a98b1ad06d34ad51fcfc4749b3ee0d2536cad94632cb6edbb', text: '13e8e63f4df3367b410fa2ca7a0acdf47eb3b8b060d1e5e96119f17cfbfd03bd', }, + // Added after the row path was deleted (#25060), so these four were never + // seeded rows: they are captured from this same registry render in the + // commit that added the template, and pin its bytes from then on. + 'auth.existing_account_sign_up@en-US': { + subject: 'You already have an account on Acme & ', + html: 'a202e07c920824c43614981036741ca4928a118ac8b6def0e280110c27447796', + text: '2134c939c462278030be798c4d08289885016e69b2a90ddb67ae5c02e680a585', + }, + 'auth.existing_account_sign_up@zh-CN': { + subject: '您已拥有 Acme & 账号', + html: '6a77dae95e1993b95cc91f4bb371e1889c918e43ab05cd29f3af1074693f1f1d', + text: 'd869d97a3f1febc0a0657d7b462d7a8df0b0706f1a9c50f1226392c03cc0cdc6', + }, + 'auth.existing_account_sign_up@ja-JP': { + subject: 'Acme & のアカウントはすでにお持ちです', + html: '1909c0c2d9a0e6d35923444af81e7f184e7359b4aa9cb276516223d7ca3ec334', + text: 'a75aba0b49bbbe5ccaec0253be1faf5b0b757580457b16fd4e9f4f15a3e06e39', + }, + 'auth.existing_account_sign_up@es-ES': { + subject: 'Ya tienes una cuenta de Acme & ', + html: 'c6abe9c3fc761d1dc88b0517ee65bce3dcedb01a425e6cdd3daebdd77aff1a00', + text: '7d65c60ed0e8b415fe7aae7aff5c544318f81e3ab6c87525067af112a30d238b', + }, }; const sha256 = (s: string) => createHash('sha256').update(s, 'utf8').digest('hex'); @@ -238,7 +263,7 @@ describe('every built-in auth mail renders byte-identical from the registry', () rendered[`${tpl.name}@${tpl.locale}`] = { subject: r.subject, html: sha256(r.html), text: sha256(r.text) }; } - expect(Object.keys(rendered)).toHaveLength(28); + expect(Object.keys(rendered)).toHaveLength(32); expect(rendered).toEqual(RENDERED); }); }); From 629addbf97035ec1330d7fa4c016e5aa556a62c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 23:31:14 +0000 Subject: [PATCH 5/6] test(plugin-email): the manifest registers 32 built-in auth rows Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W Co-authored-by: Claude --- .../src/email-plugin.template-runtime-write.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts b/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts index 292c6b79a27..a8a505ea992 100644 --- a/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts +++ b/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts @@ -227,7 +227,7 @@ describe('the built-in auth templates are code-declared items of the plugin\'s o const own = manifests.filter((m) => m.id === BUILTIN_EMAIL_TEMPLATES_PACKAGE_ID); expect(own).toHaveLength(1); expect(own[0].emailTemplates).toEqual(BUILTIN_AUTH_TEMPLATES); - expect(own[0].emailTemplates).toHaveLength(28); + expect(own[0].emailTemplates).toHaveLength(32); }); }); From c8b1dce6365cf61c7d8c95bb44dc08a0edc3fed0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 00:18:03 +0000 Subject: [PATCH 6/6] docs(self-hosting): what each posture tells a stranger about an existing address, now that open and email_domain answer like a new sign-up while mail is delivered Claude-Session: https://claude.ai/code/session_01UnHcTADjeg3VwxF21QHj7W Co-authored-by: Claude --- content/docs/deployment/self-hosting.mdx | 78 +++++++++++++----------- 1 file changed, 42 insertions(+), 36 deletions(-) diff --git a/content/docs/deployment/self-hosting.mdx b/content/docs/deployment/self-hosting.mdx index b00aed5edea..e1f53f6a927 100644 --- a/content/docs/deployment/self-hosting.mdx +++ b/content/docs/deployment/self-hosting.mdx @@ -609,11 +609,12 @@ this step. **Two remedies that look like they work, measured:** - **Opening the audience posture is not enough on its own.** With the posture - widened to `email_domain`, a seeded person's own registration is refused - `422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL` and nothing is written — the same - answer the `invite_only` default already gives for an address the directory - holds, so the widened posture buys that person no login and their sign-in - stays `401`. A fresh address does get an account, but every posture other + widened to `email_domain`, a seeded person's own registration writes nothing + and buys them no login. It is refused explicitly, as the `invite_only` default + already refuses an address the directory holds — or, while the mail transport + is live, it gets the same answer a new registration gets and the address is + mailed that it already has an account (see the next section). Either way their + sign-in still fails. A fresh address does get an account, but every posture other than `invite_only` forces email verification on (unless an `open` deployment has itself declared it off — see [Email verification under each posture](#email-verification-under-each-posture)), @@ -641,37 +642,42 @@ a populated deployment, posting to `/api/v1/auth/sign-up/email`: |:---|:---| | `invite_only` (default) | **Nothing.** A registered address and an unknown one both answer `403 SELF_REGISTRATION_CLOSED` — same body bytes, same headers. | | `email_domain` | **Only inside a domain you allowlisted.** Any address outside the list answers `403 EMAIL_DOMAIN_NOT_ALLOWED`, registered or not, so a caller must already hold an address on an allowlisted domain before they learn anything. | -| `open` | **Whether any address is already registered.** A registered address answers `422 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL`; an unregistered one answers `200`. | - -**On `open`, that disclosure is by design.** Anyone who can reach the endpoint -can test an address and learn whether it belongs to a user of this deployment, -one address per request, holding no account of their own. On an -internet-facing deployment that is an address-enumeration oracle. It is a cost -of the posture rather than an oversight: the endpoint refuses **honestly**, and -what that refusal replaced was worse for everyone except the enumerator. A duplicate -sign-up used to answer `200` carrying a freshly minted user id for a row that -was never written — no account, no verification mail, and the person's next -sign-in `401` with nothing anywhere explaining it. - -**That silence was never a real secret, either.** The auth engine hashed the -password on both lanes to level the timing and stopped there: a genuine -registration also writes rows and dispatches a verification mail, so the mail -that never arrives — and the account that can never sign in — already told a -patient caller what the `422` now states outright. The explicit refusal made an -existing disclosure legible; it did not create it. `invite_only` and -`email_domain` are untouched by it: the uniqueness refusal is reached only for -a caller the posture has already admitted, so a stranger on the default still -gets `403 SELF_REGISTRATION_CLOSED` and learns nothing. - -**If you need `open` without the disclosure, ask for it.** better-auth, the -auth engine underneath, ships the designed alternative: `onExistingUserSignUp`, -a hook that pairs a generic sign-up response with a notification to the -address's real holder — "someone tried to register with your email". It is -deliberately **not wired** here, because turning it on trades the honest -refusal above for a silent success, which is the shape that behaviour exists to -remove. If you run a real internet-facing `open` deployment and want that -trade, open an issue saying so: it is a per-deployment decision, waiting on a -deployment that needs it. +| `open` | **Nothing, while the mail transport is live.** A registered address gets the same answer as a new sign-up, nothing is written, and the address's holder is mailed that it already has an account. Without a live transport, or with email verification declared off and auto sign-in left on, a registered address is refused explicitly, so the answer tells a caller that the address is registered. | + +**What decides it on `open` and `email_domain`.** A sign-up for an address that +already has an account gets the same answer as a successful new sign-up, and +writes nothing, when all three of these hold: + +- The posture is `open` or `email_domain`. `invite_only` is unchanged: the + uniqueness question is reached only for a caller the posture has already + admitted, so a stranger on the default still learns nothing. +- Email verification is on, or auto sign-in is off. Only then does a new + sign-up's own answer carry no session, so an existing address can get the + same answer without a session for someone else's account. +- The email service's transport is live: `IEmailService.isConfigured()` answers + true. It is asked at every sign-up, so a transport saved from the mail + settings applies to the next one. plugin-email's `log` transport is never + live, in any environment, and an email service without that member counts as + not live. + +The address's holder then receives a mail saying the address already has an +account, with links to the sign-in and forgot-password pages and no token. It is +the built-in `auth.existing_account_sign_up` template, overridable like the +other auth mails, in the holder's stored language or else the deployment +default. The platform wires better-auth's `onExistingUserSignUp` hook to send +it, and decides first, before the auth engine answers, whether that mail can be +promised. + +**Otherwise the refusal stays explicit.** With no live transport, on +`invite_only`, or on `open` with verification declared off and auto sign-in +left on, a sign-up for a registered address is refused outright. On `open` +that refusal tells anyone who can reach the endpoint whether an address belongs +to a user of this deployment, one address per request — an address-enumeration +oracle on an internet-facing deployment. It stays because a success answer is +honest only when a mail follows it: what the refusal replaced was a duplicate +sign-up answered as a success for a row that was never written, with no mail +and a next sign-in that failed with nothing explaining it. To close the oracle +on `open`, keep verification on and connect a mail transport. ### Email verification under each posture