Skip to content

Commit 3a856f6

Browse files
fix(plugin-auth)!: a completed password reset ends the account's other sessions; a reset request answers the same way for any address without waiting on its mail (#29827)
security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj Clause-②: no (narrowing) — the auth manager refuses an `emailAndPassword.revokeSessionsOnPasswordReset: false` it accepted before, at construction and on every configuration patch. Nothing is widened: no export, route, key or answer is added. Metadata-protocol: none Skills-docs: none — no `skills/**` file teaches the reset doors or this key; the hand-written guides that do (`content/docs/permissions/authentication.mdx`, `docs/HARDENING.md`) are updated in this diff. ## 维护者速读 - 改了什么:自助重置密码完成后,该账号此前的所有会话一律结束(与管理员强制重置一致,不是开关);显式配置 `revokeSessionsOnPasswordReset: false` 改为启动即拒绝。重置请求对任何地址都同样作答,重置邮件改在响应之后发送,发送失败只记服务端日志。 - 为什么改:重置密码应当让旧登录失效;重置请求的回答不应随地址或邮件发送而变化。 - 风险与回滚:用户在其他设备上的登录会在重置后被登出(预期行为)。若某部署显式写了 `false`,升级后无法启动,删掉该键即可。回滚 = 还原本 PR,无数据迁移。 - 先看:`packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts`(`revokeSessionsOnPasswordReset: true` 与 `sendResetPassword` 外包的一行)、`packages/plugins/plugin-auth/src/run-after-response.ts`、`packages/plugins/plugin-auth/src/password-reset-ends-sessions.ts`。 - 两分钟验证:`pnpm --filter @objectstack/plugin-auth exec vitest run src/password-reset-ends-sessions.test.ts src/reset-request-mail-after-response.test.ts`,预期两个文件全部通过。 - 需要你做的:无,席位落地。 ## What changes - **A completed password reset ends the account's other sessions.** The auth instance now always hands better-auth `emailAndPassword.revokeSessionsOnPasswordReset: true`, so every reset door that sets a password from a reset token or code (the emailed link, and the phone OTP reset where it is mounted) ends every existing session of the account in the same unit as the password write — the same as an administrator's forced reset. It is not a setting. - **`revokeSessionsOnPasswordReset: false` is refused, never silently overridden.** The key stays declared by `@objectstack/spec`. `assertPasswordResetEndsSessions` (new, `password-reset-ends-sessions.ts`) refuses an explicit `false` where the configuration enters the manager — the constructor (stops the boot) and `applyConfigPatch` (the standing configuration keeps ruling) — the way the plugin already treats a declaration it will not honour (`assertAudienceConfig`, `assertScimAdminCoherence`). `true` and an absent key are accepted. - **A reset request answers the same way for any address, without waiting on its mail.** `sendResetPassword` is wrapped by `sendAfterResponseOnPublicDoor` (new, `run-after-response.ts`): a call that carries its `Request` (the public door) resolves at once and the mail is built and sent after the response (`setImmediate`, the shape `createRefusedAttachTombstoner` in service-storage already uses); a failure is logged on the server at error. A call with no `Request` — the identity import's server-side reset — is handed straight through and awaited as before, because it reads the delivery receipt the send settles. - **Docs.** `docs/HARDENING.md` no longer asks operators to set the key; `content/docs/permissions/authentication.mdx` states both rules. ## Tests - `plugin-auth/src/password-reset-ends-sessions.test.ts` (new): the session signed in before a completed reset answers `401 UNAUTHENTICATED` afterwards (through the emailed link and, the same, through the phone OTP reset — `/phone-number/request-password-reset` and `/phone-number/reset-password` with an SMS service) and a fresh sign-in with the new password is served (control); a reset that does not complete ends nothing; better-auth is handed the flag with no configuration; `false` is refused at construction and on a patch (standing config kept); `true`/absent accepted. - `plugin-auth/src/reset-request-mail-after-response.test.ts` (new): a send that never settles still lets `POST /request-password-reset` answer, with the body an address with no account gets; the mail is still sent, after the response (control); a failed send is logged at error and the answer is unchanged; the server-side reset call still awaits its send; `runAfterResponse` failure handling. - `qa/dogfood/test/password-reset-ends-sessions.dogfood.test.ts` (new): on the booted showcase, through the HTTP doors — the earlier session is served before the reset, refused (401) on the session door and a data read after it, and a fresh sign-in is served on both. - Updated, because the public door's reset mail now lands after the response: `auth-email-locale.test.ts`, `identity-mail-sibling.test.ts`, `admin-import-users-invite-delivery-failure.test.ts` ⑥, and `identity-mail-read-gate.dogfood.test.ts` (waits for the new identity row instead of reading the newest one at once). ## Acceptance notes - The phone OTP reset is pinned too (`password-reset-ends-sessions.test.ts`, `plugins.phoneNumber` with an SMS service): the session signed in before a reset completed with the texted code answers 401 afterwards, and a fresh sign-in is served. - A session a reset ends is deleted, not stamped: the reset paths are in neither the interactive-revoke ledger nor the erasure paths (`session-tombstone.ts`), so no `revoked_at` / `revoke_reason` trail is written and no `logout` audit row either (`logoutEventFor` records a `/sign-out` alone). The account's API keys are untouched. Parity with the forced reset's trail and key revocation is left to the maintainer. - The after-response send lives as long as the process: a host that freezes its process right after the response would lose it. The platform's own Node server does not. - `upgrade-harness/populate-fixture.ts` (the 18.0 fixture generator, never collected by vitest) requests a reset and does not read the mail row, so it is unchanged. ## Verification Round 2 (the contract review's three must-fixes), head `9c006b978b`: touched plugin tests 5 files, 94/94; typecheck exit 0. Ablation of `revokeSessionsOnPasswordReset: true,` now turns 3 cases red (the emailed-link door, the phone OTP door, the flag); restored. Derived gates for the round's paths: 51 run, 51 × exit 0, `check:doc-authoring` included; `check:optional-error-sink` exit 0. CI on `9c006b978b`: 38 success, 12 skipped, 0 failed. Round 1 follows. All readings below are on head `3ef2258988` (this branch merged with `origin/main` at `6f094645b4`). <details><summary>plugin-auth: full suite and typecheck</summary> - `pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2` → exit 0 · Test Files 218 passed (218) · Tests 3715 passed, 10 skipped (3725). - `pnpm --filter @objectstack/plugin-auth run typecheck` → exit 0 (`tsc --noEmit`, the examples project, and `check:test-typecheck`: OK, test layer held at its ledger). </details> <details><summary>One-shot ablation of each fix (via scripts/ablation-replace.mjs; restored to the HEAD blob, tree clean)</summary> - Session rule — delete `revokeSessionsOnPasswordReset: true,` in `auth-manager-auth-instance.ts` (anchor 1 → 0, blob `e85d8f531be9` → `12592d665800`): `password-reset-ends-sessions.test.ts` 2 failed / 4 passed — the two cases that state the rule ("the session signed in before the reset is refused afterwards…" and "asks better-auth to end sessions on every reset door…") went red; the config-refusal cases stayed green. Restored blob `e85d8f531be9`. - Reset request answer — force the public-door branch of `sendAfterResponseOnPublicDoor` to await the send (`if (true) return send(...)`, blob `4d5bc946ea91` → `fd8c1027b80f`): `reset-request-mail-after-response.test.ts` 2 failed / 4 passed — the two cases that state the rule ("answers while the send has not settled…" and "logs a send that fails on the server…") went red; the control (the mail is still sent) and the server-side call case stayed green. Restored. </details> <details><summary>dogfood</summary> - New pin `password-reset-ends-sessions.dogfood.test.ts` and the updated `identity-mail-read-gate.dogfood.test.ts`: pass (1/1 and 25/25 targeted). - Full battery `pnpm --filter @objectstack/dogfood test` (no shard, no path; title carries `!`): Test Files 403 passed, 1 failed, 3 skipped (407) · Tests 3320 passed, 1 failed, 28 skipped (3349). The one failure is `managed-content-sealed.dogfood.test.ts` › "control … a NEW flow is created through /meta and through /automation" — `Test timed out in 5000ms` inside a 35-minute run on a shared box; the file passes alone on the same head (10/10). It touches no auth path. </details> <details><summary>Gates (node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack)</summary> - 93 derived commands, all run with the exit code captured before any pipe: 93 × exit 0. Three first answered `PREREQUISITE NOT MET` (exit 3: `check:skill-examples` and `check:dual-build-cjs-loads` read packages outside the dogfood build closure; `check:type-check-debt` did not get the shared verify lock) and were re-run to exit 0 after building those packages / acquiring the lock. - `dispatch-gates --ran` reconciliation: 93 derived famil(ies) accounted for — 93 run, 0 NOT-MEASURED (a derived zero: every entry recorded its exit code). - After the merge: `pnpm install --frozen-lockfile`, the dogfood closure build and `pnpm --filter @objectstack/spec check:generated` → exit 0. - CI-owned families (Lint & Repo Gates, Type Check, Test Core, Build Core, the UI lanes) are read from this PR's check-runs. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e9ae8ef commit 3a856f6

14 files changed

Lines changed: 805 additions & 16 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
---
4+
5+
fix(plugin-auth)!: a completed password reset ends the account's other sessions, and a reset request answers the same way for any address without waiting on its mail
6+
7+
Clause-②: no (narrowing) — the auth manager refuses an `emailAndPassword.revokeSessionsOnPasswordReset: false` it accepted before, at construction and on every configuration patch. Nothing is widened: no export, route, key or answer is added.
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition, settings key or stored shape is added, removed, renamed or re-shaped, and no exported declaration changes. `emailAndPassword.revokeSessionsOnPasswordReset` stays declared by `@objectstack/spec`; what changes is which value plugin-auth's own entry validation accepts for the deployment configuration a host hands the plugin in code, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
11+
12+
**BREAKING** for a deployment that sets `emailAndPassword.revokeSessionsOnPasswordReset: false` only.
13+
14+
- **A completed password reset ends the account's other sessions.** Every self-service reset door — the emailed link (`POST /api/v1/auth/reset-password`) and the phone OTP reset (`POST /api/v1/auth/phone-number/reset-password`) — ends every existing session of the account when it sets the new password, the same as an administrator's forced reset does. The person who reset the password signs in again with it. It is not a setting.
15+
- **`revokeSessionsOnPasswordReset: false` is refused.** The auth manager refuses it at construction, which stops the boot, and on a configuration patch, where the standing configuration keeps ruling, with a message naming the key. `true` and an absent key are accepted and change nothing.
16+
- **A reset request answers the same way for any address, without waiting on its mail.** `POST /api/v1/auth/request-password-reset` answers at once; the reset mail is built and sent after the response. A send that fails is logged on the server, at error, and never changes the answer.
17+
- **Unchanged.** The answer's status and body; the reset mail's template, locale and recipient; the identity import, whose invitation mail is still awaited and still reported per row when it is not delivered; an administrator's forced reset and its revocation switch.
18+
- **Who sees it.** A user signed in somewhere else when their password is reset is signed out there. A host whose configuration sets `revokeSessionsOnPasswordReset: false` no longer boots until the key is removed.
19+
20+
**The remedy.** Delete `revokeSessionsOnPasswordReset: false` from the `emailAndPassword` block the host passes the auth plugin; nothing else is needed.

‎content/docs/permissions/authentication.mdx‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,10 @@ const response = await fetch('http://localhost:3000/api/v1/auth/request-password
289289
});
290290
```
291291

292+
The request answers `{ status: true }` the same way for any address. The reset
293+
mail is sent after the response; a send that fails is logged on the server and
294+
never changes the answer.
295+
292296
#### Reset Password with Token
293297

294298
{/* os:check */}
@@ -303,6 +307,11 @@ const response = await fetch('http://localhost:3000/api/v1/auth/reset-password',
303307
});
304308
```
305309

310+
A completed reset ends every existing session of the account — the emailed link
311+
and the phone OTP reset alike, the same as an administrator's forced reset — so
312+
the user signs in again with the new password. It is not a setting:
313+
`emailAndPassword.revokeSessionsOnPasswordReset: false` is refused at boot.
314+
306315
#### Setting a first local password
307316

308317
`POST /api/v1/auth/set-initial-password` sets an **initial** local password for a

‎docs/HARDENING.md‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ ObjectStack uses [better-auth](https://www.better-auth.com/) via
260260
| Session TTL | 7 days | `session.expiresIn` (seconds) |
261261
| Access token TTL | inherited from session | configure in better-auth |
262262
| Refresh | better-auth `/auth/get-session` rolls TTL | `session.updateAge` (seconds) |
263-
| Revocation | DELETE on `session` row | `revokeSessionsOnPasswordReset: true` |
263+
| Revocation | DELETE on `session` row; a completed password reset ends every session of the account | — always on (`revokeSessionsOnPasswordReset: false` is refused at boot) |
264264
| Idle / absolute / concurrent caps | Off (opt-in) | `session_idle_timeout_minutes` / `session_absolute_max_hours` / `max_concurrent_sessions_per_user` (Setup → Authentication) |
265265
| Email verify TTL | 1 hour | `emailVerification.expiresIn` |
266266

@@ -276,8 +276,8 @@ curl -X POST -H "Authorization: Bearer $TOK" $API/auth/sign-out
276276
curl -H "Authorization: Bearer $TOK" $API/data/account
277277
# → 401 Unauthorized
278278

279-
# 3. Confirm password reset revokes all other sessions for that user
280-
# (requires revokeSessionsOnPasswordReset: true)
279+
# 3. Confirm a completed password reset ends the user's other sessions
280+
# (always on: a session signed in before the reset answers 401 after it)
281281
```
282282

283283
## CORS
@@ -344,7 +344,6 @@ Run the production hardening checklist before each release:
344344
- [ ] `server.security.rateLimit` enabled with a sized budget; `server.trustProxy`
345345
matches the topology (`true` only behind a proxy that overwrites
346346
`X-Forwarded-For`); multi-node: the `cache` service is shared
347-
- [ ] `revokeSessionsOnPasswordReset: true`
348347
- [ ] CORS origin list is explicit, not `*`
349348
- [ ] `enforceProjectMembership: true` on scoped routes
350349
- [ ] `osv-scanner --lockfile=pnpm-lock.yaml` clean (npm retired the

‎packages/plugins/plugin-auth/src/admin-import-users-invite-delivery-failure.test.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -309,8 +309,11 @@ describe('[#23598] the import reports an invitation the email transport did not
309309
// address has an account or whether its mail went out.
310310
expect(throughFailing).toEqual(throughFine);
311311
expect(throughFailing).toEqual(noAccount);
312-
// Both sends really ran, with the reset copy.
313-
expect(attempts.filter((m) => m.to.address === failing).map((m) => m.template)).toEqual(['auth.password_reset']);
314-
expect(attempts.filter((m) => m.to.address === fine).map((m) => m.template)).toEqual(['auth.password_reset']);
312+
// Both sends really ran, with the reset copy — after the response, which
313+
// is when the public door sends.
314+
await vi.waitFor(() => {
315+
expect(attempts.filter((m) => m.to.address === failing).map((m) => m.template)).toEqual(['auth.password_reset']);
316+
expect(attempts.filter((m) => m.to.address === fine).map((m) => m.template)).toEqual(['auth.password_reset']);
317+
});
315318
}, 60_000);
316319
});

‎packages/plugins/plugin-auth/src/auth-email-locale.test.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,14 @@ async function boot(locale: string | undefined, extra: Record<string, unknown> =
119119

120120
const USER = { id: 'u1', email: 'ada@example.com', name: 'Ada' };
121121

122+
/**
123+
* A reset requested through the public door (a call that carries its
124+
* `Request`) is sent after the response, so the drives below wait for it to
125+
* land before reading what was sent; a call with no request is awaited inline.
126+
*/
127+
const resetSent = (sent: any[]) =>
128+
vi.waitFor(() => expect(sent.some((x: any) => x.template === 'auth.password_reset')).toBe(true));
129+
122130
/**
123131
* Drive all five auth sends and return the recorded `sendTemplate` inputs.
124132
*
@@ -317,6 +325,7 @@ async function driveWithHeader(
317325
{ user: USER, url: 'http://x/reset', token: 't' },
318326
request,
319327
);
328+
await resetSent(sent);
320329
await capturedConfig.emailVerification.sendVerificationEmail(
321330
{ user: USER, url: 'http://x/verify', token: 't' },
322331
request,
@@ -522,6 +531,7 @@ async function driveResetWith(opts: {
522531
{ user: USER, url: 'http://x/reset', token: 't' },
523532
request,
524533
);
534+
await resetSent(sent);
525535
return { sent, reads };
526536
}
527537

@@ -619,6 +629,7 @@ describe('#14762 — sys_user.locale is the top rung of the auth-mail ladder', (
619629
{ user: USER, url: 'http://x/reset', token: 't' },
620630
new Request('http://x/any', { headers: { 'accept-language': 'zh-CN' } }),
621631
);
632+
await resetSent(sent);
622633
expect(sent[0].locale).toBe('zh-CN');
623634
});
624635

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

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import {
6868
import { vendorClientAddressOptions } from './client-address-vendor.js';
6969
import { PLATFORM_IDP_PROVIDER_ID, refuseImplicitAccountLink } from './implicit-account-linking.js';
7070
import { isPlaceholderEmail } from './placeholder-email.js';
71+
import { logAfterResponseSendFailure, sendAfterResponseOnPublicDoor } from './run-after-response.js';
7172

7273
/**
7374
* The `AuthManager` members the function below reads — every one of them, and
@@ -382,17 +383,28 @@ export async function createAuthInstance(this: AuthInstanceHost): Promise<Auth<a
382383
? { resetPasswordTokenExpiresIn: this.config.emailAndPassword.resetPasswordTokenExpiresIn } : {}),
383384
...(this.config.emailAndPassword?.autoSignIn != null
384385
? { autoSignIn: this.config.emailAndPassword.autoSignIn } : {}),
385-
...(this.config.emailAndPassword?.revokeSessionsOnPasswordReset != null
386-
? { revokeSessionsOnPasswordReset: this.config.emailAndPassword.revokeSessionsOnPasswordReset } : {}),
386+
// A completed password reset ends the account's other sessions, on
387+
// every reset door, always — not a deployment option. An explicit
388+
// `false` never reaches here: the manager refuses it at entry.
389+
// `password-reset-ends-sessions.ts` holds the rule and the wiring.
390+
revokeSessionsOnPasswordReset: true,
387391
// #14319 — better-auth calls this as `sendResetPassword(data, ctx.request)`.
388392
// [#23598] better-auth CATCHES what this callback throws
389393
// (`runInBackgroundOrAwait`) and still answers `{ status: true }`, so a
390394
// failed send can never reach the identity import that asked for it.
391395
// `settleImportResetDelivery` settles the import's own delivery receipt
392-
// with the outcome (and re-raises it unchanged); a call that carries a
393-
// `Request` — the public door — is handed straight through.
396+
// with the outcome (and re-raises it unchanged).
394397
// `account-invitation-marker.ts` holds the legs that keep it server-only.
395-
sendResetPassword: async ({ user, url, token }: { user: { id: string; email: string; name?: string }; url: string; token: string }, request?: unknown) => settleImportResetDelivery(user.email, request, async () => {
398+
//
399+
// A reset request answers the same way for any address, answers at once
400+
// and does not wait on the send: a call that carries a `Request` — the
401+
// public door — resolves immediately, and the mail is built and sent
402+
// after the response
403+
// (`sendAfterResponseOnPublicDoor`); what the send does is logged on
404+
// the server and never reaches the caller. The identity import's own
405+
// server-side call (no `Request`) still awaits its send, because it
406+
// reads the delivery receipt the send settles.
407+
sendResetPassword: sendAfterResponseOnPublicDoor(async ({ user, url, token }: { user: { id: string; email: string; name?: string }; url: string; token: string }, request?: unknown) => settleImportResetDelivery(user.email, request, async () => {
396408
// #2766 V1.5 — placeholder addresses (phone-only users) are never
397409
// real recipients. Refuse loudly instead of "sending" into the void;
398410
// the reset path for these users is phone sign-in / an admin
@@ -488,7 +500,7 @@ export async function createAuthInstance(this: AuthInstanceHost): Promise<Auth<a
488500
`${invitation ? 'Account-invitation' : 'Password-reset'} email could not be sent to ${user.email}: ${result.error ?? 'delivery failed'}`,
489501
);
490502
}
491-
}),
503+
}), (error) => logAfterResponseSendFailure(this.config.logger, 'password-reset', error)),
492504
// [#25060, ruling 1B on #25040] The mail half of "a sign-up for an
493505
// address that already has an account answers like a success". The
494506
// vendor calls this only when the address already has a row AND its

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ import {
8080
} from './reconcile-membership.js';
8181
import type { TenancyService } from './tenancy-service.js';
8282
import { OtpSendGuard, assertOtpCooldownSeconds } from './otp-send-guard.js';
83+
import { assertPasswordResetEndsSessions } from './password-reset-ends-sessions.js';
8384
import type { CounterStore } from './rate-limit-storage.js';
8485
import {
8586
applyUserBan,
@@ -1604,6 +1605,12 @@ export class AuthManager {
16041605
// appear after boot.
16051606
assertScimAdminCoherence(config.plugins);
16061607

1608+
// A completed password reset ends the account's other sessions, always;
1609+
// an explicit `revokeSessionsOnPasswordReset: false` is refused here, at
1610+
// boot, rather than silently overridden (`password-reset-ends-sessions.ts`).
1611+
// `applyConfigPatch` re-runs it on the merged result.
1612+
assertPasswordResetEndsSessions(config.emailAndPassword);
1613+
16071614
// The mobile sign-in hand-off's registered app schemes — same
16081615
// boot-loudly rationale: an entry that is not a reverse-domain app scheme
16091616
// is refused here, never dropped (`one-time-token-handoff.ts`).
@@ -2075,6 +2082,12 @@ export class AuthManager {
20752082
if ('plugins' in patch) {
20762083
assertScimAdminCoherence(next.plugins);
20772084
}
2085+
// Same entry validation the constructor runs, on the MERGED block: a patch
2086+
// cannot ask a completed password reset to keep the account's other
2087+
// sessions. Refused loudly and the standing config keeps ruling.
2088+
if ('emailAndPassword' in patch) {
2089+
assertPasswordResetEndsSessions(next.emailAndPassword);
2090+
}
20782091
if ('trustedAppSchemes' in patch) {
20792092
assertTrustedAppSchemes(next.trustedAppSchemes);
20802093
}

‎packages/plugins/plugin-auth/src/identity-mail-sibling.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,8 @@ async function driveAuthMail(tenancy: Tenancy) {
7979
{ user: USER, url: 'http://x/reset', token: 't' },
8080
new Request('http://x/api/v1/auth/request-password-reset'),
8181
);
82+
// The public door sends the reset mail after the response.
83+
await vi.waitFor(() => expect(sent.some((s) => s.template === 'auth.password_reset')).toBe(true));
8284
await config.emailVerification.sendVerificationEmail({ user: USER, url: 'http://x/verify', token: 't' });
8385
await config.plugins.find((p: any) => p.id === 'magic-link')._opts.sendMagicLink({
8486
email: USER.email, url: 'http://x/magic', token: 't',

0 commit comments

Comments
 (0)