@lenne.tech/nest-server 11.21.0 → 11.21.1

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 (39) hide show
  1. package/README.md +444 -100
  2. package/dist/core/common/helpers/input.helper.js +11 -8
  3. package/dist/core/common/helpers/input.helper.js.map +1 -1
  4. package/dist/core/common/interceptors/check-security.interceptor.js +5 -7
  5. package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
  6. package/dist/core/common/interfaces/server-options.interface.d.ts +1 -0
  7. package/dist/core/common/services/email.service.d.ts +5 -1
  8. package/dist/core/common/services/email.service.js +16 -2
  9. package/dist/core/common/services/email.service.js.map +1 -1
  10. package/dist/core/common/services/request-context.service.js +6 -0
  11. package/dist/core/common/services/request-context.service.js.map +1 -1
  12. package/dist/core/modules/auth/tokens.decorator.d.ts +1 -1
  13. package/dist/core/modules/better-auth/core-better-auth-user.mapper.d.ts +6 -0
  14. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +52 -17
  15. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  16. package/dist/core/modules/better-auth/core-better-auth.service.d.ts +3 -1
  17. package/dist/core/modules/better-auth/core-better-auth.service.js +14 -0
  18. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  19. package/dist/core/modules/tenant/core-tenant.guard.d.ts +14 -2
  20. package/dist/core/modules/tenant/core-tenant.guard.js +123 -14
  21. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  22. package/dist/core/modules/tenant/core-tenant.service.d.ts +3 -1
  23. package/dist/core/modules/tenant/core-tenant.service.js +14 -4
  24. package/dist/core/modules/tenant/core-tenant.service.js.map +1 -1
  25. package/dist/core/modules/user/core-user.service.js +12 -1
  26. package/dist/core/modules/user/core-user.service.js.map +1 -1
  27. package/dist/tsconfig.build.tsbuildinfo +1 -1
  28. package/package.json +30 -21
  29. package/src/core/common/helpers/input.helper.ts +24 -9
  30. package/src/core/common/interceptors/check-security.interceptor.ts +10 -11
  31. package/src/core/common/interfaces/server-options.interface.ts +19 -0
  32. package/src/core/common/services/email.service.ts +26 -5
  33. package/src/core/common/services/request-context.service.ts +8 -0
  34. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +86 -21
  35. package/src/core/modules/better-auth/core-better-auth.service.ts +27 -2
  36. package/src/core/modules/tenant/README.md +38 -2
  37. package/src/core/modules/tenant/core-tenant.guard.ts +219 -18
  38. package/src/core/modules/tenant/core-tenant.service.ts +13 -4
  39. package/src/core/modules/user/core-user.service.ts +17 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.21.0",
3
+ "version": "11.21.1",
4
4
  "description": "Modern, fast, powerful Node.js web framework in TypeScript based on Nest with a GraphQL API and a connection to MongoDB (or other databases).",
5
5
  "keywords": [
6
6
  "node",
@@ -73,22 +73,22 @@
73
73
  "node": ">= 20"
74
74
  },
75
75
  "dependencies": {
76
- "@apollo/server": "5.4.0",
76
+ "@apollo/server": "5.5.0",
77
77
  "@as-integrations/express5": "1.1.2",
78
78
  "@better-auth/passkey": "1.5.5",
79
79
  "@getbrevo/brevo": "3.0.1",
80
80
  "@nestjs/apollo": "13.2.4",
81
- "@nestjs/common": "11.1.16",
82
- "@nestjs/core": "11.1.16",
81
+ "@nestjs/common": "11.1.17",
82
+ "@nestjs/core": "11.1.17",
83
83
  "@nestjs/graphql": "13.2.4",
84
84
  "@nestjs/jwt": "11.0.2",
85
85
  "@nestjs/mongoose": "11.0.4",
86
86
  "@nestjs/passport": "11.0.5",
87
- "@nestjs/platform-express": "11.1.16",
87
+ "@nestjs/platform-express": "11.1.17",
88
88
  "@nestjs/schedule": "6.1.1",
89
89
  "@nestjs/swagger": "11.2.6",
90
90
  "@nestjs/terminus": "11.1.1",
91
- "@nestjs/websockets": "11.1.16",
91
+ "@nestjs/websockets": "11.1.17",
92
92
  "@tus/file-store": "2.0.0",
93
93
  "@tus/server": "2.3.0",
94
94
  "bcrypt": "6.0.0",
@@ -100,18 +100,18 @@
100
100
  "dotenv": "17.3.1",
101
101
  "ejs": "5.0.1",
102
102
  "express": "5.2.1",
103
- "graphql": "16.13.1",
103
+ "graphql": "16.13.2",
104
104
  "graphql-query-complexity": "1.1.0",
105
105
  "graphql-subscriptions": "3.0.0",
106
106
  "graphql-upload": "15.0.2",
107
107
  "js-sha256": "0.11.1",
108
108
  "json-to-graphql-query": "2.3.0",
109
109
  "lodash": "4.17.23",
110
- "mongodb": "7.1.0",
111
- "mongoose": "9.3.0",
110
+ "mongodb": "7.1.1",
111
+ "mongoose": "9.3.3",
112
112
  "multer": "2.1.1",
113
113
  "node-mailjet": "6.0.11",
114
- "nodemailer": "8.0.2",
114
+ "nodemailer": "8.0.4",
115
115
  "passport": "0.7.0",
116
116
  "passport-jwt": "4.0.1",
117
117
  "reflect-metadata": "0.2.2",
@@ -122,11 +122,11 @@
122
122
  },
123
123
  "devDependencies": {
124
124
  "@compodoc/compodoc": "1.2.1",
125
- "@nestjs/cli": "11.0.16",
126
- "@nestjs/schematics": "11.0.9",
127
- "@nestjs/testing": "11.1.16",
125
+ "@nestjs/cli": "11.0.17",
126
+ "@nestjs/schematics": "11.0.10",
127
+ "@nestjs/testing": "11.1.17",
128
128
  "@swc/cli": "0.8.0",
129
- "@swc/core": "1.15.18",
129
+ "@swc/core": "1.15.21",
130
130
  "@types/compression": "1.8.1",
131
131
  "@types/cookie-parser": "1.4.10",
132
132
  "@types/ejs": "3.1.5",
@@ -137,16 +137,16 @@
137
137
  "@types/nodemailer": "7.0.11",
138
138
  "@types/passport": "1.0.17",
139
139
  "@types/supertest": "7.2.0",
140
- "@vitest/coverage-v8": "4.1.0",
141
- "@vitest/ui": "4.1.0",
140
+ "@vitest/coverage-v8": "4.1.2",
141
+ "@vitest/ui": "4.1.2",
142
142
  "ansi-colors": "4.1.3",
143
143
  "find-file-up": "2.0.1",
144
144
  "husky": "9.1.7",
145
145
  "nodemon": "3.1.14",
146
146
  "npm-watch": "0.13.0",
147
147
  "otpauth": "9.5.0",
148
- "oxfmt": "0.40.0",
149
- "oxlint": "1.55.0",
148
+ "oxfmt": "0.43.0",
149
+ "oxlint": "1.58.0",
150
150
  "rimraf": "6.1.3",
151
151
  "supertest": "7.2.2",
152
152
  "ts-node": "10.9.2",
@@ -157,7 +157,7 @@
157
157
  "vite": "7.3.1",
158
158
  "vite-plugin-node": "7.0.0",
159
159
  "vite-tsconfig-paths": "6.1.1",
160
- "vitest": "4.1.0"
160
+ "vitest": "4.1.2"
161
161
  },
162
162
  "main": "dist/index.js",
163
163
  "types": "dist/index.d.ts",
@@ -179,12 +179,21 @@
179
179
  "minimatch@<3.1.4": "3.1.4",
180
180
  "minimatch@>=9.0.0 <9.0.7": "9.0.7",
181
181
  "minimatch@>=10.0.0 <10.2.3": "10.2.4",
182
- "rollup@>=4.0.0 <4.59.0": "4.59.0",
182
+ "rollup@>=4.0.0 <4.60.1": "4.60.1",
183
183
  "ajv@<6.14.0": "6.14.0",
184
184
  "ajv@>=7.0.0-alpha.0 <8.18.0": "8.18.0",
185
185
  "file-type@>=13.0.0 <21.3.2": "21.3.2",
186
186
  "undici@>=7.0.0 <7.24.0": "7.24.3",
187
- "yauzl@<3.2.1": "3.2.1"
187
+ "yauzl@<3.2.1": "3.2.1",
188
+ "flatted@<=3.4.1": "3.4.2",
189
+ "srvx@<0.11.13": "0.11.13",
190
+ "handlebars@>=4.0.0 <4.7.9": "4.7.9",
191
+ "brace-expansion@<1.1.13": "1.1.13",
192
+ "brace-expansion@>=4.0.0 <5.0.5": "5.0.5",
193
+ "picomatch@<2.3.2": "2.3.2",
194
+ "picomatch@>=4.0.0 <4.0.4": "4.0.4",
195
+ "path-to-regexp@>=8.0.0 <8.4.0": "8.4.1",
196
+ "kysely@>=0.26.0 <0.28.15": "0.28.15"
188
197
  },
189
198
  "onlyBuiltDependencies": [
190
199
  "bcrypt",
@@ -783,15 +783,30 @@ export function processDeep(
783
783
  specialProperties?: string[];
784
784
  },
785
785
  ): any {
786
- // Set options
787
- const { processedObjects, specialClasses, specialFunctions, specialProperties } = {
788
- processedObjects: new WeakMap(),
789
- specialClasses: [],
790
- specialFunctions: [],
791
- specialProperties: [],
792
- ...options,
786
+ // Set options once and reuse for all recursive calls (avoids creating new objects per property)
787
+ const resolvedOptions = {
788
+ processedObjects: options?.processedObjects ?? new WeakMap(),
789
+ specialClasses: options?.specialClasses ?? [],
790
+ specialFunctions: options?.specialFunctions ?? [],
791
+ specialProperties: options?.specialProperties ?? [],
793
792
  };
794
793
 
794
+ return processDeepInternal(data, func, resolvedOptions);
795
+ }
796
+
797
+ /** Internal recursive implementation that reuses the resolved options object */
798
+ function processDeepInternal(
799
+ data: any,
800
+ func: (data: any) => any,
801
+ options: {
802
+ processedObjects: WeakMap<any, boolean>;
803
+ specialClasses: ((new (args: any[]) => any) | string)[];
804
+ specialFunctions: string[];
805
+ specialProperties: string[];
806
+ },
807
+ ): any {
808
+ const { processedObjects, specialClasses, specialFunctions, specialProperties } = options;
809
+
795
810
  // Check for falsifiable values
796
811
  if (!data) {
797
812
  return func(data);
@@ -806,7 +821,7 @@ export function processDeep(
806
821
 
807
822
  // Process array
808
823
  if (Array.isArray(data)) {
809
- return func(data.map((item) => processDeep(item, func, { processedObjects, specialClasses })));
824
+ return func(data.map((item) => processDeepInternal(item, func, options)));
810
825
  }
811
826
 
812
827
  // Process object
@@ -826,7 +841,7 @@ export function processDeep(
826
841
  }
827
842
  }
828
843
  for (const [key, value] of Object.entries(data)) {
829
- data[key] = processDeep(value, func, { processedObjects, specialClasses });
844
+ data[key] = processDeepInternal(value, func, options);
830
845
  }
831
846
  return func(data);
832
847
  }
@@ -62,18 +62,17 @@ export class CheckSecurityInterceptor implements NestInterceptor {
62
62
 
63
63
  // Check data
64
64
  if (data && typeof data === 'object' && typeof data.securityCheck === 'function') {
65
- const dataJson = JSON.stringify(data);
65
+ // Only capture pre-check state when debug is active (JSON.stringify is expensive)
66
+ const dataJson = this.config.debug ? JSON.stringify(data) : undefined;
66
67
  const response = data.securityCheck(user, force);
67
- new Promise(() => {
68
- if (this.config.debug && dataJson !== JSON.stringify(response)) {
69
- const id = getStringIds(data);
70
- console.debug(
71
- 'CheckSecurityInterceptor: securityCheck changed data of type',
72
- data.constructor.name,
73
- id && !Array.isArray(id) ? `with ID: ${id}` : '',
74
- );
75
- }
76
- });
68
+ if (this.config.debug && dataJson !== JSON.stringify(response)) {
69
+ const id = getStringIds(data);
70
+ console.debug(
71
+ 'CheckSecurityInterceptor: securityCheck changed data of type',
72
+ data.constructor.name,
73
+ id && !Array.isArray(id) ? `with ID: ${id}` : '',
74
+ );
75
+ }
77
76
  if (response && !data._doNotCheckSecurityDeep) {
78
77
  for (const key of Object.keys(response)) {
79
78
  response[key] = check(response[key]);
@@ -895,6 +895,25 @@ export interface IMultiTenancy {
895
895
  * ```
896
896
  */
897
897
  roleHierarchy?: Record<string, number>;
898
+
899
+ /**
900
+ * TTL in milliseconds for the tenant guard's in-memory membership cache.
901
+ * The cache avoids repeated DB lookups when the same user accesses the same tenant.
902
+ * Set to 0 to disable caching (useful for testing or security-critical deployments).
903
+ *
904
+ * **Important:** This cache is process-local. In horizontally scaled deployments
905
+ * (multiple server instances), membership changes on one instance are not reflected
906
+ * on other instances until the TTL expires. For security-sensitive deployments,
907
+ * reduce the TTL or set to 0 to disable.
908
+ *
909
+ * Note: `CoreBetterAuthUserMapper` has an independent 15-second user cache for
910
+ * roles and verified status. Both caches affect revocation latency. To control both,
911
+ * set this to 0 and override `USER_CACHE_TTL_MS` in a custom mapper.
912
+ *
913
+ * @default 30000 (30 seconds)
914
+ * @since 11.21.1
915
+ */
916
+ cacheTtlMs?: number;
898
917
  }
899
918
 
900
919
  /**
@@ -1,6 +1,7 @@
1
1
  import type SMTPPool = require('nodemailer/lib/smtp-pool');
2
2
 
3
- import { Injectable } from '@nestjs/common';
3
+ import { createHash } from 'crypto';
4
+ import { Injectable, OnModuleDestroy } from '@nestjs/common';
4
5
  import nodemailer = require('nodemailer');
5
6
  import { Attachment } from 'nodemailer/lib/mailer';
6
7
 
@@ -12,7 +13,14 @@ import { TemplateService } from './template.service';
12
13
  * Email service
13
14
  */
14
15
  @Injectable()
15
- export class EmailService {
16
+ export class EmailService implements OnModuleDestroy {
17
+ /**
18
+ * Cached transporter to avoid creating new SMTP connections per email.
19
+ * Reused as long as the SMTP config hasn't changed.
20
+ */
21
+ private cachedTransporter: nodemailer.Transporter | null = null;
22
+ private cachedSmtpConfig: string | null = null;
23
+
16
24
  /**
17
25
  * Inject services
18
26
  */
@@ -21,6 +29,14 @@ export class EmailService {
21
29
  protected templateService: TemplateService,
22
30
  ) {}
23
31
 
32
+ onModuleDestroy(): void {
33
+ if (this.cachedTransporter) {
34
+ this.cachedTransporter.close();
35
+ this.cachedTransporter = null;
36
+ this.cachedSmtpConfig = null;
37
+ }
38
+ }
39
+
24
40
  /**
25
41
  * Send a mail
26
42
  */
@@ -74,11 +90,16 @@ export class EmailService {
74
90
  isNonEmptyString(html);
75
91
  }
76
92
 
77
- // Init transporter
78
- const transporter = nodemailer.createTransport(smtp);
93
+ // Reuse transporter if SMTP config hasn't changed (avoids creating new connections per email)
94
+ // Use hash instead of raw JSON to avoid keeping credentials as a string in memory
95
+ const smtpKey = createHash('sha256').update(JSON.stringify(smtp)).digest('hex');
96
+ if (!this.cachedTransporter || this.cachedSmtpConfig !== smtpKey) {
97
+ this.cachedTransporter = nodemailer.createTransport(smtp);
98
+ this.cachedSmtpConfig = smtpKey;
99
+ }
79
100
 
80
101
  // Send mail
81
- return transporter.sendMail({
102
+ return this.cachedTransporter.sendMail({
82
103
  attachments,
83
104
  from: `"${senderName}" <${senderEmail}>`,
84
105
  html,
@@ -70,6 +70,10 @@ export class RequestContext {
70
70
  */
71
71
  static runWithBypassRoleGuard<T>(fn: () => T): T {
72
72
  const currentStore = this.storage.getStore();
73
+ // Skip context creation if already bypassed (avoids redundant object spread)
74
+ if (currentStore?.bypassRoleGuard) {
75
+ return fn();
76
+ }
73
77
  const context: IRequestContext = {
74
78
  ...currentStore,
75
79
  bypassRoleGuard: true,
@@ -103,6 +107,10 @@ export class RequestContext {
103
107
  */
104
108
  static runWithBypassTenantGuard<T>(fn: () => T): T {
105
109
  const currentStore = this.storage.getStore();
110
+ // Skip context creation if already bypassed (avoids redundant object spread)
111
+ if (currentStore?.bypassTenantGuard) {
112
+ return fn();
113
+ }
106
114
  const context: IRequestContext = {
107
115
  ...currentStore,
108
116
  bypassTenantGuard: true,
@@ -97,10 +97,32 @@ export interface SyncedUserDocument {
97
97
  * - IAM → Legacy: Copies password from `accounts` to `users.password`
98
98
  * - Legacy → IAM: Creates account entry in `accounts` from `users.password`
99
99
  */
100
+ /**
101
+ * Cached user data for mapSessionUser (lightweight: only the fields needed for MappedUser)
102
+ */
103
+ interface CachedUserData {
104
+ expiresAt: number;
105
+ id: string;
106
+ roles: string[];
107
+ verified: boolean;
108
+ }
109
+
100
110
  @Injectable()
101
111
  export class CoreBetterAuthUserMapper {
102
112
  private readonly logger = new Logger(CoreBetterAuthUserMapper.name);
103
113
 
114
+ /**
115
+ * Lightweight TTL cache for user DB lookups.
116
+ * Key: iamId (BetterAuth user ID), Value: minimal user data (id, roles, verified).
117
+ * Only caches ~100 bytes per entry (no full documents). Max 500 entries = ~50KB.
118
+ */
119
+ private readonly userCache = new Map<string, CachedUserData>();
120
+ private static readonly USER_CACHE_MAX = 500;
121
+
122
+ /** Cache TTL: 15s in production, disabled in test environments */
123
+ private static readonly USER_CACHE_TTL_MS =
124
+ process.env.VITEST === 'true' || process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'e2e' ? 0 : 15_000;
125
+
104
126
  constructor(@Optional() @InjectConnection() private readonly connection?: Connection) {}
105
127
 
106
128
  /**
@@ -135,6 +157,23 @@ export class CoreBetterAuthUserMapper {
135
157
  }
136
158
 
137
159
  try {
160
+ // Check lightweight cache first (only stores id, roles, verified — ~100 bytes per entry)
161
+ const ttl = CoreBetterAuthUserMapper.USER_CACHE_TTL_MS;
162
+ const now = Date.now();
163
+ const cached = ttl > 0 ? this.userCache.get(sessionUser.id) : undefined;
164
+ if (cached && now < cached.expiresAt) {
165
+ return this.createMappedUser({
166
+ email: sessionUser.email,
167
+ emailVerified: sessionUser.emailVerified,
168
+ iamId: sessionUser.id,
169
+ id: cached.id,
170
+ image: sessionUser.image,
171
+ name: sessionUser.name,
172
+ roles: cached.roles,
173
+ verified: cached.verified,
174
+ });
175
+ }
176
+
138
177
  // Look up the user in our database by email OR iamId
139
178
  // This ensures we find the user regardless of which system they signed up with
140
179
  const userCollection = this.connection.collection('users');
@@ -148,6 +187,11 @@ export class CoreBetterAuthUserMapper {
148
187
  // Use database verified status, fallback to Better-Auth emailVerified
149
188
  const verified = dbUser.verified === true || sessionUser.emailVerified === true;
150
189
 
190
+ // Cache only the minimal data needed (not the full document)
191
+ if (CoreBetterAuthUserMapper.USER_CACHE_TTL_MS > 0) {
192
+ this.cacheUserData(sessionUser.id, { id: dbUser._id.toString(), roles, verified });
193
+ }
194
+
151
195
  return this.createMappedUser({
152
196
  email: sessionUser.email,
153
197
  emailVerified: sessionUser.emailVerified,
@@ -190,32 +234,53 @@ export class CoreBetterAuthUserMapper {
190
234
  return {
191
235
  ...userData,
192
236
  _authenticatedViaBetterAuth: true,
193
- hasRole: (checkRoles: string | string[]): boolean => {
194
- const rolesToCheck = Array.isArray(checkRoles) ? checkRoles : [checkRoles];
237
+ // Bind to static method to avoid creating a new closure per request
238
+ hasRole: CoreBetterAuthUserMapper.createHasRole(roles, userData.verified === true),
239
+ roles,
240
+ };
241
+ }
195
242
 
196
- // Check for special roles
197
- if (rolesToCheck.includes(RoleEnum.S_EVERYONE)) {
198
- return true;
199
- }
243
+ /**
244
+ * Creates a hasRole function. Uses a shared factory to minimize per-request closure size.
245
+ * The returned function only captures `roles` (string[]) and `verified` (boolean) — no large objects.
246
+ */
247
+ private static createHasRole(roles: string[], verified: boolean): (checkRoles: string | string[]) => boolean {
248
+ return (checkRoles: string | string[]): boolean => {
249
+ const rolesToCheck = Array.isArray(checkRoles) ? checkRoles : [checkRoles];
200
250
 
201
- if (rolesToCheck.includes(RoleEnum.S_USER)) {
202
- return true; // User is authenticated via Better-Auth
203
- }
251
+ if (rolesToCheck.includes(RoleEnum.S_EVERYONE)) return true;
252
+ if (rolesToCheck.includes(RoleEnum.S_USER)) return true;
253
+ if (rolesToCheck.includes(RoleEnum.S_NO_ONE)) return false;
254
+ if (rolesToCheck.includes(RoleEnum.S_VERIFIED)) return verified;
204
255
 
205
- if (rolesToCheck.includes(RoleEnum.S_NO_ONE)) {
206
- return false;
207
- }
256
+ return rolesToCheck.some((role) => roles.includes(role));
257
+ };
258
+ }
208
259
 
209
- // S_VERIFIED check - uses verified field (from DB or Better-Auth emailVerified)
210
- if (rolesToCheck.includes(RoleEnum.S_VERIFIED)) {
211
- return userData.verified === true;
212
- }
260
+ /**
261
+ * Store minimal user data in the lightweight cache.
262
+ * Only stores id, roles, verified no full documents or large objects.
263
+ */
264
+ private cacheUserData(iamId: string, data: Omit<CachedUserData, 'expiresAt'>): void {
265
+ // Evict oldest if at capacity (simple FIFO)
266
+ if (this.userCache.size >= CoreBetterAuthUserMapper.USER_CACHE_MAX) {
267
+ const firstKey = this.userCache.keys().next().value;
268
+ if (firstKey) this.userCache.delete(firstKey);
269
+ }
270
+ this.userCache.set(iamId, {
271
+ ...data,
272
+ expiresAt: Date.now() + CoreBetterAuthUserMapper.USER_CACHE_TTL_MS,
273
+ });
274
+ }
213
275
 
214
- // Check actual roles
215
- return rolesToCheck.some((role) => roles.includes(role));
216
- },
217
- roles,
218
- };
276
+ /**
277
+ * Invalidate cached user data. Call when user roles or verified status change.
278
+ * Called automatically from `CoreUserService.setRoles()` and `CoreUserService.update()`.
279
+ *
280
+ * @param iamId - The BetterAuth user ID (from session/account, not MongoDB _id)
281
+ */
282
+ invalidateUserCache(iamId: string): void {
283
+ this.userCache.delete(iamId);
219
284
  }
220
285
 
221
286
  // ===================================================================================================================
@@ -1,4 +1,4 @@
1
- import { BadRequestException, Inject, Injectable, Logger, Optional } from '@nestjs/common';
1
+ import { BadRequestException, Inject, Injectable, Logger, OnModuleInit, Optional } from '@nestjs/common';
2
2
  import { InjectConnection } from '@nestjs/mongoose';
3
3
  import { Request } from 'express';
4
4
  import { importJWK, jwtVerify } from 'jose';
@@ -61,7 +61,7 @@ export const BETTER_AUTH_CONFIG = 'BETTER_AUTH_CONFIG';
61
61
  export const BETTER_AUTH_COOKIE_DOMAIN = 'BETTER_AUTH_COOKIE_DOMAIN';
62
62
 
63
63
  @Injectable()
64
- export class CoreBetterAuthService {
64
+ export class CoreBetterAuthService implements OnModuleInit {
65
65
  private readonly logger = new Logger(CoreBetterAuthService.name);
66
66
  private readonly config: IBetterAuth;
67
67
 
@@ -78,6 +78,31 @@ export class CoreBetterAuthService {
78
78
  this.config = this.resolvedConfig || this.configService?.get<IBetterAuth>('betterAuth') || {};
79
79
  }
80
80
 
81
+ /**
82
+ * Ensure performance indices exist on session and users collections.
83
+ * Indices are idempotent — calling createIndex on an existing index is a no-op.
84
+ */
85
+ async onModuleInit(): Promise<void> {
86
+ if (!this.isEnabled() || !this.connection?.db) return;
87
+
88
+ try {
89
+ const db = this.connection.db;
90
+
91
+ // Session collection: token lookup (getSessionByToken) and user+expiry lookup (getActiveSessionForUser)
92
+ await db.collection('session').createIndex({ token: 1 });
93
+ await db.collection('session').createIndex({ userId: 1, expiresAt: 1 });
94
+
95
+ // Users collection: iamId lookup (mapSessionUser uses $or with email and iamId)
96
+ // email is typically already indexed by Mongoose schema, but iamId may not be
97
+ await db.collection('users').createIndex({ iamId: 1 }, { sparse: true });
98
+
99
+ this.logger.debug('Performance indices ensured on session and users collections');
100
+ } catch (error) {
101
+ // Non-fatal: indices improve performance but are not required for correctness
102
+ this.logger.warn(`Could not create performance indices: ${error instanceof Error ? error.message : 'unknown'}`);
103
+ }
104
+ }
105
+
81
106
  /**
82
107
  * Checks if better-auth is enabled and initialized
83
108
  * Returns true only if:
@@ -43,6 +43,7 @@ multiTenancy: {
43
43
  membershipModel: 'TenantMember', // Mongoose model name (default)
44
44
  adminBypass: true, // System admins bypass membership (default: true)
45
45
  excludeSchemas: ['User', 'Session'], // Schemas without tenant filtering
46
+ cacheTtlMs: 30000, // Membership cache TTL in ms (default: 30s, 0 = disabled)
46
47
  roleHierarchy: { // Custom role hierarchy (default below)
47
48
  member: 1,
48
49
  manager: 2,
@@ -194,9 +195,44 @@ CoreTenantModule.forRoot({ service: TenantService });
194
195
 
195
196
  ## Performance Considerations
196
197
 
197
- The `CoreTenantGuard` resolves tenant memberships (`resolveUserTenantIds()`) on every authenticated request that does not include an `X-Tenant-Id` header. This is necessary so the Mongoose plugin can filter by `{ tenantId: { $in: tenantIds } }`.
198
+ ### Membership Cache (since 11.21.1)
198
199
 
199
- For high-frequency endpoints that don't access tenant-scoped data, use `@SkipTenantCheck()` to avoid the membership lookup.
200
+ The `CoreTenantGuard` uses an in-memory TTL cache for membership lookups and tenant ID resolution. This avoids repeated DB queries when the same user accesses the same tenant across multiple requests.
201
+
202
+ ```typescript
203
+ // config.env.ts — configure or disable the cache
204
+ multiTenancy: {
205
+ cacheTtlMs: 30000, // default: 30s. Set to 0 to disable.
206
+ }
207
+ ```
208
+
209
+ **Cache behavior:**
210
+ - **Positive-only:** Only active memberships are cached. Non-member lookups always hit the DB (security-first).
211
+ - **Auto-invalidation:** `CoreTenantService.addMember/removeMember/updateMemberRole` automatically invalidate the cache.
212
+ - **Config-change detection:** Cache is flushed when `multiTenancy` config changes (e.g., `roleHierarchy` update).
213
+ - **Bounded:** Max 500 entries with FIFO eviction. Memory overhead: ~100-250 KB.
214
+
215
+ **Important:** The cache is process-local. In horizontally scaled deployments (multiple instances), membership changes on one instance are not reflected on other instances until the TTL expires. Set `cacheTtlMs: 0` for security-sensitive deployments.
216
+
217
+ ### Manual Cache Invalidation
218
+
219
+ When extending `CoreTenantService` with custom membership mutation methods, call `invalidateUser()` after changes:
220
+
221
+ ```typescript
222
+ @Injectable()
223
+ export class TenantService extends CoreTenantService {
224
+ async customMembershipChange(tenantId: string, userId: string) {
225
+ // ... your logic ...
226
+ this.tenantGuard?.invalidateUser(userId);
227
+ }
228
+ }
229
+ ```
230
+
231
+ Use `invalidateAll()` to flush the entire cache (e.g., after bulk operations).
232
+
233
+ ### SkipTenantCheck
234
+
235
+ For high-frequency endpoints that don't access tenant-scoped data, use `@SkipTenantCheck()` to avoid the membership lookup entirely.
200
236
 
201
237
  ## Security Notes
202
238