@lenne.tech/nest-server 11.21.0 → 11.21.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 (41) 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 +2 -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 +16 -2
  20. package/dist/core/modules/tenant/core-tenant.guard.js +168 -22
  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 +32 -25
  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 +45 -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/README.md +20 -1
  35. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +86 -21
  36. package/src/core/modules/better-auth/core-better-auth.service.ts +27 -2
  37. package/src/core/modules/tenant/INTEGRATION-CHECKLIST.md +20 -1
  38. package/src/core/modules/tenant/README.md +71 -2
  39. package/src/core/modules/tenant/core-tenant.guard.ts +304 -27
  40. package/src/core/modules/tenant/core-tenant.service.ts +13 -4
  41. 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.2",
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",
@@ -97,21 +97,21 @@
97
97
  "class-validator": "0.15.1",
98
98
  "compression": "1.8.1",
99
99
  "cookie-parser": "1.4.7",
100
- "dotenv": "17.3.1",
100
+ "dotenv": "17.4.0",
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
- "lodash": "4.17.23",
110
- "mongodb": "7.1.0",
111
- "mongoose": "9.3.0",
109
+ "lodash": "4.18.1",
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",
128
- "@swc/cli": "0.8.0",
129
- "@swc/core": "1.15.18",
125
+ "@nestjs/cli": "11.0.17",
126
+ "@nestjs/schematics": "11.0.10",
127
+ "@nestjs/testing": "11.1.17",
128
+ "@swc/cli": "0.8.1",
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,19 @@
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
- "file-type@>=13.0.0 <21.3.2": "21.3.2",
186
185
  "undici@>=7.0.0 <7.24.0": "7.24.3",
187
- "yauzl@<3.2.1": "3.2.1"
186
+ "srvx@<0.11.13": "0.11.13",
187
+ "handlebars@>=4.0.0 <4.7.9": "4.7.9",
188
+ "brace-expansion@<1.1.13": "1.1.13",
189
+ "brace-expansion@>=4.0.0 <5.0.5": "5.0.5",
190
+ "picomatch@<2.3.2": "2.3.2",
191
+ "picomatch@>=4.0.0 <4.0.4": "4.0.4",
192
+ "path-to-regexp@>=8.0.0 <8.4.0": "8.4.1",
193
+ "kysely@>=0.26.0 <0.28.15": "0.28.15",
194
+ "lodash@>=4.0.0 <4.18.0": "4.18.1"
188
195
  },
189
196
  "onlyBuiltDependencies": [
190
197
  "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
  /**
@@ -2382,6 +2401,32 @@ interface IBetterAuthBase {
2382
2401
  */
2383
2402
  secret?: string;
2384
2403
 
2404
+ /**
2405
+ * Skip tenant validation on IAM endpoints.
2406
+ *
2407
+ * When true (default), IAM endpoints (sign-up, sign-in, sign-out, session, etc.)
2408
+ * skip the CoreTenantGuard tenant membership check. This is the correct default
2409
+ * because authentication typically happens BEFORE tenant context is established.
2410
+ *
2411
+ * Set to false for scenarios where tenant context is known at login time:
2412
+ * - Subdomain-based tenancy (tenant-a.crm.example.com)
2413
+ * - Invite links with embedded tenant (crm.example.com/invite/abc123)
2414
+ * - Tenant-specific login pages (crm.example.com/login?org=tenant-a)
2415
+ * - Tenant-specific auth policies (e.g., one tenant requires SSO)
2416
+ *
2417
+ * @default true
2418
+ *
2419
+ * @example
2420
+ * ```typescript
2421
+ * // Default: IAM endpoints skip tenant validation (correct for most cases)
2422
+ * betterAuth: { skipTenantCheck: true }
2423
+ *
2424
+ * // Tenant-aware authentication (subdomain-based tenancy, invite links, etc.)
2425
+ * betterAuth: { skipTenantCheck: false }
2426
+ * ```
2427
+ */
2428
+ skipTenantCheck?: boolean;
2429
+
2385
2430
  /**
2386
2431
  * Sign-up checks configuration.
2387
2432
  *
@@ -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,
@@ -2137,4 +2137,23 @@ For frontend integration with Better-Auth, see the **[Integration Checklist](./I
2137
2137
  1. **Password Hashing**: Always hash passwords with SHA256 client-side before sending
2138
2138
  2. **2FA Redirect**: Check for `twoFactorRedirect: true` in sign-in response
2139
2139
  3. **Passkey Session**: Passkey auth returns session without user - call `validateSession()` to fetch user data
2140
- 4. **Credentials**: Use `credentials: 'include'` for cross-origin cookie handling
2140
+ 4. **Credentials**: Use `credentials: 'include'` for cross-origin cookie handling
2141
+
2142
+ ---
2143
+
2144
+ ## Multi-Tenancy Integration
2145
+
2146
+ When both BetterAuth and Multi-Tenancy (`multiTenancy: {}`) are active, IAM endpoints
2147
+ automatically skip tenant validation by default (`skipTenantCheck: true`). This is the
2148
+ correct default — authentication happens before tenant context is established.
2149
+
2150
+ For tenant-aware authentication scenarios (subdomain-based tenancy, invite links, etc.),
2151
+ set `skipTenantCheck: false`:
2152
+
2153
+ ```typescript
2154
+ betterAuth: {
2155
+ skipTenantCheck: false, // IAM endpoints require valid X-Tenant-Id header
2156
+ }
2157
+ ```
2158
+
2159
+ See the [CoreTenantModule README](../tenant/README.md#betterauth-iam-integration) for details.
@@ -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:
@@ -133,6 +133,23 @@ import { SkipTenantCheck, Roles, RoleEnum } from '@lenne.tech/nest-server';
133
133
  async listMyTenants() { ... }
134
134
  ```
135
135
 
136
+ ### 9. BetterAuth (IAM) Coexistence
137
+
138
+ When both `multiTenancy` and `betterAuth` are active, IAM endpoints (sign-in, sign-up, session, etc.)
139
+ automatically skip tenant validation when no `X-Tenant-Id` header is sent (`betterAuth.skipTenantCheck: true`, default).
140
+ If a header IS present, normal membership validation runs.
141
+
142
+ For tenant-aware authentication (subdomain-based, invite links, SSO per tenant), opt out:
143
+
144
+ ```typescript
145
+ // config.env.ts
146
+ betterAuth: {
147
+ skipTenantCheck: false, // IAM endpoints require valid X-Tenant-Id header
148
+ }
149
+ ```
150
+
151
+ See [Tenant README — BetterAuth Integration](./README.md#betterauth-iam-integration) for details.
152
+
136
153
  ## Verification Checklist
137
154
 
138
155
  - [ ] `pnpm run build` succeeds
@@ -143,6 +160,7 @@ async listMyTenants() { ... }
143
160
  - [ ] Non-member gets 403 "Not a member of this tenant"
144
161
  - [ ] Unauthenticated + header → 403 "Authentication required for tenant access"
145
162
  - [ ] Public endpoint accessing tenantId-schema without context throws 403 (Safety Net)
163
+ - [ ] IAM endpoints work without `X-Tenant-Id` header when `skipTenantCheck: true` (default)
146
164
 
147
165
  ## Security
148
166
 
@@ -162,4 +180,5 @@ async listMyTenants() { ... }
162
180
  | Querying membership without bypass | Empty results due to tenant filter | Use `RequestContext.runWithBypassTenantGuard()` |
163
181
  | Public endpoint accessing tenantId-schema | 403 Safety Net exception | Use `@SkipTenantCheck()` + `RequestContext.runWithBypassTenantGuard()` |
164
182
  | Passing user-supplied tenantId to create() | Cross-tenant write possible | Let plugin auto-set tenantId from context |
165
- | Custom hierarchy doesn't match config | Roles fail unexpectedly | Ensure `createHierarchyRoles()` input matches `multiTenancy.roleHierarchy` |
183
+ | Custom hierarchy doesn't match config | Roles fail unexpectedly | Ensure `createHierarchyRoles()` input matches `multiTenancy.roleHierarchy` |
184
+ | `@SkipTenantCheck()` on BetterAuth handler | Redundant since v11.21.2 | Remove — auto-skip handles this via `betterAuth.skipTenantCheck` (default) |