@lenne.tech/nest-server 11.20.1 → 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 (84) hide show
  1. package/README.md +444 -100
  2. package/dist/core/common/decorators/restricted.decorator.d.ts +1 -0
  3. package/dist/core/common/decorators/restricted.decorator.js +4 -1
  4. package/dist/core/common/decorators/restricted.decorator.js.map +1 -1
  5. package/dist/core/common/helpers/input.helper.js +11 -8
  6. package/dist/core/common/helpers/input.helper.js.map +1 -1
  7. package/dist/core/common/interceptors/check-security.interceptor.js +10 -8
  8. package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
  9. package/dist/core/common/interfaces/server-options.interface.d.ts +5 -1
  10. package/dist/core/common/middleware/request-context.middleware.js +10 -6
  11. package/dist/core/common/middleware/request-context.middleware.js.map +1 -1
  12. package/dist/core/common/plugins/mongoose-tenant.plugin.js +40 -24
  13. package/dist/core/common/plugins/mongoose-tenant.plugin.js.map +1 -1
  14. package/dist/core/common/services/email.service.d.ts +5 -1
  15. package/dist/core/common/services/email.service.js +16 -2
  16. package/dist/core/common/services/email.service.js.map +1 -1
  17. package/dist/core/common/services/request-context.service.d.ts +3 -0
  18. package/dist/core/common/services/request-context.service.js +6 -0
  19. package/dist/core/common/services/request-context.service.js.map +1 -1
  20. package/dist/core/modules/auth/guards/roles.guard.js +6 -10
  21. package/dist/core/modules/auth/guards/roles.guard.js.map +1 -1
  22. package/dist/core/modules/auth/tokens.decorator.d.ts +1 -1
  23. package/dist/core/modules/better-auth/better-auth-roles.guard.js +5 -6
  24. package/dist/core/modules/better-auth/better-auth-roles.guard.js.map +1 -1
  25. package/dist/core/modules/better-auth/core-better-auth-user.mapper.d.ts +6 -0
  26. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +52 -17
  27. package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
  28. package/dist/core/modules/better-auth/core-better-auth.service.d.ts +3 -1
  29. package/dist/core/modules/better-auth/core-better-auth.service.js +14 -0
  30. package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
  31. package/dist/core/modules/tenant/core-tenant-member.model.d.ts +11 -0
  32. package/dist/core/modules/tenant/core-tenant-member.model.js +106 -0
  33. package/dist/core/modules/tenant/core-tenant-member.model.js.map +1 -0
  34. package/dist/core/modules/tenant/core-tenant.decorators.d.ts +3 -0
  35. package/dist/core/modules/tenant/core-tenant.decorators.js +12 -0
  36. package/dist/core/modules/tenant/core-tenant.decorators.js.map +1 -0
  37. package/dist/core/modules/tenant/core-tenant.enums.d.ts +13 -0
  38. package/dist/core/modules/tenant/core-tenant.enums.js +25 -0
  39. package/dist/core/modules/tenant/core-tenant.enums.js.map +1 -0
  40. package/dist/core/modules/tenant/core-tenant.guard.d.ts +25 -0
  41. package/dist/core/modules/tenant/core-tenant.guard.js +271 -0
  42. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -0
  43. package/dist/core/modules/tenant/core-tenant.helpers.d.ts +7 -0
  44. package/dist/core/modules/tenant/core-tenant.helpers.js +60 -0
  45. package/dist/core/modules/tenant/core-tenant.helpers.js.map +1 -0
  46. package/dist/core/modules/tenant/core-tenant.module.d.ts +12 -0
  47. package/dist/core/modules/tenant/core-tenant.module.js +58 -0
  48. package/dist/core/modules/tenant/core-tenant.module.js.map +1 -0
  49. package/dist/core/modules/tenant/core-tenant.service.d.ts +19 -0
  50. package/dist/core/modules/tenant/core-tenant.service.js +170 -0
  51. package/dist/core/modules/tenant/core-tenant.service.js.map +1 -0
  52. package/dist/core/modules/user/core-user.service.js +12 -1
  53. package/dist/core/modules/user/core-user.service.js.map +1 -1
  54. package/dist/core.module.js +11 -0
  55. package/dist/core.module.js.map +1 -1
  56. package/dist/index.d.ts +7 -0
  57. package/dist/index.js +7 -0
  58. package/dist/index.js.map +1 -1
  59. package/dist/tsconfig.build.tsbuildinfo +1 -1
  60. package/package.json +35 -24
  61. package/src/core/common/decorators/restricted.decorator.ts +12 -2
  62. package/src/core/common/helpers/input.helper.ts +24 -9
  63. package/src/core/common/interceptors/check-security.interceptor.ts +19 -13
  64. package/src/core/common/interfaces/server-options.interface.ts +80 -28
  65. package/src/core/common/middleware/request-context.middleware.ts +12 -5
  66. package/src/core/common/plugins/mongoose-tenant.plugin.ts +78 -45
  67. package/src/core/common/services/email.service.ts +26 -5
  68. package/src/core/common/services/request-context.service.ts +15 -1
  69. package/src/core/modules/auth/guards/roles.guard.ts +10 -10
  70. package/src/core/modules/better-auth/better-auth-roles.guard.ts +9 -6
  71. package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +86 -21
  72. package/src/core/modules/better-auth/core-better-auth.service.ts +27 -2
  73. package/src/core/modules/tenant/INTEGRATION-CHECKLIST.md +165 -0
  74. package/src/core/modules/tenant/README.md +268 -0
  75. package/src/core/modules/tenant/core-tenant-member.model.ts +121 -0
  76. package/src/core/modules/tenant/core-tenant.decorators.ts +46 -0
  77. package/src/core/modules/tenant/core-tenant.enums.ts +77 -0
  78. package/src/core/modules/tenant/core-tenant.guard.ts +441 -0
  79. package/src/core/modules/tenant/core-tenant.helpers.ts +103 -0
  80. package/src/core/modules/tenant/core-tenant.module.ts +102 -0
  81. package/src/core/modules/tenant/core-tenant.service.ts +244 -0
  82. package/src/core/modules/user/core-user.service.ts +17 -1
  83. package/src/core.module.ts +15 -0
  84. package/src/index.ts +12 -0
@@ -1,3 +1,5 @@
1
+ import { ForbiddenException } from '@nestjs/common';
2
+
1
3
  import { ConfigService } from '../services/config.service';
2
4
  import { RequestContext } from '../services/request-context.service';
3
5
 
@@ -15,18 +17,21 @@ import { RequestContext } from '../services/request-context.service';
15
17
  * - New documents get tenantId set automatically from context
16
18
  * - Aggregates get a $match stage prepended
17
19
  *
20
+ * **Filter modes:**
21
+ * - X-Tenant-Id header set → `{ tenantId: headerValue }` (single tenant)
22
+ * - No header + authenticated user → `{ tenantId: { $in: userTenantIds } }` (all user's tenants)
23
+ * - No header + no user → no filter (public/system routes)
24
+ *
18
25
  * **No filter applied when:**
19
26
  * - No RequestContext (system operations, cron jobs, migrations)
20
27
  * - `bypassTenantGuard` is active (via `RequestContext.runWithBypassTenantGuard()`)
21
28
  * - Schema's model name is in `excludeSchemas` config
22
- * - No user on request (public endpoints)
23
- *
24
- * **User without tenantId:**
25
- * - Filters by `{ tenantId: null }` — sees only data without tenant assignment
26
- * - Falsy values (undefined, null, empty string '') are all treated as "no tenant"
27
29
  */
28
30
  export function mongooseTenantPlugin(schema) {
29
- // Only activate on schemas with a tenantId path
31
+ // Only activate on schemas with a tenantId path.
32
+ // CoreTenantMemberModel uses 'tenant' (not 'tenantId') intentionally, so this check
33
+ // excludes it at registration time. Additionally, 'TenantMember' is auto-added to
34
+ // excludeSchemas in CoreModule as defense-in-depth (see shouldBypass()).
30
35
  if (!schema.path('tenantId')) {
31
36
  return;
32
37
  }
@@ -54,22 +59,21 @@ export function mongooseTenantPlugin(schema) {
54
59
  schema.pre(hookName, function () {
55
60
  // Query hooks: `this` is a Mongoose Query — modelName is on `this.model`
56
61
  const modelName = this.model?.modelName;
57
- const tenantId = resolveTenantId(modelName);
58
- if (tenantId !== undefined) {
59
- this.where({ tenantId });
62
+ const filter = resolveTenantFilter(modelName);
63
+ if (filter !== undefined) {
64
+ this.where(filter);
60
65
  }
61
66
  });
62
67
  }
63
68
 
64
69
  // === Save: set tenantId automatically on new documents ===
65
70
  // Intentional asymmetry: writes only set tenantId when truthy (not null).
66
- // A user without tenantId creates "unassigned" documents, which the null-filter
67
- // in query hooks will still make visible to them on reads.
71
+ // Only uses single tenantId from header tenantIds array is for reads only.
68
72
  schema.pre('save', function () {
69
73
  if (this.isNew && !this['tenantId']) {
70
74
  // Document hooks: `this` is the document instance — modelName is on the constructor (the Model class)
71
75
  const modelName = (this.constructor as any).modelName;
72
- const tenantId = resolveTenantId(modelName);
76
+ const tenantId = resolveSingleTenantId(modelName);
73
77
  if (tenantId) {
74
78
  this['tenantId'] = tenantId;
75
79
  }
@@ -80,7 +84,7 @@ export function mongooseTenantPlugin(schema) {
80
84
  schema.pre('insertMany', function (docs: any[]) {
81
85
  // Model-level hooks: `this` is the Model class itself — modelName is a direct property
82
86
  const modelName = this.modelName;
83
- const tenantId = resolveTenantId(modelName);
87
+ const tenantId = resolveSingleTenantId(modelName);
84
88
  if (tenantId && Array.isArray(docs)) {
85
89
  for (const doc of docs) {
86
90
  if (!doc.tenantId) {
@@ -94,25 +98,27 @@ export function mongooseTenantPlugin(schema) {
94
98
  schema.pre('bulkWrite', function (ops: any[]) {
95
99
  // Model-level hooks: `this` is the Model class itself — modelName is a direct property
96
100
  const modelName = this.modelName;
97
- const tenantId = resolveTenantId(modelName);
98
- if (tenantId === undefined) return;
101
+ const filter = resolveTenantFilter(modelName);
102
+ if (filter === undefined) return;
103
+
104
+ const tenantId = resolveSingleTenantId(modelName);
99
105
 
100
106
  for (const op of ops) {
101
107
  if ('insertOne' in op) {
102
- // Auto-set tenantId on insert (only if truthy, consistent with save hook)
108
+ // Auto-set tenantId on insert (only single tenantId, consistent with save hook)
103
109
  if (tenantId && !op.insertOne.document.tenantId) {
104
110
  op.insertOne.document.tenantId = tenantId;
105
111
  }
106
112
  } else if ('updateOne' in op) {
107
- op.updateOne.filter = { ...op.updateOne.filter, tenantId };
113
+ op.updateOne.filter = { ...op.updateOne.filter, ...filter };
108
114
  } else if ('updateMany' in op) {
109
- op.updateMany.filter = { ...op.updateMany.filter, tenantId };
115
+ op.updateMany.filter = { ...op.updateMany.filter, ...filter };
110
116
  } else if ('replaceOne' in op) {
111
- op.replaceOne.filter = { ...op.replaceOne.filter, tenantId };
117
+ op.replaceOne.filter = { ...op.replaceOne.filter, ...filter };
112
118
  } else if ('deleteOne' in op) {
113
- op.deleteOne.filter = { ...op.deleteOne.filter, tenantId };
119
+ op.deleteOne.filter = { ...op.deleteOne.filter, ...filter };
114
120
  } else if ('deleteMany' in op) {
115
- op.deleteMany.filter = { ...op.deleteMany.filter, tenantId };
121
+ op.deleteMany.filter = { ...op.deleteMany.filter, ...filter };
116
122
  }
117
123
  }
118
124
  });
@@ -121,45 +127,72 @@ export function mongooseTenantPlugin(schema) {
121
127
  schema.pre('aggregate', function () {
122
128
  // Aggregate hooks: `this` is the Aggregation pipeline — the model is on the internal `_model` property
123
129
  const modelName = (this as any)._model?.modelName;
124
- const tenantId = resolveTenantId(modelName);
125
- if (tenantId !== undefined) {
126
- this.pipeline().unshift({ $match: { tenantId } });
130
+ const filter = resolveTenantFilter(modelName);
131
+ if (filter !== undefined) {
132
+ this.pipeline().unshift({ $match: filter });
127
133
  }
128
134
  });
129
135
  }
130
136
 
131
137
  /**
132
- * Resolve tenant ID from RequestContext.
138
+ * Check common bypass conditions.
133
139
  *
134
- * @returns
135
- * - `undefined` → no filter should be applied
136
- * - `string` → filter by this tenant ID
137
- * - `null` → filter by `{ tenantId: null }` (user without tenant sees only unassigned data)
140
+ * @returns `true` if filtering should be skipped, `false` otherwise
138
141
  */
139
- function resolveTenantId(modelName?: string): string | null | undefined {
140
- // Defense-in-depth: check config even if plugin is registered
142
+ function shouldBypass(modelName?: string): boolean {
141
143
  const mtConfig = ConfigService.configFastButReadOnly?.multiTenancy;
142
- if (!mtConfig || mtConfig.enabled === false) return undefined;
144
+ if (!mtConfig || mtConfig.enabled === false) return true;
143
145
 
144
146
  const context = RequestContext.get();
147
+ if (!context) return true;
148
+ if (context.bypassTenantGuard) return true;
149
+ if (modelName && mtConfig.excludeSchemas?.includes(modelName)) return true;
145
150
 
146
- // No RequestContext (system operation, cron, migration) → no filter
147
- if (!context) return undefined;
151
+ return false;
152
+ }
148
153
 
149
- // Explicit bypass
150
- if (context.bypassTenantGuard) return undefined;
154
+ /**
155
+ * Resolve tenant filter from RequestContext for read operations (queries, aggregates).
156
+ *
157
+ * Defense-in-depth: If a schema has tenantId but there is no valid tenant context,
158
+ * throws ForbiddenException instead of returning unfiltered data (Safety Net).
159
+ *
160
+ * @returns
161
+ * - `undefined` → no filter should be applied (bypass active or plugin disabled)
162
+ * - `{}` → empty filter (admin bypass without header — sees all data)
163
+ * - `{ tenantId: string }` → filter by single validated tenant
164
+ * - `{ tenantId: { $in: string[] } }` → filter by user's tenant memberships
165
+ * @throws ForbiddenException when tenantId-schema is accessed without valid tenant context
166
+ */
167
+ function resolveTenantFilter(modelName?: string): Record<string, any> | undefined {
168
+ if (shouldBypass(modelName)) return undefined;
169
+
170
+ const context = RequestContext.get();
151
171
 
152
- // Check excluded schemas (model names, e.g. ['User', 'Session'])
153
- if (modelName && mtConfig.excludeSchemas?.includes(modelName)) return undefined;
172
+ // Validated tenant ID (set by CoreTenantGuard) filter by it
173
+ const tenantId = context?.tenantId;
174
+ if (tenantId) return { tenantId };
154
175
 
155
- const tenantId = context.tenantId;
176
+ // User has resolved memberships → filter by their tenants
177
+ const tenantIds = context?.tenantIds;
178
+ if (tenantIds) return { tenantId: { $in: tenantIds } };
156
179
 
157
- // User has tenantId → filter by it (empty string is treated as falsy = no tenant)
158
- if (tenantId) return tenantId;
180
+ // Admin bypass without header no filter, sees all data
181
+ if (context?.isAdminBypass) return {};
159
182
 
160
- // User is logged in but has no tenantId (undefined, null, or '') → null filter (sees only data without tenant)
161
- if (context.currentUser) return null;
183
+ // SAFETY NET: Schema has tenantId but no valid tenant context.
184
+ // Throw instead of returning unfiltered data to prevent data leaks.
185
+ throw new ForbiddenException(
186
+ 'Tenant context required: this data is tenant-scoped but no valid tenant context was provided',
187
+ );
188
+ }
189
+
190
+ /**
191
+ * Resolve single tenant ID for write operations (save, insertMany).
192
+ * Only returns a value when a specific tenant header is set.
193
+ */
194
+ function resolveSingleTenantId(modelName?: string): string | undefined {
195
+ if (shouldBypass(modelName)) return undefined;
162
196
 
163
- // No user (public endpoint) no filter
164
- return undefined;
197
+ return RequestContext.get()?.tenantId || undefined;
165
198
  }
@@ -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,
@@ -11,8 +11,14 @@ export interface IRequestContext {
11
11
  bypassRoleGuard?: boolean;
12
12
  /** When true, mongooseTenantPlugin skips tenant filtering */
13
13
  bypassTenantGuard?: boolean;
14
- /** Tenant ID resolved from the current user */
14
+ /** Validated tenant ID (set by CoreTenantGuard after membership validation, not raw header) */
15
15
  tenantId?: string;
16
+ /** Tenant IDs from user's active tenant memberships (used when no specific header is set) */
17
+ tenantIds?: string[];
18
+ /** Tenant role of the current user in the active tenant */
19
+ tenantRole?: string;
20
+ /** When true, indicates admin bypass is active (admin without header sees all data) */
21
+ isAdminBypass?: boolean;
16
22
  }
17
23
 
18
24
  /**
@@ -64,6 +70,10 @@ export class RequestContext {
64
70
  */
65
71
  static runWithBypassRoleGuard<T>(fn: () => T): T {
66
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
+ }
67
77
  const context: IRequestContext = {
68
78
  ...currentStore,
69
79
  bypassRoleGuard: true,
@@ -97,6 +107,10 @@ export class RequestContext {
97
107
  */
98
108
  static runWithBypassTenantGuard<T>(fn: () => T): T {
99
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
+ }
100
114
  const context: IRequestContext = {
101
115
  ...currentStore,
102
116
  bypassTenantGuard: true,
@@ -16,6 +16,7 @@ import { BetterAuthTokenService } from '../../better-auth/better-auth-token.serv
16
16
  import { BetterAuthenticatedUser } from '../../better-auth/better-auth.types';
17
17
  import { CoreBetterAuthService } from '../../better-auth/core-better-auth.service';
18
18
  import { ErrorCode } from '../../error-code';
19
+ import { isMultiTenancyActive, isSystemRole, mergeRolesMetadata } from '../../tenant/core-tenant.helpers';
19
20
  import { AuthGuardStrategy } from '../auth-guard-strategy.enum';
20
21
  import { ExpiredTokenException } from '../exceptions/expired-token.exception';
21
22
  import { InvalidTokenException } from '../exceptions/invalid-token.exception';
@@ -134,11 +135,7 @@ export class RolesGuard extends AuthGuard(AuthGuardStrategy.JWT) {
134
135
  context.getHandler(),
135
136
  context.getClass(),
136
137
  ]);
137
- const roles: string[] = reflectorRoles[0]
138
- ? reflectorRoles[1]
139
- ? [...reflectorRoles[0], ...reflectorRoles[1]]
140
- : reflectorRoles[0]
141
- : reflectorRoles[1];
138
+ const roles = mergeRolesMetadata(reflectorRoles);
142
139
 
143
140
  // Check if locked - always deny
144
141
  if (roles && roles.includes(RoleEnum.S_NO_ONE)) {
@@ -294,11 +291,7 @@ export class RolesGuard extends AuthGuard(AuthGuardStrategy.JWT) {
294
291
  context.getHandler(),
295
292
  context.getClass(),
296
293
  ]);
297
- const roles: string[] = reflectorRoles[0]
298
- ? reflectorRoles[1]
299
- ? [...reflectorRoles[0], ...reflectorRoles[1]]
300
- : reflectorRoles[0]
301
- : reflectorRoles[1];
294
+ const roles = mergeRolesMetadata(reflectorRoles);
302
295
 
303
296
  // Check if locked
304
297
  if (roles && roles.includes(RoleEnum.S_NO_ONE)) {
@@ -317,6 +310,13 @@ export class RolesGuard extends AuthGuard(AuthGuardStrategy.JWT) {
317
310
  return user;
318
311
  }
319
312
 
313
+ // When multiTenancy active: pass through ALL non-system roles to CoreTenantGuard.
314
+ // CoreTenantGuard handles hierarchy (level) and non-hierarchy (exact) checks
315
+ // against membership.role (tenant) or user.roles (no tenant).
316
+ if (user && isMultiTenancyActive() && roles.some((r) => !isSystemRole(r))) {
317
+ return user;
318
+ }
319
+
320
320
  // If user is missing throw token exception
321
321
  if (!user) {
322
322
  if (err) {
@@ -10,6 +10,7 @@ import { GqlExecutionContext } from '@nestjs/graphql';
10
10
 
11
11
  import { RoleEnum } from '../../common/enums/role.enum';
12
12
  import { ErrorCode } from '../error-code';
13
+ import { isMultiTenancyActive, isSystemRole, mergeRolesMetadata } from '../tenant/core-tenant.helpers';
13
14
  import { BetterAuthTokenService } from './better-auth-token.service';
14
15
  import { BetterAuthenticatedUser } from './better-auth.types';
15
16
  import { CoreBetterAuthModule } from './core-better-auth.module';
@@ -79,12 +80,7 @@ export class BetterAuthRolesGuard implements CanActivate {
79
80
  const classRoles = Reflect.getMetadata('roles', context.getClass()) as string[] | undefined;
80
81
 
81
82
  // Combine handler and class roles (handler takes precedence, like Reflector.getAll)
82
- const reflectorRoles: (string[] | undefined)[] = [handlerRoles, classRoles];
83
- const roles: string[] = reflectorRoles[0]
84
- ? reflectorRoles[1]
85
- ? [...reflectorRoles[0], ...reflectorRoles[1]]
86
- : reflectorRoles[0]
87
- : reflectorRoles[1];
83
+ const roles = mergeRolesMetadata([handlerRoles, classRoles]);
88
84
 
89
85
  // Check if locked - always deny
90
86
  if (roles && roles.includes(RoleEnum.S_NO_ONE)) {
@@ -120,6 +116,13 @@ export class BetterAuthRolesGuard implements CanActivate {
120
116
  return true;
121
117
  }
122
118
 
119
+ // When multiTenancy active: pass through ALL non-system roles to CoreTenantGuard.
120
+ // CoreTenantGuard handles hierarchy (level) and non-hierarchy (exact) checks
121
+ // against membership.role (tenant) or user.roles (no tenant).
122
+ if (isMultiTenancyActive() && roles.some((r) => !isSystemRole(r))) {
123
+ return true;
124
+ }
125
+
123
126
  // Check S_SELF role - user is accessing their own data
124
127
  if (roles.includes(RoleEnum.S_SELF)) {
125
128
  // Get the target object's ID from params or args
@@ -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: