You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 4851668
Browse filesBrowse the repository at this point in the historyBrowse 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>
Copy file name to clipboardExpand all lines: .changeset/sign-up-mail-after-response.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -9,4 +9,4 @@ Metadata-protocol: none
9
9
10
10
-**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.
11
11
-**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.
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.
0 commit comments