@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.
- package/README.md +444 -100
- package/dist/core/common/helpers/input.helper.js +11 -8
- package/dist/core/common/helpers/input.helper.js.map +1 -1
- package/dist/core/common/interceptors/check-security.interceptor.js +5 -7
- package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +1 -0
- package/dist/core/common/services/email.service.d.ts +5 -1
- package/dist/core/common/services/email.service.js +16 -2
- package/dist/core/common/services/email.service.js.map +1 -1
- package/dist/core/common/services/request-context.service.js +6 -0
- package/dist/core/common/services/request-context.service.js.map +1 -1
- package/dist/core/modules/auth/tokens.decorator.d.ts +1 -1
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.d.ts +6 -0
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +52 -17
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.service.d.ts +3 -1
- package/dist/core/modules/better-auth/core-better-auth.service.js +14 -0
- package/dist/core/modules/better-auth/core-better-auth.service.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.guard.d.ts +14 -2
- package/dist/core/modules/tenant/core-tenant.guard.js +123 -14
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.service.d.ts +3 -1
- package/dist/core/modules/tenant/core-tenant.service.js +14 -4
- package/dist/core/modules/tenant/core-tenant.service.js.map +1 -1
- package/dist/core/modules/user/core-user.service.js +12 -1
- package/dist/core/modules/user/core-user.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +30 -21
- package/src/core/common/helpers/input.helper.ts +24 -9
- package/src/core/common/interceptors/check-security.interceptor.ts +10 -11
- package/src/core/common/interfaces/server-options.interface.ts +19 -0
- package/src/core/common/services/email.service.ts +26 -5
- package/src/core/common/services/request-context.service.ts +8 -0
- package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +86 -21
- package/src/core/modules/better-auth/core-better-auth.service.ts +27 -2
- package/src/core/modules/tenant/README.md +38 -2
- package/src/core/modules/tenant/core-tenant.guard.ts +219 -18
- package/src/core/modules/tenant/core-tenant.service.ts +13 -4
- 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.
|
|
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.
|
|
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.
|
|
82
|
-
"@nestjs/core": "11.1.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
111
|
-
"mongoose": "9.3.
|
|
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.
|
|
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.
|
|
126
|
-
"@nestjs/schematics": "11.0.
|
|
127
|
-
"@nestjs/testing": "11.1.
|
|
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.
|
|
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.
|
|
141
|
-
"@vitest/ui": "4.1.
|
|
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.
|
|
149
|
-
"oxlint": "1.
|
|
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.
|
|
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.
|
|
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
|
|
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) =>
|
|
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] =
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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 {
|
|
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
|
-
//
|
|
78
|
-
|
|
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
|
|
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
|
-
|
|
194
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
256
|
+
return rolesToCheck.some((role) => roles.includes(role));
|
|
257
|
+
};
|
|
258
|
+
}
|
|
208
259
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
198
|
+
### Membership Cache (since 11.21.1)
|
|
198
199
|
|
|
199
|
-
|
|
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
|
|