Skip to content

Commit 4851668

Browse files
fix(plugin-auth): a verification-email resend from a visitor who is not signed in answers the same whatever the send does (#30144)
Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:未登录访客调用 `POST /api/v1/auth/send-verification-email` 时,回答不再随发信结果变化。邮件通道可用时一律答 `{ "status": true }`,信在响应之后才发,发送失败只在服务端记一条 error 日志;邮件通道不可用时(未接邮件服务,或 `isConfigured()` 不为 true,例如 `log` 传输),访客的每次重发都以同一个 `400 EMAIL_SERVICE_REQUIRED` 拒绝。已登录用户(cookie 或 bearer)重发自己的地址、以及服务端 `auth.api` 调用,行为不变。 - 为什么改:访客重发的回答应当只取决于部署配置,而不取决于发信结果;同时保留「邮件未配置」这一配置错误信号。 - 风险与回滚:用户可见变化在验证页。邮件通道不可用时(含开发环境默认的 `log` 传输),访客点「重发」会看到「未配置邮件投递」的提示;邮件通道可用但发送失败时,访客看到「已发送」,失败只见于服务端日志与发信记录。已登录用户的重发不受影响。回滚即 revert 本 PR。 - 先看:`packages/plugins/plugin-auth/src/visitor-verification-resend.ts`(规则、访客判定与拒绝);`auth-manager-auth-instance.ts` 的 `sendAfterResponseOnSignUpAndVisitorResend`;`auth-manager-before-hook.ts` 新增的一处调用。 - 两分钟验证:`pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/signup-existing-address-success-shape.test.ts`,预期 25 个用例全绿,其中 ⑧ 组 7 个。 - 需要你做的:在本 PR 上书面确认对尚未发布的 `.changeset/sign-up-mail-after-response.md` 第 12 行的改写(把「重发仍等待并报告失败」限定为已登录用户的重发);`Check Changeset` 因此按设计保持红,它不是必需检查。 ## What changes - **The rule.** A verification-email resend (`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 (`isEmailDeliveryLive()`), it answers `{ "status": true }`. The mail is built and sent after the response through `sendAfterResponseOnPublicDoor`, as the sign-up's verification mail already is, and a send that fails is logged on the server at error (`verification email was not delivered`); - while the transport is not live, every such resend is refused before the endpoint runs, with one coded answer: `400 EMAIL_SERVICE_REQUIRED`. - **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. - **Telling a visitor apart.** In the send callback: the endpoint context's declared path, plus the session the resend endpoint resolved before it called the send (`ctx.context.session`, `null` for a caller with none). In the before-hook, where that field cannot answer yet: `AuthManager.resolveActor`, the actor reader the other before-hook gates use, which reads the cookie and the bearer token alike. `getSessionFromCtx` alone reads a caller signed in by bearer token as no session there, because better-auth's `bearer()` plugin applies its cookie only after every before-hook has run (measured; ablation leg 3 below pins it). - **Files.** - `visitor-verification-resend.ts` (new, not exported from the package): the rule, the endpoint-context predicate, and the before-hook refusal. - `auth-manager-auth-instance.ts`: `sendAfterResponseOnSignUp` becomes `sendAfterResponseOnSignUpAndVisitorResend`, which also hands the visitor's resend to the after-response path; comments in the callback scoped to the signed-in resend. - `auth-manager-before-hook.ts`: one call, and `isEmailDeliveryLive` on the hook's host. - `verification-email-failure-propagation.test.ts`: header text scoped to the signed-in resend; assertions unchanged. - **Changesets.** New `visitor-verification-resend.md` (`@objectstack/plugin-auth` patch). `sign-up-mail-after-response.md` corrected, see Acceptance notes. ## Tests Pins in a new top-level describe ⑧ of `signup-existing-address-success-shape.test.ts`, the real-engine harness the after-response sends already share. `deferred()` and `gatedEmail()` moved from ⑦ to module scope (`gatedEmail` gains a `throws` outcome) so ⑦ and ⑧ share them. - While the transport is live, with a send that reports failure, and with one that throws: three addresses, one of each kind, get the same `200 {"status":true}` while the send is still held open by a deferred, never by a wall-clock threshold. Released, the send settles and one error line is logged. - While the transport is not live (the `log` transport's `isConfigured()` answering false, a service without `isConfigured()`, no email service at all): the three addresses get the same `400` answer with code `EMAIL_SERVICE_REQUIRED`, and nothing is sent. - While the transport is not live, a caller signed in by cookie and one signed in by bearer token are not refused, and their resend still sends. - A server-side call (no `Request`) still awaits its send and rejects with its failure. - ⑦'s signed-in resend case keeps its body and now names its rule: "a signed-in caller's /send-verification-email resend still awaits its send and reports one that failed". <details> <summary>Results</summary> ```text signup-existing-address-success-shape.test.ts @ 95edc4f Test Files 1 passed (1) Tests 25 passed (25) VERDICT command-exit 0 plugin-auth full suite @ 4200bbe (closure rebuilt after the merge of main; plugin-auth built) Test Files 220 passed (220) Tests 3822 passed | 10 skipped (3832) os-verify-lock: VERDICT command-exit 0 pnpm --filter @objectstack/plugin-auth typecheck @ 4200bbe: exit 0 check:test-typecheck: OK, 10 file(s) / 88 error(s) / 23 pinned signature(s) held in test-typecheck-debt.json ``` </details> <details> <summary>One-shot ablation, three legs</summary> Each leg went through `scripts/ablation-replace.mjs`: anchor hit once, blob changed, the file run, then restored with blob == HEAD and an empty `git diff HEAD`. The tests import `src/` relatively, so no `dist/` leg exists to prove. ```text leg 1 visitor resend handed back to the awaited send x live transport, send reports failure ... 10019ms Test timed out in 10000ms x live transport, send throws ... 10012ms Test timed out in 10000ms Tests 2 failed | 23 passed (25) leg 2 the not-live refusal removed x not live (log transport) ... expected 200 to be 400 x not live (no isConfigured) ... x not live (no email service) ... Tests 3 failed | 22 passed (25) leg 3 before-hook reads the caller with getSessionFromCtx instead of resolveActor x not live, signed-in caller by cookie or bearer token still sends: EMAIL_SERVICE_REQUIRED body: expected 400 to be 200 (the bearer request) Tests 1 failed | 24 passed (25) ``` </details> ## Acceptance notes - **A pending changeset corrected (the DELIBERATE CORRECTION class `check-empty-changeset.mjs` names).** `.changeset/sign-up-mail-after-response.md` (from #29975, still unreleased on `main`) listed "the `/send-verification-email` resend, which still awaits its send and still reports one that failed" as unchanged. This PR keeps that true only for a signed-in caller, so the line now reads "a signed-in caller's `/send-verification-email` resend, which still awaits its send and still reports one that failed". Restoring it would publish a sentence this PR makes false. `Check Changeset` stays red by design until the correction is confirmed here in writing; it is not a required context. - **No new error code.** `EMAIL_SERVICE_REQUIRED` is already in the ADR-0112 ledger for `@objectstack/plugin-auth`, and `/change-email` and the identity import answer it for the same deployment condition. The constant is typed against the ledger, so a ledger change reds its compile. The body is better-auth's flat `{ code, message }`, the shape `/change-email`'s coded refusals answer in; the message names the fix (Settings → Mail). - **`Clause-②: no`, measured.** No spec schema, export or ledger entry changes. The new module is not re-exported: the built `dist/*.d.ts` and `dist/*.d.mts` carry none of its five names (control: `AuthManager`, 30 hits), and `require('./dist/index.js')` lists no new key. - **Verify screens (not touched).** The console's `VerifyEmailPromptPage` and `RegisterPage` show the server's `message` when a resend fails, so a visitor sees the refusal's message while no transport is live; while one is live, a visitor's resend now always reads as sent. The docs' resend example (`content/docs/permissions/authentication.mdx`) is the signed-in, bearer-token call, which is unchanged. - **Dogfood sweep of `packages/qa/dogfood/test`.** No test sends or reads a `/send-verification-email` mail. The only caller is `upgrade-harness/cli-prerelease-fixture.ts`, a fixture generator that is not a test and drives a pinned prerelease CLI, not HEAD code. Nothing to adapt. - **Test weight.** ⑧ adds seven cases to the file, each asking its three addresses concurrently. The file's local run at 95edc4f read `tests 7.44s` (shared box). ## Verification `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on head `4200bbebc7` derived 60 commands; all 60 were run with each exit captured before any pipe, then reconciled with `--ran` (`60 derived, 59 run, 1 NOT-MEASURED, 0 UNRUN`). - 58 exit 0. - `node scripts/check-empty-changeset.mjs --base origin/main` exit 1: the deliberate-correction refusal above, red by design. - `pnpm check:dual-build-cjs-loads` exit 3, PREREQUISITE NOT MET (packages outside plugin-auth's closure have no `dist/`): NOT MEASURED locally; CI's build measures it. - `pnpm check:type-check-debt` exit 0, re-run under the verify lock at `4200bbebc7` (re-measure OK: 0 raw tsc errors above any recorded number). - `node scripts/check-platform-checklist.mjs --changed-from origin/main`: exit 0, no anchored file in the change set. <!-- signed: security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 42afac7 commit 4851668

7 files changed

Lines changed: 364 additions & 63 deletions

‎.changeset/sign-up-mail-after-response.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,4 @@ Metadata-protocol: none
99

1010
- **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.
1111
- **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.
12-
- **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.
12+
- **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.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
fix(plugin-auth): a verification-email resend from a visitor who is not signed in answers the same whatever the send does
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **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`).
11+
- **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.
12+
- **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.

‎packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts‎

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -70,36 +70,44 @@ import { vendorClientAddressOptions } from './client-address-vendor.js';
7070
import { PLATFORM_IDP_PROVIDER_ID, refuseImplicitAccountLink } from './implicit-account-linking.js';
7171
import { isPlaceholderEmail } from './placeholder-email.js';
7272
import { logAfterResponseSendFailure, sendAfterResponseOnPublicDoor } from './run-after-response.js';
73+
import { isVisitorResendInEndpointContext } from './visitor-verification-resend.js';
7374

7475
/** The path better-auth declares for the email sign-up endpoint. */
7576
const SIGN_UP_EMAIL_PATH = '/sign-up/email';
7677

7778
/**
78-
* `send`, with the one call the sign-up endpoint makes handed to
79-
* {@link sendAfterResponseOnPublicDoor}: a sign-up answers without waiting for
80-
* any mail send, whether or not the address already has an account. Every
81-
* other call is `send` itself, awaited exactly as before — the
82-
* `/send-verification-email` resend among them, which reports a send that
83-
* failed to its caller.
79+
* `send`, with two of its calls handed to {@link sendAfterResponseOnPublicDoor}:
80+
*
81+
* - the one the sign-up endpoint makes: a sign-up answers without waiting for
82+
* any mail send, whether or not the address already has an account;
83+
* - the one the `/send-verification-email` resend makes for a visitor who is
84+
* not signed in: that resend answers the same whatever the send does
85+
* (`visitor-verification-resend.ts` states the rule).
86+
*
87+
* Their failure is logged on the server, never handed to the caller. Every
88+
* other call is `send` itself, awaited exactly as before — a signed-in
89+
* caller's resend among them, which reports a send that failed to its caller.
8490
*
8591
* The endpoint is the one better-auth runs the call inside, read by its
8692
* declared path from the endpoint context (as the hooks read `ctx.path`),
87-
* never from the request's URL. Outside any endpoint context the call is
88-
* awaited.
93+
* never from the request's URL; whether the caller is signed in is the session
94+
* that endpoint resolved before it called. Outside any endpoint context the
95+
* call is awaited.
8996
*/
90-
function sendAfterResponseOnSignUp<D>(
97+
function sendAfterResponseOnSignUpAndVisitorResend<D>(
9198
send: (data: D, request?: unknown) => Promise<unknown>,
9299
onFailure: (error: unknown) => void,
93100
): (data: D, request?: unknown) => Promise<void> {
94101
const afterResponse = sendAfterResponseOnPublicDoor(send, onFailure);
95102
return (data, request) => {
96-
let path: unknown;
103+
let answersFirst = false;
97104
try {
98-
path = tryGetCurrentAuthEndpointContext()?.path;
105+
const endpoint = tryGetCurrentAuthEndpointContext();
106+
answersFirst = endpoint?.path === SIGN_UP_EMAIL_PATH || isVisitorResendInEndpointContext(endpoint);
99107
} catch {
100-
path = undefined;
108+
answersFirst = false;
101109
}
102-
return path === SIGN_UP_EMAIL_PATH ? afterResponse(data, request) : (send(data, request) as Promise<void>);
110+
return answersFirst ? afterResponse(data, request) : (send(data, request) as Promise<void>);
103111
};
104112
}
105113

@@ -583,16 +591,22 @@ export async function createAuthInstance(this: AuthInstanceHost): Promise<Auth<a
583591
// makes, carrying its request, resolves at once and the mail is built
584592
// and sent after the response, as the notice to an address that
585593
// already has an account is (`onExistingUserSignUp` above); its
586-
// failure is logged on the server. Every other call is awaited as
587-
// before (`sendAfterResponseOnSignUp`) — the resend below among them.
588-
sendVerificationEmail: sendAfterResponseOnSignUp(async ({ user, url, token }: { user: { id: string; email: string; name?: string }; url: string; token: string }, request?: unknown) => {
594+
// failure is logged on the server. The `/send-verification-email`
595+
// resend of a visitor who is not signed in is sent the same way, so
596+
// it answers the same whatever the send does
597+
// (`visitor-verification-resend.ts`). Every other call is awaited as
598+
// before (`sendAfterResponseOnSignUpAndVisitorResend`) — a signed-in
599+
// caller's resend among them.
600+
sendVerificationEmail: sendAfterResponseOnSignUpAndVisitorResend(async ({ user, url, token }: { user: { id: string; email: string; name?: string }; url: string; token: string }, request?: unknown) => {
589601
const email = this.getEmailService();
590602
if (!email) {
591603
// Verification is enabled (this callback only exists when it is)
592604
// but no email transport is wired — a misconfiguration, not a
593-
// transient blip. THROW so the explicit `/send-verification-email`
594-
// resend endpoint (which awaits this) surfaces a real error
595-
// instead of a false "email sent" success. Sign-up stays
605+
// transient blip. THROW so a signed-in caller's
606+
// `/send-verification-email` resend (which awaits this) surfaces a
607+
// real error instead of a false "email sent" success; a visitor's
608+
// resend is refused before it gets here while no transport is
609+
// live (`EMAIL_SERVICE_REQUIRED`, the before-hook). Sign-up stays
596610
// resilient regardless: better-auth runs the sendOnSignUp call
597611
// through `runInBackgroundOrAwait`, which logs (never rethrows)
598612
// a failure, so the account is still created and the user lands
@@ -606,7 +620,8 @@ export async function createAuthInstance(this: AuthInstanceHost): Promise<Auth<a
606620
const ttlSec = this.config.emailVerification?.expiresIn ?? 60 * 60;
607621
// Let send failures propagate (see above): sendTemplate THROWS on
608622
// template/loader errors, and returns status:'failed' on transport
609-
// errors — surface both so resend is honest and signup stays
623+
// errors — surface both so a signed-in caller's resend is honest
624+
// (a visitor's is logged on the server instead) and signup stays
610625
// resilient via better-auth's background-task error handling.
611626
// commit 35e94c96b — the stored rung, same ladder as the reset above. An
612627
// admin re-triggering verification for a user is the same
@@ -770,6 +785,7 @@ export async function createAuthInstance(this: AuthInstanceHost): Promise<Auth<a
770785
hasExistingUserFor: (...a) => this.hasExistingUserFor(...a),
771786
isAuthGateActive: (...a) => this.isAuthGateActive(...a),
772787
isBootstrapCreation: (...a) => this.isBootstrapCreation(...a),
788+
isEmailDeliveryLive: (...a) => this.isEmailDeliveryLive(...a),
773789
isOperatorProvisioning: (...a) => this.isOperatorProvisioning(...a),
774790
isPlatformAdminUserId: (...a) => this.isPlatformAdminUserId(...a),
775791
resolveActor: (...a) => this.resolveActor(...a),

‎packages/plugins/plugin-auth/src/auth-manager-before-hook.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ import {
7272
import { ORGANIZATION_DELETE_PATH } from './organization-delete.js';
7373
import { carriesPrincipalSession } from './principal-session-mint.js';
7474
import { snapshotRoleChangeGrants, UPDATE_MEMBER_ROLE_PATH } from './role-change-granted-set.js';
75+
import { refuseVisitorResendWithoutLiveTransport } from './visitor-verification-resend.js';
7576

7677
/**
7778
* The `AuthManager` members the function below reads — every one of them, and
@@ -95,6 +96,7 @@ export interface BeforeHookHost {
9596
hasExistingUserFor: AuthManager['hasExistingUserFor'];
9697
isAuthGateActive: AuthManager['isAuthGateActive'];
9798
isBootstrapCreation: AuthManager['isBootstrapCreation'];
99+
isEmailDeliveryLive: AuthManager['isEmailDeliveryLive'];
98100
isOperatorProvisioning: AuthManager['isOperatorProvisioning'];
99101
isPlatformAdminUserId: AuthManager['isPlatformAdminUserId'];
100102
resolveActor: AuthManager['resolveActor'];
@@ -639,6 +641,19 @@ export async function globalBeforeHook(
639641
if (roleChangeDeps) await snapshotRoleChangeGrants(ctx, roleChangeDeps);
640642
}
641643

644+
// ── The verification-email resend of a visitor who is not signed in ──
645+
// While no email transport is live, every such resend is refused here,
646+
// before the endpoint runs, with one coded answer whatever the address
647+
// (400 `EMAIL_SERVICE_REQUIRED`). While one is live the resend answers
648+
// the same whatever the send does: its send runs after the response
649+
// (`sendAfterResponseOnSignUpAndVisitorResend`). A signed-in caller's
650+
// resend passes through untouched. `visitor-verification-resend.ts`
651+
// states the rule and how a visitor is told apart.
652+
await refuseVisitorResendWithoutLiveTransport(ctx, {
653+
isEmailDeliveryLive: () => this.isEmailDeliveryLive(),
654+
resolveActor: (c) => this.resolveActor(c),
655+
});
656+
642657
// ── ADR-0069 D2: account lockout (gate) ─────────────────────
643658
// Reject a sign-in for a locked identity BEFORE better-auth checks
644659
// the password — a lock must hold even against the correct password.

0 commit comments

Comments
 (0)