@lenne.tech/nest-server 11.36.0 → 11.36.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/.claude/rules/configurable-features.md +3 -1
  2. package/.claude/rules/testing.md +24 -1
  3. package/CLAUDE.md +1 -0
  4. package/FRAMEWORK-API.md +3 -2
  5. package/dist/core/common/helpers/logging.helper.js +1 -1
  6. package/dist/core/common/helpers/logging.helper.js.map +1 -1
  7. package/dist/core/common/interfaces/server-options.interface.d.ts +2 -0
  8. package/dist/core/modules/better-auth/better-auth.config.d.ts +5 -2
  9. package/dist/core/modules/better-auth/better-auth.config.js +34 -2
  10. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  11. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +15 -1
  12. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +115 -9
  13. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  14. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +1 -1
  15. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +14 -1
  16. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
  17. package/dist/core/modules/better-auth/core-better-auth.module.js +19 -5
  18. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  19. package/dist/core/modules/better-auth/core-better-auth.service.js +3 -1
  20. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  21. package/dist/core/modules/tenant/core-tenant.service.d.ts +1 -0
  22. package/dist/core/modules/tenant/core-tenant.service.js +11 -2
  23. package/dist/core/modules/tenant/core-tenant.service.js.map +1 -1
  24. package/dist/templates/password-reset-de.ejs +65 -0
  25. package/dist/templates/password-reset-en.ejs +65 -0
  26. package/dist/templates/password-reset.ejs +1 -1
  27. package/dist/tsconfig.build.tsbuildinfo +1 -1
  28. package/docs/security-overrides.md +17 -11
  29. package/migration-guides/11.36.0-to-11.36.1.md +283 -0
  30. package/migration-guides/11.36.1-to-11.36.2.md +67 -0
  31. package/package.json +10 -11
  32. package/src/core/common/helpers/logging.helper.ts +9 -1
  33. package/src/core/common/interfaces/server-options.interface.ts +50 -8
  34. package/src/core/modules/better-auth/CUSTOMIZATION.md +33 -7
  35. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +38 -0
  36. package/src/core/modules/better-auth/README.md +100 -15
  37. package/src/core/modules/better-auth/better-auth.config.ts +138 -9
  38. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +265 -20
  39. package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +30 -2
  40. package/src/core/modules/better-auth/core-better-auth.module.ts +45 -13
  41. package/src/core/modules/better-auth/core-better-auth.service.ts +10 -2
  42. package/src/core/modules/tenant/core-tenant.service.ts +41 -4
  43. package/src/templates/password-reset-de.ejs +65 -0
  44. package/src/templates/password-reset-en.ejs +65 -0
  45. package/src/templates/password-reset.ejs +1 -1
@@ -270,6 +270,44 @@ Available variables: `name`, `link`, `expiresIn`, `appName`
270
270
 
271
271
  ---
272
272
 
273
+ ## Password Reset (v11.36.1+)
274
+
275
+ **Native password reset is ON by default and needs no configuration.**
276
+ `CoreBetterAuthModule` injects the `emailAndPassword.sendResetPassword` hook, so
277
+ `POST /iam/request-password-reset` is live from the first boot. Before 11.36.1 no hook was wired
278
+ and the route answered `RESET_PASSWORD_DISABLED`, so **this changes behaviour on upgrade**: reset
279
+ mail starts going out to real users.
280
+
281
+ Check these four things when integrating:
282
+
283
+ - [ ] **Rate limiting is on.** The route is unauthenticated and sends mail. `betterAuth.rateLimit`
284
+ is off unless you configure it — providing the object at all (even `{}`) enables it. The
285
+ mailer additionally holds a per-ADDRESS cooldown (`emailVerification.resendCooldownSeconds`,
286
+ 60 s), which is the axis that matters against mail-bombing one victim from rotating IPs.
287
+ - [ ] **Your reset page exists and points at `POST /iam/reset-password`** with `{ token, newPassword }`.
288
+ Tokens are valid for 1 h by default.
289
+ - [ ] **`trustedOrigins` contains no wildcard.** `redirectTo` is validated against it and the reset
290
+ redirect carries the token, so a wildcard hands a live token to any origin it admits. The
291
+ framework warns at boot if it finds one.
292
+ - [ ] **If you ship your own `password-reset.ejs`, it must render from `{ link, name }` alone.**
293
+ The legacy `POST /users/password/reset-request` flow resolves that exact template name and
294
+ passes nothing else. Locale variants (`password-reset-de.ejs` / `-en.ejs`) are only reached
295
+ by the IAM flow and may use `appName`.
296
+
297
+ To turn the flow off entirely (support-mediated or SSO-primary reset policies):
298
+
299
+ ```typescript
300
+ betterAuth: {
301
+ emailAndPassword: { passwordReset: false },
302
+ },
303
+ ```
304
+
305
+ Optional: `betterAuth.emailVerification.passwordResetBrevoTemplateId` routes the mail through
306
+ Brevo. It deliberately does **not** fall back to `brevoTemplateId` — that is the verification
307
+ template, which would tell the user to confirm their address instead of resetting their password.
308
+
309
+ ---
310
+
273
311
  ## Sign-Up Checks (v11.13.0+)
274
312
 
275
313
  Sign-up validation is **enabled by default** requiring `termsAndPrivacyAccepted`.
@@ -1732,31 +1732,116 @@ When a legacy user signs in via BetterAuth for the first time, their account is
1732
1732
 
1733
1733
  ### BetterAuth Password Reset Configuration
1734
1734
 
1735
- BetterAuth provides native password reset via `/iam/forgot-password` and `/iam/reset-password` endpoints. To enable this, configure the `sendResetPassword` callback:
1735
+ BetterAuth provides native password reset via `/iam/request-password-reset` and
1736
+ `/iam/reset-password`. It is **enabled automatically** — `CoreBetterAuthModule`
1737
+ injects a `sendResetPassword` hook that delegates to
1738
+ `CoreBetterAuthEmailVerificationService.sendPasswordResetEmail()`. Nothing needs
1739
+ to be configured for the flow to work; without that hook Better-Auth answers
1740
+ `RESET_PASSWORD_DISABLED`.
1741
+
1742
+ > **This is a live, unauthenticated endpoint on every deployment.** Anyone can
1743
+ > POST any address to it and cause a mail to be sent and a token to be minted.
1744
+ > Before you ship, check the two bounds below (rate limit + cooldown), or turn
1745
+ > the flow off if your reset policy is support-mediated or SSO-primary:
1746
+ >
1747
+ > ```typescript
1748
+ > betterAuth: {
1749
+ > emailAndPassword: { passwordReset: false }, // route answers RESET_PASSWORD_DISABLED again
1750
+ > },
1751
+ > ```
1752
+
1753
+ #### Rate limiting (check this)
1754
+
1755
+ Two independent bounds, on different axes:
1756
+
1757
+ | Bound | Axis | Default |
1758
+ | ----------------------------------------------------------- | ----------------- | ------------------------- |
1759
+ | `betterAuth.rateLimit` (route middleware) | IP | **off unless configured** |
1760
+ | Mailer cooldown (`emailVerification.resendCooldownSeconds`) | recipient address | 60 s |
1761
+
1762
+ The recipient-axis cooldown is what stops an attacker rotating IPs to mail-bomb
1763
+ one victim; the IP-axis limiter is what stops one caller hammering the route.
1764
+ `/request-password-reset` is listed in `strictEndpoints`, so it receives the
1765
+ halved limit when the limiter is on. Providing a `rateLimit` object at all —
1766
+ even `{}` — enables it:
1767
+
1768
+ ```typescript
1769
+ betterAuth: {
1770
+ rateLimit: { max: 10, windowSeconds: 60 },
1771
+ },
1772
+ ```
1773
+
1774
+ #### Branding the mail
1775
+
1776
+ Drop your own template into the project template directory — it wins over the
1777
+ nest-server default via the usual project-first, locale-aware lookup:
1778
+
1779
+ ```
1780
+ src/assets/templates/password-reset-de.ejs # or -en, or plain password-reset.ejs
1781
+ ```
1782
+
1783
+ | Variable | Always passed | Notes |
1784
+ | --------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1785
+ | `name` | yes | `user.name`, falling back to the local part of the address |
1786
+ | `link` | yes | the ready-made reset URL |
1787
+ | `appName` | yes | derived from `package.json` `name` |
1788
+ | `logoSrc` | **no** | optional. The shipped templates render an `<img>` when it is present and the app name as text otherwise. Nothing in the framework supplies it — pass it from a subclass that overrides `sendPasswordResetEmail()`, or reference it from your own template. |
1789
+
1790
+ > **Keep the un-suffixed `password-reset.ejs` renderable from `{ link, name }` alone.**
1791
+ > The legacy `POST /users/password/reset-request` flow (`UserService.sendPasswordResetMail()`)
1792
+ > resolves that exact name and passes nothing else, so a template that
1793
+ > references `appName` unguarded turns legacy password recovery into an HTTP 500.
1794
+ > The framework's own shipped templates guard it; `tests/unit/email-templates.spec.ts`
1795
+ > pins the contract.
1796
+
1797
+ For Brevo, set a **reset-specific** template id (never reuse `brevoTemplateId` —
1798
+ that is the verification mail, and the user would be told to confirm their
1799
+ address instead of resetting their password):
1736
1800
 
1737
1801
  ```typescript
1738
1802
  // config.env.ts
1739
1803
  betterAuth: {
1740
- options: {
1741
- emailAndPassword: {
1742
- sendResetPassword: async ({ user, url, token }) => {
1743
- // Send password reset email
1744
- // 'url' contains the full reset URL with token
1745
- await emailService.sendEmail({
1746
- to: user.email,
1747
- subject: 'Reset Your Password',
1748
- html: `<a href="${url}">Click here to reset your password</a>`,
1749
- });
1750
- },
1751
- },
1804
+ emailVerification: {
1805
+ passwordResetBrevoTemplateId: 42,
1752
1806
  },
1753
1807
  },
1754
1808
  ```
1755
1809
 
1810
+ A Brevo send that reports failure by resolving to `null` falls through to SMTP
1811
+ rather than returning, so an outage does not leave a locked-out user with no
1812
+ mail at all.
1813
+
1814
+ To replace the sending logic entirely, override `sendPasswordResetEmail()` in a
1815
+ subclass of `CoreBetterAuthEmailVerificationService`, exactly as with
1816
+ `sendVerificationEmail()`.
1817
+
1818
+ #### Why not `betterAuth.options.emailAndPassword`
1819
+
1820
+ You _can_ set `sendResetPassword` there, and it will win — `options` is merged
1821
+ last. That is precisely why it is the wrong place:
1822
+
1823
+ - **Your callback replaces the framework hook entirely.** The mail no longer
1824
+ goes through `sendPasswordResetEmail()` or the templates above, and it loses
1825
+ the fire-and-forget wrapper that keeps the response time from revealing
1826
+ whether the address exists.
1827
+ - **The block you are writing into carries the password hashing.** It holds
1828
+ `password: { hash: nativeScryptHash, verify: nativeScryptVerify }` — the
1829
+ hashing every stored credential was created with. Since 11.36.1 the merge
1830
+ protects `password` key-by-key, so an unrelated key can no longer drop it, but
1831
+ staying out of that block is still the safer habit.
1832
+
1833
+ Use the hook, the template, and `passwordReset: false` to opt out.
1834
+
1756
1835
  #### Password Reset Flow (BetterAuth)
1757
1836
 
1758
- 1. User requests reset: `POST /iam/forgot-password` with `{ email }`
1759
- 2. BetterAuth generates token and calls `sendResetPassword` callback
1837
+ 1. User requests reset: `POST /iam/request-password-reset` with `{ email }` and
1838
+ an optional `redirectTo`. `redirectTo` is validated against
1839
+ `trustedOrigins` — a value outside it is rejected. Do not put a wildcard in
1840
+ `trustedOrigins`: the reset redirect carries the token, so any origin the
1841
+ wildcard admits can collect a live one.
1842
+ 2. BetterAuth generates a token (valid 1 h by default,
1843
+ `resetPasswordTokenExpiresIn`) and calls the built-in `sendResetPassword`
1844
+ hook, which delegates to `sendPasswordResetEmail()`
1760
1845
  3. User clicks link in email → navigates to reset page
1761
1846
  4. Frontend submits: `POST /iam/reset-password` with `{ token, newPassword }`
1762
1847
  5. BetterAuth updates password in `account` collection
@@ -128,6 +128,18 @@ export interface CreateBetterAuthOptions {
128
128
  */
129
129
  onEmailVerified?: OnEmailVerifiedCallback;
130
130
 
131
+ /**
132
+ * Callback for sending the password-reset email.
133
+ * Injected from CoreBetterAuthModule to use NestJS services.
134
+ *
135
+ * When provided, it is wired into Better-Auth's
136
+ * `emailAndPassword.sendResetPassword` hook, which is what ENABLES the native
137
+ * `POST /iam/request-password-reset` flow. Without a callback Better-Auth
138
+ * answers `RESET_PASSWORD_DISABLED` and no reset mail is ever sent — a
139
+ * password-reset page that cannot work, and users locked out for good.
140
+ */
141
+ sendResetPasswordEmail?: SendResetPasswordEmailCallback;
142
+
131
143
  /**
132
144
  * Callback for sending verification email
133
145
  * Injected from CoreBetterAuthModule to use NestJS services
@@ -174,11 +186,35 @@ export type OnEmailVerifiedCallback = (userId: string) => Promise<void>;
174
186
  * Callback for sending verification email
175
187
  * Injected from CoreBetterAuthModule to use NestJS services
176
188
  */
177
- export type SendVerificationEmailCallback = (options: {
189
+ export interface AuthEmailCallbackOptions {
190
+ /** The raw token, for consumers that build their own link. */
178
191
  token: string;
192
+
193
+ /** The ready-made URL Better-Auth generated. */
179
194
  url: string;
195
+
196
+ /** The user the mail is for. */
180
197
  user: { email: string; id: string; name?: null | string };
181
- }) => Promise<void>;
198
+ }
199
+
200
+ export type SendVerificationEmailCallback = (options: AuthEmailCallbackOptions) => Promise<void>;
201
+
202
+ /**
203
+ * Sends the password-reset mail. Same shape as the verification callback —
204
+ * `url` is the ready-made reset link Better-Auth generated, `token` the raw
205
+ * token for consumers that build their own link.
206
+ */
207
+ /**
208
+ * Sends the password-reset mail.
209
+ *
210
+ * Naming note — the word order flips at this boundary, once and deliberately. Everything on the
211
+ * Better-Auth side is `resetPassword` because Better-Auth's own option key is `sendResetPassword`;
212
+ * everything on the NestJS service side is `passwordReset`
213
+ * (`CoreBetterAuthEmailVerificationService.sendPasswordResetEmail`, `PASSWORD_RESET_TEMPLATE`,
214
+ * `passwordResetBrevoTemplateId`) because that is how the feature is named in this framework's
215
+ * config and templates. If you grep for one spelling and find half the feature, this is why.
216
+ */
217
+ export type SendResetPasswordEmailCallback = (options: AuthEmailCallbackOptions) => Promise<void>;
182
218
 
183
219
  /**
184
220
  * Invoke an auth-email send (verification / password-reset) fire-and-forget.
@@ -192,6 +228,12 @@ export type SendVerificationEmailCallback = (options: {
192
228
  * rejection) to `onError` instead. `onError` itself is guarded too: if the
193
229
  * handler throws, the error is swallowed rather than crashing the process.
194
230
  *
231
+ * Do NOT remove this wrapper on the grounds that Better-Auth "already detaches". Verified against
232
+ * better-auth 1.6.26 (`context/create-context.mjs:214`): `runInBackgroundOrAwait` detaches ONLY when
233
+ * `advanced.backgroundTasks.handler` is configured, and `else await promise`. This framework does
234
+ * not configure a handler, so without this wrapper Better-Auth would await the send — and the
235
+ * response time would once again reveal whether the address exists.
236
+ *
195
237
  * Deliberate divergence from Better-Auth's own mechanism: Better-Auth awaits
196
238
  * these callbacks via `runInBackgroundOrAwait` and offers
197
239
  * `advanced.backgroundTasks` as its native non-blocking path. That option is
@@ -214,6 +256,23 @@ export function sendAuthEmailSafely(send: () => unknown, onError: (error: unknow
214
256
  });
215
257
  }
216
258
 
259
+ /**
260
+ * Shallow-merge `override` onto `base`, skipping keys whose override value is `undefined`.
261
+ *
262
+ * Distinct from a spread on purpose — see the call site in the `emailAndPassword` merge.
263
+ */
264
+ function mergeDefined(base: unknown, override: unknown): Record<string, unknown> {
265
+ const merged: Record<string, unknown> = { ...(base as Record<string, unknown> | undefined) };
266
+ if (override && typeof override === 'object') {
267
+ for (const [key, value] of Object.entries(override as Record<string, unknown>)) {
268
+ if (value !== undefined) {
269
+ merged[key] = value;
270
+ }
271
+ }
272
+ }
273
+ return merged;
274
+ }
275
+
217
276
  /**
218
277
  * Better-Auth field type definition
219
278
  * Matches the DBFieldType from better-auth
@@ -312,7 +371,8 @@ export interface CreateBetterAuthResult {
312
371
  }
313
372
 
314
373
  export function createBetterAuthInstance(options: CreateBetterAuthOptions): CreateBetterAuthResult | null {
315
- const { config, db, fallbackSecrets, onEmailVerified, sendVerificationEmail, serverEnv } = options;
374
+ const { config, db, fallbackSecrets, onEmailVerified, sendResetPasswordEmail, sendVerificationEmail, serverEnv } =
375
+ options;
316
376
 
317
377
  // Return null only if better-auth is explicitly disabled
318
378
  // BetterAuth is enabled by default (zero-config)
@@ -448,6 +508,32 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Crea
448
508
  // is the point of resetting after a suspected takeover. Off by default so
449
509
  // the behaviour of existing deployments does not change under them.
450
510
  revokeSessionsOnPasswordReset: config.emailAndPassword?.revokeSessionsOnPasswordReset === true,
511
+ // Presence of this hook is what turns the native password reset ON, so withholding it is the
512
+ // only way to keep `POST /iam/request-password-reset` answering RESET_PASSWORD_DISABLED.
513
+ //
514
+ // Note what this condition does NOT buy: `CoreBetterAuthModule.createEmailVerificationCallbacks()`
515
+ // always supplies a callback — the "no mail service" case is handled INSIDE it — so for every
516
+ // consumer that goes through the module, the callback half is always true. An earlier comment
517
+ // here claimed a mail-less server would keep answering RESET_PASSWORD_DISABLED; it would not.
518
+ // `emailAndPassword.passwordReset: false` is the real off switch, for deployments whose reset
519
+ // policy is support-mediated or SSO-primary.
520
+ // Mirrors the emailVerification.sendVerificationEmail wiring.
521
+ ...(sendResetPasswordEmail &&
522
+ config.emailAndPassword?.passwordReset !== false && {
523
+ sendResetPassword: async (data: AuthEmailCallbackOptions) => {
524
+ // Deliberately NOT awaited: Better-Auth answers the request the same
525
+ // way whether or not the address exists, and awaiting the send would
526
+ // leak that difference as response time. A failed send must still be
527
+ // logged rather than crash the process — see sendAuthEmailSafely.
528
+ sendAuthEmailSafely(
529
+ () => sendResetPasswordEmail(data),
530
+ (error) =>
531
+ logger.error(
532
+ `Failed to send password-reset email: ${error instanceof Error ? error.message : String(error)}`,
533
+ ),
534
+ );
535
+ },
536
+ }),
451
537
  },
452
538
  plugins,
453
539
  secret: validation.resolvedSecret || config.secret,
@@ -467,15 +553,61 @@ export function createBetterAuthInstance(options: CreateBetterAuthOptions): Crea
467
553
  // When undefined, Better-Auth uses its default CORS behavior (allows all origins)
468
554
  if (trustedOrigins) {
469
555
  betterAuthConfig.trustedOrigins = trustedOrigins;
556
+
557
+ // A wildcard here was a CORS-looseness problem before the password-reset flow was wired; it is
558
+ // an account-takeover vector now. `redirectTo` on POST /iam/request-password-reset is validated
559
+ // against trustedOrigins, and GET /iam/reset-password/:token redirects to
560
+ // `<callbackURL>?token=<token>` — so a wildcard that admits an attacker-controlled subdomain
561
+ // hands them a live reset token for whichever address they named.
562
+ const wildcardOrigins = trustedOrigins.filter((origin) => typeof origin === 'string' && origin.includes('*'));
563
+ if (wildcardOrigins.length) {
564
+ logger.warn(
565
+ `betterAuth.trustedOrigins contains a wildcard (${wildcardOrigins.join(', ')}). The password-reset ` +
566
+ 'redirect is validated against this list, so any origin it admits can receive a live reset ' +
567
+ 'token. List exact origins instead.',
568
+ );
569
+ }
470
570
  }
471
571
 
472
572
  // Merge with custom options passthrough
473
573
  // This allows projects to configure any Better-Auth option not explicitly defined
474
- // Deep-merge 'advanced' to preserve cookiePrefix when options.advanced is provided
574
+ // Deep-merge 'advanced' to preserve cookiePrefix when options.advanced is provided,
575
+ // and 'emailAndPassword' to preserve the password hashing (see below).
475
576
  let finalConfig: Record<string, unknown>;
476
577
  if (config.options) {
477
- const { advanced: optionsAdvanced, ...restOptions } = config.options as Record<string, unknown>;
578
+ const {
579
+ advanced: optionsAdvanced,
580
+ emailAndPassword: optionsEmailAndPassword,
581
+ ...restOptions
582
+ } = config.options as Record<string, unknown>;
478
583
  finalConfig = { ...betterAuthConfig, ...restOptions };
584
+
585
+ // `emailAndPassword` needs a DEEP merge, and this one is load-bearing.
586
+ // The block above carries `password: { hash: nativeScryptHash, verify:
587
+ // nativeScryptVerify }` — the hashing every stored credential was created
588
+ // with. A shallow spread would let a consumer who sets `options.
589
+ // emailAndPassword` for an unrelated reason (a `sendResetPassword` callback,
590
+ // `maxPasswordLength`) replace the whole block and drop those functions.
591
+ // Better-Auth would fall back to its own default hasher, every existing
592
+ // password would stop verifying, and every user of that deployment would be
593
+ // locked out at once — with nothing in the logs to say why.
594
+ // `password` is therefore re-applied as the BASE, so an explicit override
595
+ // still wins while an unrelated key can no longer clobber it.
596
+ if (optionsEmailAndPassword && typeof optionsEmailAndPassword === 'object') {
597
+ const base = betterAuthConfig.emailAndPassword as Record<string, unknown>;
598
+ const override = optionsEmailAndPassword as Record<string, unknown>;
599
+ finalConfig.emailAndPassword = {
600
+ ...base,
601
+ ...override,
602
+ // Merged key-by-key rather than spread, because object spread copies an EXPLICITLY
603
+ // undefined value as a present key: `password: { hash: undefined }` would survive as
604
+ // `hash: undefined`, and Better-Auth resolves `password?.hash || hashPassword`, silently
605
+ // switching to its own hasher for WRITES while nest-server's scrypt verify still handles
606
+ // READS. The result is an asymmetric pair — anyone who resets their password can then
607
+ // never sign in again. An explicit override still wins; only `undefined` is ignored.
608
+ password: mergeDefined(base.password, override.password),
609
+ };
610
+ }
479
611
  if (optionsAdvanced && typeof optionsAdvanced === 'object') {
480
612
  // Drift guard: a programmatic `options.advanced.cookiePrefix` would only
481
613
  // change what Better-Auth itself sets — the NestJS layer keeps resolving
@@ -590,10 +722,7 @@ function buildEmailVerificationConfig(
590
722
 
591
723
  // Add sendVerificationEmail callback if provided
592
724
  if (sendVerificationEmail) {
593
- result.sendVerificationEmail = async (
594
- data: { token: string; url: string; user: { email: string; id: string; name?: null | string } },
595
- _request?: Request,
596
- ) => {
725
+ result.sendVerificationEmail = async (data: AuthEmailCallbackOptions, _request?: Request) => {
597
726
  // Fire-and-forget (timing-attack mitigation, per Better-Auth docs) — but a
598
727
  // failed send must be logged, never crash the process (see sendAuthEmailSafely).
599
728
  // Note: delivery failures are also logged (with masked recipient) by the