@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
@@ -4,6 +4,7 @@ import ejs = require('ejs');
4
4
  import * as fs from 'fs';
5
5
  import * as path from 'path';
6
6
 
7
+ import { isProductionLikeEnv } from '../../common/helpers/cookies.helper';
7
8
  import { maskEmail } from '../../common/helpers/logging.helper';
8
9
  import { IBetterAuthEmailVerificationConfig } from '../../common/interfaces/server-options.interface';
9
10
  import { BrevoService } from '../../common/services/brevo.service';
@@ -11,14 +12,24 @@ import { ConfigService } from '../../common/services/config.service';
11
12
  import { CoreRedisService } from '../../common/services/core-redis.service';
12
13
  import { EmailService } from '../../common/services/email.service';
13
14
  import { TemplateService } from '../../common/services/template.service';
14
- import { formatProjectName } from './better-auth.config';
15
+ import { AuthEmailCallbackOptions, formatProjectName } from './better-auth.config';
15
16
 
16
17
  /**
17
18
  * Resolved configuration type for email verification
18
- * Uses Required for mandatory fields but preserves optional nature of brevoTemplateId
19
+ * Uses Required for mandatory fields but preserves optional nature of the
20
+ * Brevo template ids — those stay undefined when no Brevo template is
21
+ * configured, which is what selects the SMTP/EJS path.
19
22
  */
20
- type ResolvedEmailVerificationConfig = Pick<IBetterAuthEmailVerificationConfig, 'brevoTemplateId' | 'callbackURL'> &
21
- Required<Omit<IBetterAuthEmailVerificationConfig, 'brevoTemplateId' | 'callbackURL' | 'resendCooldownSeconds'>> & {
23
+ type ResolvedEmailVerificationConfig = Pick<
24
+ IBetterAuthEmailVerificationConfig,
25
+ 'brevoTemplateId' | 'callbackURL' | 'passwordResetBrevoTemplateId'
26
+ > &
27
+ Required<
28
+ Omit<
29
+ IBetterAuthEmailVerificationConfig,
30
+ 'brevoTemplateId' | 'callbackURL' | 'passwordResetBrevoTemplateId' | 'resendCooldownSeconds'
31
+ >
32
+ > & {
22
33
  resendCooldownSeconds: number;
23
34
  };
24
35
 
@@ -94,9 +105,36 @@ export interface SendVerificationEmailOptions {
94
105
  */
95
106
  const LOCAL_SLOT_TOKEN = 'local';
96
107
 
108
+ /**
109
+ * Options for sending the password-reset email.
110
+ *
111
+ * Structurally the shared auth-email payload ({@link AuthEmailCallbackOptions}): `token` is the raw
112
+ * reset token for consumers that build their own link, `url` the ready-made link Better-Auth
113
+ * generated, `user` the account the reset was requested for. Aliased rather than redeclared so a
114
+ * future field lands in one place instead of six.
115
+ */
116
+ export type SendPasswordResetEmailOptions = AuthEmailCallbackOptions;
117
+
118
+ /**
119
+ * Template for the password-reset mail. Resolved through the same
120
+ * project-first / locale-aware lookup as the verification template, so a
121
+ * deployment brands it by dropping its own `password-reset[-<locale>].ejs`
122
+ * into the project template directory.
123
+ */
124
+ const PASSWORD_RESET_TEMPLATE = 'password-reset';
125
+
97
126
  @Injectable()
98
127
  export class CoreBetterAuthEmailVerificationService {
99
128
  protected readonly logger = new Logger(CoreBetterAuthEmailVerificationService.name);
129
+
130
+ /** Memoised `getAppName()` result — see the note there. */
131
+ protected cachedAppName?: string;
132
+
133
+ /** Compiled nest-server templates, keyed by resolved absolute path — see renderFrameworkTemplate. */
134
+ protected readonly compiledTemplates = new Map<string, ejs.TemplateFunction>();
135
+
136
+ /** Memoised `resolveTemplatePath()` results, keyed by `<name>:<locale>`. */
137
+ protected readonly resolvedTemplatePaths = new Map<string, { isAbsolute: boolean; path: string }>();
100
138
  protected config: ResolvedEmailVerificationConfig = DEFAULT_CONFIG;
101
139
 
102
140
  /**
@@ -187,13 +225,7 @@ export class CoreBetterAuthEmailVerificationService {
187
225
  url = this.buildFrontendVerificationUrl(token);
188
226
  }
189
227
 
190
- // Log verification URL in non-production environments for debugging and test capture
191
- // Uses console.log directly to ensure reliable capture in test environments (Vitest)
192
- // NestJS Logger may buffer output which makes interception unreliable in tests
193
- if (process.env.NODE_ENV !== 'production') {
194
- // oxlint-disable-next-line no-console
195
- console.log(`[EMAIL VERIFICATION] User: ${user.email}, URL: ${url}`);
196
- }
228
+ this.logAuthUrlForDevelopment('EMAIL VERIFICATION', user.email, url);
197
229
 
198
230
  // Brevo template path: send via Brevo transactional API if configured
199
231
  if (this.config.brevoTemplateId && this.brevoService) {
@@ -245,9 +277,8 @@ export class CoreBetterAuthEmailVerificationService {
245
277
  };
246
278
 
247
279
  if (resolved.isAbsolute) {
248
- // Fallback template from nest-server: render directly via EJS
249
- const templateContent = fs.readFileSync(`${resolved.path}.ejs`, 'utf-8');
250
- const html = ejs.render(templateContent, templateData);
280
+ // Fallback template from nest-server: render directly via EJS (compiled once)
281
+ const html = this.renderFrameworkTemplate(resolved.path, templateData);
251
282
 
252
283
  await this.emailService.sendMail(user.email, this.getEmailSubject(appName), { html });
253
284
  } else {
@@ -273,6 +304,135 @@ export class CoreBetterAuthEmailVerificationService {
273
304
  }
274
305
  }
275
306
 
307
+ /**
308
+ * Send the password-reset email.
309
+ *
310
+ * Called from Better-Auth's `emailAndPassword.sendResetPassword` hook.
311
+ * Override to customise delivery.
312
+ *
313
+ * Deliberately does NOT consult `isEnabled()` / `emailVerification.enabled`. Those govern email
314
+ * VERIFICATION; switching that off must not also take away password recovery, which is a
315
+ * different concern and the only way back in for a locked-out user. The reset flow's own switch
316
+ * is `betterAuth.emailAndPassword.passwordReset`. (This method lives on the verification service
317
+ * because it shares the template lookup, the Brevo overlay and the cooldown store with it — the
318
+ * config key `passwordResetBrevoTemplateId` sits under `emailVerification` for the same reason.)
319
+ *
320
+ * Throttled per ADDRESS (see the slot comment in the body), not per session: the abuse this
321
+ * unauthenticated route enables runs on the recipient axis, which an IP-keyed limiter cannot
322
+ * express. The route-level `betterAuth.rateLimit` remains the IP-axis bound.
323
+ *
324
+ * @param options - The reset email options from Better-Auth
325
+ * @throws Re-throws a Brevo or SMTP send failure after logging it with the masked address. The
326
+ * framework's own caller (`sendResetPassword` in better-auth.config.ts) invokes this through
327
+ * `sendAuthEmailSafely`, which catches and logs — so a throw never reaches the request. An
328
+ * override that calls this directly must handle it.
329
+ */
330
+ async sendPasswordResetEmail(options: SendPasswordResetEmailOptions): Promise<void> {
331
+ const { url, user } = options;
332
+
333
+ this.logAuthUrlForDevelopment('PASSWORD RESET', user.email, url);
334
+
335
+ // Per-ADDRESS throttle. The abuse this route enables runs on the RECIPIENT axis: an attacker
336
+ // rotating IPs mail-bombs one victim and writes one `verification` document per request, and an
337
+ // IP-keyed limiter cannot express "one reset mail per address per window". The slot is
338
+ // namespaced so the two flows cannot starve each other — a pending verification mail must not
339
+ // block a password reset.
340
+ //
341
+ // Returning early is enumeration-safe: Better-Auth has already produced its uniform
342
+ // "if this email exists in our system…" response by the time this hook runs, and the send is
343
+ // detached, so nothing about the response varies.
344
+ const slotKey = `${PASSWORD_RESET_TEMPLATE}:${user.email}`;
345
+ const slotToken = await this.acquireSendSlot(slotKey);
346
+ if (!slotToken) {
347
+ this.logger.debug(`Password-reset cooldown active for ${this.maskEmail(user.email)}, skipping email send`);
348
+ return;
349
+ }
350
+
351
+ // Only a SUCCESSFUL send may burn the cooldown — otherwise a transient SMTP failure would lock
352
+ // a user out of retrying for the whole window, on the one route that gets them back in.
353
+ let sent = false;
354
+
355
+ try {
356
+ const appName = this.getAppName();
357
+ const templateData = {
358
+ appName,
359
+ link: url,
360
+ name: user.name || user.email.split('@')[0],
361
+ };
362
+
363
+ // Brevo path: only when a reset-specific template is configured. Falling back
364
+ // to the VERIFICATION template id here would mail the user "confirm your
365
+ // address" for a password reset.
366
+ if (this.config.passwordResetBrevoTemplateId && this.brevoService) {
367
+ try {
368
+ const result = await this.brevoService.sendMail(
369
+ user.email,
370
+ this.config.passwordResetBrevoTemplateId,
371
+ templateData,
372
+ );
373
+
374
+ // `sendMail()` swallows SDK errors and resolves to `null` unless
375
+ // `brevo.throwOnError` is set, so "did not throw" is NOT "was delivered".
376
+ // Returning here on a null would leave a locked-out user with no mail at
377
+ // all; fall through to SMTP instead. Mirrors sendVerificationEmail.
378
+ if (result === null) {
379
+ this.logger.error(
380
+ `Brevo password-reset send failed for ${this.maskEmail(user.email)} — falling back to SMTP`,
381
+ );
382
+ // Deliberately no `return`: fall through to the EmailService path.
383
+ } else {
384
+ sent = true;
385
+ this.logger.debug(`Password-reset email sent via Brevo to ${this.maskEmail(user.email)}`);
386
+ return;
387
+ }
388
+ } catch (error) {
389
+ // Mirrors sendVerificationEmail: a Brevo THROW skips the SMTP fallback (only a `null`
390
+ // falls through). The address-identifying line matters — without it the only trace is
391
+ // the generic handler in better-auth.config.ts, which knows neither the user nor which
392
+ // of the two mail paths failed.
393
+ this.logger.error(
394
+ `Failed to send password-reset email via Brevo to ${this.maskEmail(user.email)}: ${error instanceof Error ? error.message : 'Unknown error'}`,
395
+ );
396
+ throw error;
397
+ }
398
+ }
399
+
400
+ if (!this.emailService) {
401
+ this.logger.warn('EmailService not available, cannot send password-reset email');
402
+ return;
403
+ }
404
+
405
+ try {
406
+ const resolved = await this.resolveTemplatePath(PASSWORD_RESET_TEMPLATE, this.config.locale);
407
+ const subject = this.getPasswordResetSubject(appName);
408
+
409
+ if (resolved.isAbsolute) {
410
+ // nest-server fallback template: render directly via EJS (compiled once, see the helper)
411
+ const html = this.renderFrameworkTemplate(resolved.path, templateData);
412
+ await this.emailService.sendMail(user.email, subject, { html });
413
+ } else {
414
+ // Project template: use TemplateService (relative path)
415
+ await this.emailService.sendMail(user.email, subject, {
416
+ htmlTemplate: resolved.path,
417
+ templateData,
418
+ });
419
+ }
420
+
421
+ sent = true;
422
+ this.logger.debug(`Password-reset email sent to ${this.maskEmail(user.email)}`);
423
+ } catch (error) {
424
+ this.logger.error(
425
+ `Failed to send password-reset email to ${this.maskEmail(user.email)}: ${error instanceof Error ? error.message : 'Unknown error'}`,
426
+ );
427
+ throw error;
428
+ }
429
+ } finally {
430
+ if (!sent) {
431
+ await this.releaseSendSlot(slotKey, slotToken);
432
+ }
433
+ }
434
+ }
435
+
276
436
  /**
277
437
  * Reserve the resend-cooldown slot for an email address.
278
438
  *
@@ -448,6 +608,15 @@ export class CoreBetterAuthEmailVerificationService {
448
608
  templateName: string,
449
609
  locale: string,
450
610
  ): Promise<{ isAbsolute: boolean; path: string }> {
611
+ // Memoised: this walk costs up to four blocking `existsSync` calls, and it runs per outgoing
612
+ // auth mail on a route an unauthenticated caller can drive. Template files do not appear at
613
+ // runtime, so re-walking buys nothing after the first resolution.
614
+ const cacheKey = `${templateName}:${locale}`;
615
+ const cached = this.resolvedTemplatePaths.get(cacheKey);
616
+ if (cached) {
617
+ return cached;
618
+ }
619
+
451
620
  const projectTemplatesPath = this.configService.getFastButReadOnly<string>('templates.path');
452
621
  const nestServerTemplatesPath = path.join(__dirname, '..', '..', '..', 'templates');
453
622
 
@@ -467,12 +636,13 @@ export class CoreBetterAuthEmailVerificationService {
467
636
 
468
637
  const fullPath = path.join(candidate.base, `${candidate.name}.ejs`);
469
638
  if (fs.existsSync(fullPath)) {
470
- if (candidate.isNestServer) {
471
- // nest-server template: return absolute path (rendered directly via EJS)
472
- return { isAbsolute: true, path: fullPath.replace('.ejs', '') };
473
- }
474
- // Project template: return relative name (for TemplateService)
475
- return { isAbsolute: false, path: candidate.name };
639
+ const resolved = candidate.isNestServer
640
+ ? // nest-server template: absolute path (rendered directly via EJS)
641
+ { isAbsolute: true, path: fullPath.replace('.ejs', '') }
642
+ : // Project template: relative name (for TemplateService)
643
+ { isAbsolute: false, path: candidate.name };
644
+ this.resolvedTemplatePaths.set(cacheKey, resolved);
645
+ return resolved;
476
646
  }
477
647
  }
478
648
 
@@ -484,7 +654,71 @@ export class CoreBetterAuthEmailVerificationService {
484
654
  /**
485
655
  * Get the app name for the email
486
656
  */
657
+ /**
658
+ * Render a nest-server fallback template, compiling it at most once per resolved path.
659
+ *
660
+ * `TemplateService` already caches compiled templates — but only for PROJECT templates. The
661
+ * absolute-path branch bypasses it entirely, so before this every outgoing auth mail re-read the
662
+ * file and re-compiled the EJS: measured at ~113 µs of event-loop-blocking work per mail against
663
+ * ~2 µs for a cached call. `readFileSync` blocks every other in-flight request while it runs, and
664
+ * the password-reset route that drives it is unauthenticated.
665
+ *
666
+ * Template files do not appear or change at runtime, so an unbounded map is bounded in practice
667
+ * by the number of templates on disk.
668
+ */
669
+ protected renderFrameworkTemplate(pathWithoutExtension: string, data: Record<string, unknown>): string {
670
+ let compiled = this.compiledTemplates.get(pathWithoutExtension);
671
+ if (!compiled) {
672
+ compiled = ejs.compile(fs.readFileSync(`${pathWithoutExtension}.ejs`, 'utf-8'));
673
+ this.compiledTemplates.set(pathWithoutExtension, compiled);
674
+ }
675
+ return compiled(data);
676
+ }
677
+
678
+ /**
679
+ * Print an auth link (verification / password reset) to stdout for local development.
680
+ *
681
+ * The URL embeds a bearer token: the verification link confirms an address, the reset link
682
+ * changes a password outright. Neither may reach a deployment holding real accounts, so the gate
683
+ * is `isProductionLikeEnv()` — the same two-layer check `EmailService` and the cookie helpers
684
+ * already use. `NODE_ENV !== 'production'` alone is NOT enough: a staging deployment sets
685
+ * `NODE_ENV=staging` so `getEnvironmentConfig()` loads its config block, which passes that test
686
+ * while holding real users.
687
+ *
688
+ * The address is masked (PII, and the rest of this file already masks it); the URL is printed in
689
+ * full because following it without a mail server is the whole point locally. Set
690
+ * `LT_LOG_AUTH_URLS=0` to suppress it even outside production.
691
+ *
692
+ * `console.log` rather than the NestJS logger, deliberately and for two reasons: the logger
693
+ * buffers, which makes interception unreliable under Vitest, and a logger call would route the
694
+ * token into `HubLogBufferService`'s ring buffer, which is readable over HTTP by any ADMIN.
695
+ */
696
+ protected logAuthUrlForDevelopment(label: string, email: string, url: string): void {
697
+ if (process.env.LT_LOG_AUTH_URLS === '0') {
698
+ return;
699
+ }
700
+ if (isProductionLikeEnv(this.configService?.getFastButReadOnly<string>('env'))) {
701
+ return;
702
+ }
703
+ // oxlint-disable-next-line no-console
704
+ console.log(`[${label}] User: ${this.maskEmail(email)}, URL: ${url}`);
705
+ }
706
+
487
707
  protected getAppName(): string {
708
+ // `package.json` never changes at runtime, but this runs once per outgoing auth mail — on a
709
+ // route an unauthenticated caller can drive. Cache it so that cannot become a synchronous
710
+ // file read per request. Mirrors `cachedProjectAppName` in better-auth.config.ts.
711
+ if (this.cachedAppName !== undefined) {
712
+ return this.cachedAppName;
713
+ }
714
+ this.cachedAppName = this.resolveAppName();
715
+ return this.cachedAppName;
716
+ }
717
+
718
+ /**
719
+ * Resolve the application name from package.json, falling back to a framework default.
720
+ */
721
+ protected resolveAppName(): string {
488
722
  // Try to get from package.json name
489
723
  try {
490
724
  const packageJsonPath = path.join(process.cwd(), 'package.json');
@@ -517,6 +751,17 @@ export class CoreBetterAuthEmailVerificationService {
517
751
  return `${appName} - Verify your email address`;
518
752
  }
519
753
 
754
+ /**
755
+ * Subject line of the password-reset mail.
756
+ */
757
+ protected getPasswordResetSubject(appName: string): string {
758
+ const subjects: Record<string, string> = {
759
+ de: `${appName} - Passwort zurücksetzen`,
760
+ en: `${appName} - Reset your password`,
761
+ };
762
+ return subjects[this.config.locale] ?? subjects.en;
763
+ }
764
+
520
765
  /**
521
766
  * Format expires in seconds to human readable string
522
767
  */
@@ -49,7 +49,21 @@ const DEFAULT_CONFIG: Required<IBetterAuthRateLimit> = {
49
49
  maxEntries: 10000,
50
50
  message: 'Too many requests, please try again later.',
51
51
  skipEndpoints: ['/session', '/callback'],
52
- strictEndpoints: ['/sign-in', '/sign-up', '/forgot-password', '/reset-password'],
52
+ // The routes Better-Auth actually serves, plus the spellings a project may route itself.
53
+ // `/request-password-reset` is the entry point that MINTS a reset token and sends mail; it
54
+ // matched nothing here before (the only `/` in it is followed by `request`, so
55
+ // `includes('/reset-password')` is false), which left the expensive, mail-sending half of the
56
+ // flow on the FULL limit while the cheap submit half got the halved one. `/forget-password` is
57
+ // Better-Auth's own alias spelling — `/forgot-password` never matched any real route.
58
+ strictEndpoints: [
59
+ '/sign-in',
60
+ '/sign-up',
61
+ '/request-password-reset',
62
+ '/forget-password',
63
+ '/forgot-password',
64
+ '/reset-password',
65
+ '/change-password',
66
+ ],
53
67
  windowSeconds: 60,
54
68
  };
55
69
 
@@ -88,10 +102,24 @@ export class CoreBetterAuthRateLimiter {
88
102
  *
89
103
  * @param config - Rate limiting configuration
90
104
  */
91
- configure(config: IBetterAuthRateLimit | undefined): void {
105
+ configure(config: IBetterAuthRateLimit | null | undefined): void {
106
+ // Absent config leaves rate limiting off — backward compatible.
107
+ if (config === undefined || config === null) {
108
+ return;
109
+ }
110
+
111
+ // Presence of config implies enabled, unless explicitly disabled. This is the contract
112
+ // `.claude/rules/configurable-features.md` documents for `betterAuth.rateLimit`, and the one
113
+ // `LegacyAuthRateLimiter.configure()` has always implemented. Spreading over an
114
+ // `enabled: false` default without recomputing meant the two sibling limiters had OPPOSITE
115
+ // semantics: a documented `rateLimit: { max: 20 }` armed the legacy limiter and silently
116
+ // armed nothing here — on the side where every new project lives.
117
+ const enabled = config.enabled !== false;
118
+
92
119
  this.config = {
93
120
  ...DEFAULT_CONFIG,
94
121
  ...config,
122
+ enabled,
95
123
  // Ensure arrays are properly merged
96
124
  skipEndpoints: config?.skipEndpoints ?? DEFAULT_CONFIG.skipEndpoints,
97
125
  strictEndpoints: config?.strictEndpoints ?? DEFAULT_CONFIG.strictEndpoints,
@@ -13,13 +13,19 @@ import { APP_GUARD } from '@nestjs/core';
13
13
  import { getConnectionToken } from '@nestjs/mongoose';
14
14
  import mongoose, { Connection } from 'mongoose';
15
15
 
16
+ import { maskEmail } from '../../common/helpers/logging.helper';
16
17
  import { IBetterAuth, ICorsConfig } from '../../common/interfaces/server-options.interface';
17
18
  import { BrevoService } from '../../common/services/brevo.service';
18
19
  import { ConfigService } from '../../common/services/config.service';
19
20
  import { RolesGuardRegistry } from '../auth/guards/roles-guard-registry';
20
21
  import { BetterAuthRolesGuard } from './better-auth-roles.guard';
21
22
  import { BetterAuthTokenService } from './better-auth-token.service';
22
- import { BetterAuthInstance, CreateBetterAuthResult, createBetterAuthInstance } from './better-auth.config';
23
+ import {
24
+ AuthEmailCallbackOptions,
25
+ BetterAuthInstance,
26
+ CreateBetterAuthResult,
27
+ createBetterAuthInstance,
28
+ } from './better-auth.config';
23
29
  import { DefaultBetterAuthResolver } from './better-auth.resolver';
24
30
  import { CoreBetterAuthApiMiddleware } from './core-better-auth-api.middleware';
25
31
  import { CoreBetterAuthChallengeService } from './core-better-auth-challenge.service';
@@ -406,8 +412,14 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
406
412
  consumer.apply(CoreBetterAuthMiddleware).forRoutes('(.*)'); // New path-to-regexp syntax for wildcard
407
413
  CoreBetterAuthModule.logger.debug('CoreBetterAuthMiddleware registered for all routes');
408
414
 
409
- // Apply rate limiting to Better-Auth endpoints only
410
- if (CoreBetterAuthModule.currentConfig?.rateLimit?.enabled) {
415
+ // Apply rate limiting to Better-Auth endpoints only.
416
+ // Mirrors the "presence implies enabled" contract `CoreBetterAuthRateLimiter.configure()`
417
+ // implements. Gating the MOUNT on `.enabled` alone made that fix unobservable: the limiter
418
+ // would report itself enabled while no middleware was ever registered.
419
+ if (
420
+ CoreBetterAuthModule.currentConfig?.rateLimit &&
421
+ CoreBetterAuthModule.currentConfig.rateLimit.enabled !== false
422
+ ) {
411
423
  consumer.apply(CoreBetterAuthRateLimitMiddleware).forRoutes(`${basePath}/*path`);
412
424
  CoreBetterAuthModule.logger.debug(`Rate limiting middleware registered for ${basePath}/*path endpoints`);
413
425
  }
@@ -655,7 +667,8 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
655
667
  const fallbackSecrets = [jwtConfig?.secret, jwtConfig?.refresh?.secret];
656
668
 
657
669
  // Create email verification callbacks that delegate to the NestJS service
658
- const { onEmailVerified, sendVerificationEmail } = this.createEmailVerificationCallbacks();
670
+ const { onEmailVerified, sendResetPasswordEmail, sendVerificationEmail } =
671
+ this.createEmailVerificationCallbacks();
659
672
 
660
673
  // Note: Secret validation is now handled in createBetterAuthInstance
661
674
  // with fallback to jwt.secret, jwt.refresh.secret, or auto-generation
@@ -664,6 +677,7 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
664
677
  db,
665
678
  fallbackSecrets,
666
679
  onEmailVerified,
680
+ sendResetPasswordEmail,
667
681
  sendVerificationEmail,
668
682
  });
669
683
  this.authInstance = result?.instance ?? null;
@@ -802,11 +816,8 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
802
816
  */
803
817
  private static createEmailVerificationCallbacks(): {
804
818
  onEmailVerified: (userId: string) => Promise<void>;
805
- sendVerificationEmail: (options: {
806
- token: string;
807
- url: string;
808
- user: { email: string; id: string; name?: null | string };
809
- }) => Promise<void>;
819
+ sendResetPasswordEmail: (options: AuthEmailCallbackOptions) => Promise<void>;
820
+ sendVerificationEmail: (options: AuthEmailCallbackOptions) => Promise<void>;
810
821
  } {
811
822
  return {
812
823
  onEmailVerified: async (userId: string) => {
@@ -829,14 +840,33 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
829
840
  );
830
841
  }
831
842
  },
843
+ sendResetPasswordEmail: async (options) => {
844
+ // Delegate to the NestJS service
845
+ if (this.emailVerificationService) {
846
+ await this.emailVerificationService.sendPasswordResetEmail(options);
847
+ } else {
848
+ // Report the failure, never the capability. The reset URL is a bearer token for account
849
+ // takeover, this branch has no environment gate, and `this.logger` feeds
850
+ // `HubLogBufferService`'s ring buffer — which is readable over HTTP by any ADMIN. The
851
+ // operator needs to know delivery is broken, not to be handed the victim's token.
852
+ this.logger.error(
853
+ `Password reset requested for ${maskEmail(options.user.email)} but CoreBetterAuthEmailVerificationService ` +
854
+ 'is not available — NO mail was sent. Register the service, or disable the flow via ' +
855
+ 'betterAuth.emailAndPassword.passwordReset: false.',
856
+ );
857
+ }
858
+ },
832
859
  sendVerificationEmail: async (options) => {
833
860
  // Delegate to the NestJS service
834
861
  if (this.emailVerificationService) {
835
862
  await this.emailVerificationService.sendVerificationEmail(options);
836
863
  } else {
837
- // Fallback: Log verification URL if service not available
838
- this.logger.warn('Email verification service not available, logging URL for development');
839
- this.logger.log(`[DEV] Verification URL for ${options.user.email}: ${options.url}`);
864
+ // Same reasoning as the password-reset branch above: the URL carries a token and this
865
+ // logger feeds the ADMIN-readable Hub log buffer, so report the breakage instead.
866
+ this.logger.error(
867
+ `Verification mail requested for ${maskEmail(options.user.email)} but ` +
868
+ 'CoreBetterAuthEmailVerificationService is not available — NO mail was sent.',
869
+ );
840
870
  }
841
871
  },
842
872
  };
@@ -902,13 +932,15 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
902
932
  this.mongoConnection = connection;
903
933
 
904
934
  // Create email verification callbacks that delegate to the NestJS service
905
- const { onEmailVerified, sendVerificationEmail } = this.createEmailVerificationCallbacks();
935
+ const { onEmailVerified, sendResetPasswordEmail, sendVerificationEmail } =
936
+ this.createEmailVerificationCallbacks();
906
937
 
907
938
  // Build shared instance options
908
939
  const sharedInstanceOptions = {
909
940
  config,
910
941
  fallbackSecrets: options?.fallbackSecrets,
911
942
  onEmailVerified,
943
+ sendResetPasswordEmail,
912
944
  sendVerificationEmail,
913
945
  serverAppUrl: options?.serverAppUrl,
914
946
  serverBaseUrl: options?.serverBaseUrl,
@@ -93,7 +93,7 @@ export class CoreBetterAuthService implements OnModuleInit {
93
93
  }
94
94
 
95
95
  /**
96
- * Ensure performance indices exist on session and users collections.
96
+ * Ensure performance indices exist on the session, users, account and verification collections.
97
97
  * Indices are idempotent — calling createIndex on an existing index is a no-op.
98
98
  */
99
99
  async onModuleInit(): Promise<void> {
@@ -112,9 +112,17 @@ export class CoreBetterAuthService implements OnModuleInit {
112
112
  // Account: userId lookup ($lookup in getMigrationStatus) and providerId filtering
113
113
  db.collection('account').createIndex({ userId: 1 }),
114
114
  db.collection('account').createIndex({ providerId: 1, userId: 1 }),
115
+ // Verification: identifier lookup (findVerificationValue runs on every verify/reset).
116
+ db.collection('verification').createIndex({ identifier: 1 }),
117
+ // Verification TTL — this one is correctness, not just speed. Better-Auth writes a
118
+ // verification document per email-verification AND per password-reset request, and removes
119
+ // it only when the token is CONSUMED. `POST /iam/request-password-reset` is unauthenticated,
120
+ // so every un-clicked request left a row that nothing would ever delete. The documents
121
+ // already carry `expiresAt`; `expireAfterSeconds: 0` makes MongoDB honour it.
122
+ db.collection('verification').createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 }),
115
123
  ]);
116
124
 
117
- this.logger.debug('Performance indices ensured on session, users, and account collections');
125
+ this.logger.debug('Performance indices ensured on session, users, account, and verification collections');
118
126
  } catch (error) {
119
127
  // Non-fatal: indices improve performance but are not required for correctness
120
128
  this.logger.warn(`Could not create performance indices: ${error instanceof Error ? error.message : 'unknown'}`);
@@ -74,7 +74,22 @@ export class CoreTenantService {
74
74
  }
75
75
 
76
76
  /**
77
- * Get a single membership (any status).
77
+ * Get a single membership REGARDLESS of its status.
78
+ *
79
+ * Read the name literally: a removal is a status change to `SUSPENDED`, not
80
+ * a delete, so this still answers for somebody who was thrown out. That is
81
+ * deliberate and `addMember` depends on it — it reactivates the existing row
82
+ * instead of creating a duplicate.
83
+ *
84
+ * It is therefore the WRONG method for an authorization check. Asking "is
85
+ * this user a member with role X?" through it answers yes for a removed
86
+ * member, and a route that guards itself this way keeps granting a
87
+ * suspended administrator the right to invite, remove and re-role — the very
88
+ * rights that being removed was supposed to take away. Routes that carry
89
+ * `@SkipTenantCheck()` and decide for themselves are exactly the ones at
90
+ * risk, because the tenant guard never sees them.
91
+ *
92
+ * For an authorization check use {@link getActiveMembership}.
78
93
  */
79
94
  async getMembership(tenantId: string, userId: string): Promise<CoreTenantMemberModel | null> {
80
95
  return this.memberModel
@@ -83,6 +98,25 @@ export class CoreTenantService {
83
98
  .exec() as Promise<CoreTenantMemberModel | null>;
84
99
  }
85
100
 
101
+ /**
102
+ * Get a membership only while it is live — the one to use when deciding what
103
+ * somebody may do.
104
+ *
105
+ * Returns `null` for a suspended or invited membership, so "removed" means
106
+ * removed everywhere, not just for the data the tenant guard happens to
107
+ * cover.
108
+ */
109
+ async getActiveMembership(tenantId: string, userId: string): Promise<CoreTenantMemberModel | null> {
110
+ if (!tenantId?.trim() || !userId?.trim()) {
111
+ return null;
112
+ }
113
+
114
+ return this.memberModel
115
+ .findOne({ status: TenantMemberStatus.ACTIVE, tenant: tenantId, user: userId })
116
+ .lean()
117
+ .exec() as Promise<CoreTenantMemberModel | null>;
118
+ }
119
+
86
120
  /**
87
121
  * Add a member to a tenant.
88
122
  * Uses bypassTenantGuard to avoid tenant filtering on the membership collection itself.
@@ -193,8 +227,11 @@ export class CoreTenantService {
193
227
  assertAssignableMembershipRole(role);
194
228
  const highestRole = this.getHighestRole();
195
229
 
196
- // If demoting from highest role, ensure it's not the last one
197
- const existing = await this.getMembership(tenantId, userId);
230
+ // If demoting from highest role, ensure it's not the last one. Active
231
+ // only: a suspended membership is not an owner any more, and letting it
232
+ // trigger the guard produces "cannot demote the last owner" for a user who
233
+ // is not even a member.
234
+ const existing = await this.getActiveMembership(tenantId, userId);
198
235
  if (existing?.role === highestRole && role !== highestRole) {
199
236
  await this.assertNotLastOwner(tenantId, userId);
200
237
  }
@@ -237,7 +274,7 @@ export class CoreTenantService {
237
274
  });
238
275
 
239
276
  if (ownerCount <= 1) {
240
- const membership = await this.getMembership(tenantId, userId);
277
+ const membership = await this.getActiveMembership(tenantId, userId);
241
278
  if (membership?.role === highestRole) {
242
279
  throw new BadRequestException('Cannot remove or demote the last owner of a tenant');
243
280
  }