@lenne.tech/nest-server 11.16.1 → 11.18.0

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 (113) hide show
  1. package/dist/config.env.js +8 -2
  2. package/dist/config.env.js.map +1 -1
  3. package/dist/core/common/decorators/response-model.decorator.d.ts +3 -0
  4. package/dist/core/common/decorators/response-model.decorator.js +8 -0
  5. package/dist/core/common/decorators/response-model.decorator.js.map +1 -0
  6. package/dist/core/common/helpers/db.helper.js +2 -2
  7. package/dist/core/common/helpers/db.helper.js.map +1 -1
  8. package/dist/core/common/helpers/filter.helper.js +3 -3
  9. package/dist/core/common/helpers/filter.helper.js.map +1 -1
  10. package/dist/core/common/helpers/input.helper.js +2 -2
  11. package/dist/core/common/helpers/input.helper.js.map +1 -1
  12. package/dist/core/common/helpers/interceptor.helper.d.ts +3 -0
  13. package/dist/core/common/helpers/interceptor.helper.js +84 -0
  14. package/dist/core/common/helpers/interceptor.helper.js.map +1 -0
  15. package/dist/core/common/helpers/service.helper.d.ts +1 -0
  16. package/dist/core/common/helpers/service.helper.js +1 -0
  17. package/dist/core/common/helpers/service.helper.js.map +1 -1
  18. package/dist/core/common/interceptors/check-security.interceptor.d.ts +2 -0
  19. package/dist/core/common/interceptors/check-security.interceptor.js +43 -1
  20. package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
  21. package/dist/core/common/interceptors/response-model.interceptor.d.ts +13 -0
  22. package/dist/core/common/interceptors/response-model.interceptor.js +107 -0
  23. package/dist/core/common/interceptors/response-model.interceptor.js.map +1 -0
  24. package/dist/core/common/interceptors/translate-response.interceptor.d.ts +8 -0
  25. package/dist/core/common/interceptors/translate-response.interceptor.js +85 -0
  26. package/dist/core/common/interceptors/translate-response.interceptor.js.map +1 -0
  27. package/dist/core/common/interfaces/server-options.interface.d.ts +16 -0
  28. package/dist/core/common/middleware/request-context.middleware.d.ts +5 -0
  29. package/dist/core/common/middleware/request-context.middleware.js +29 -0
  30. package/dist/core/common/middleware/request-context.middleware.js.map +1 -0
  31. package/dist/core/common/pipes/map-and-validate.pipe.js +2 -2
  32. package/dist/core/common/pipes/map-and-validate.pipe.js.map +1 -1
  33. package/dist/core/common/plugins/complexity.plugin.d.ts +2 -2
  34. package/dist/core/common/plugins/mongoose-audit-fields.plugin.d.ts +1 -0
  35. package/dist/core/common/plugins/mongoose-audit-fields.plugin.js +51 -0
  36. package/dist/core/common/plugins/mongoose-audit-fields.plugin.js.map +1 -0
  37. package/dist/core/common/plugins/mongoose-password.plugin.d.ts +4 -0
  38. package/dist/core/common/plugins/mongoose-password.plugin.js +69 -0
  39. package/dist/core/common/plugins/mongoose-password.plugin.js.map +1 -0
  40. package/dist/core/common/plugins/mongoose-role-guard.plugin.d.ts +1 -0
  41. package/dist/core/common/plugins/mongoose-role-guard.plugin.js +80 -0
  42. package/dist/core/common/plugins/mongoose-role-guard.plugin.js.map +1 -0
  43. package/dist/core/common/services/config.service.js +2 -2
  44. package/dist/core/common/services/config.service.js.map +1 -1
  45. package/dist/core/common/services/model-registry.service.d.ts +8 -0
  46. package/dist/core/common/services/model-registry.service.js +20 -0
  47. package/dist/core/common/services/model-registry.service.js.map +1 -0
  48. package/dist/core/common/services/module.service.d.ts +2 -0
  49. package/dist/core/common/services/module.service.js +36 -1
  50. package/dist/core/common/services/module.service.js.map +1 -1
  51. package/dist/core/common/services/request-context.service.d.ts +18 -0
  52. package/dist/core/common/services/request-context.service.js +32 -0
  53. package/dist/core/common/services/request-context.service.js.map +1 -0
  54. package/dist/core/modules/auth/guards/auth.guard.js +2 -2
  55. package/dist/core/modules/auth/guards/auth.guard.js.map +1 -1
  56. package/dist/core/modules/better-auth/core-better-auth.resolver.js +2 -2
  57. package/dist/core/modules/better-auth/core-better-auth.resolver.js.map +1 -1
  58. package/dist/core/modules/permissions/core-permissions.controller.d.ts +13 -0
  59. package/dist/core/modules/permissions/core-permissions.controller.js +71 -0
  60. package/dist/core/modules/permissions/core-permissions.controller.js.map +1 -0
  61. package/dist/core/modules/permissions/core-permissions.module.d.ts +5 -0
  62. package/dist/core/modules/permissions/core-permissions.module.js +36 -0
  63. package/dist/core/modules/permissions/core-permissions.module.js.map +1 -0
  64. package/dist/core/modules/permissions/core-permissions.service.d.ts +34 -0
  65. package/dist/core/modules/permissions/core-permissions.service.js +610 -0
  66. package/dist/core/modules/permissions/core-permissions.service.js.map +1 -0
  67. package/dist/core/modules/permissions/interfaces/permissions.interface.d.ts +93 -0
  68. package/dist/core/modules/permissions/interfaces/permissions.interface.js +3 -0
  69. package/dist/core/modules/permissions/interfaces/permissions.interface.js.map +1 -0
  70. package/dist/core/modules/permissions/permissions-scanner.d.ts +25 -0
  71. package/dist/core/modules/permissions/permissions-scanner.js +817 -0
  72. package/dist/core/modules/permissions/permissions-scanner.js.map +1 -0
  73. package/dist/core.module.js +41 -0
  74. package/dist/core.module.js.map +1 -1
  75. package/dist/index.d.ts +15 -0
  76. package/dist/index.js +15 -0
  77. package/dist/index.js.map +1 -1
  78. package/dist/server/modules/file/file-info.model.d.ts +12 -12
  79. package/dist/server/modules/user/user.model.d.ts +33 -33
  80. package/dist/tsconfig.build.tsbuildinfo +1 -1
  81. package/package.json +35 -30
  82. package/src/config.env.ts +8 -2
  83. package/src/core/common/decorators/response-model.decorator.ts +31 -0
  84. package/src/core/common/helpers/db.helper.ts +2 -2
  85. package/src/core/common/helpers/filter.helper.ts +3 -3
  86. package/src/core/common/helpers/input.helper.ts +2 -2
  87. package/src/core/common/helpers/interceptor.helper.ts +132 -0
  88. package/src/core/common/helpers/service.helper.ts +1 -1
  89. package/src/core/common/interceptors/check-security.interceptor.ts +44 -1
  90. package/src/core/common/interceptors/response-model.interceptor.ts +135 -0
  91. package/src/core/common/interceptors/translate-response.interceptor.ts +104 -0
  92. package/src/core/common/interfaces/server-options.interface.ts +186 -0
  93. package/src/core/common/middleware/request-context.middleware.ts +25 -0
  94. package/src/core/common/pipes/map-and-validate.pipe.ts +2 -2
  95. package/src/core/common/plugins/complexity.plugin.ts +2 -2
  96. package/src/core/common/plugins/mongoose-audit-fields.plugin.ts +74 -0
  97. package/src/core/common/plugins/mongoose-password.plugin.ts +100 -0
  98. package/src/core/common/plugins/mongoose-role-guard.plugin.ts +150 -0
  99. package/src/core/common/services/config.service.ts +2 -2
  100. package/src/core/common/services/model-registry.service.ts +25 -0
  101. package/src/core/common/services/module.service.ts +91 -1
  102. package/src/core/common/services/request-context.service.ts +69 -0
  103. package/src/core/modules/auth/guards/auth.guard.ts +2 -2
  104. package/src/core/modules/better-auth/core-better-auth.resolver.ts +2 -2
  105. package/src/core/modules/permissions/INTEGRATION-CHECKLIST.md +56 -0
  106. package/src/core/modules/permissions/README.md +102 -0
  107. package/src/core/modules/permissions/core-permissions.controller.ts +34 -0
  108. package/src/core/modules/permissions/core-permissions.module.ts +36 -0
  109. package/src/core/modules/permissions/core-permissions.service.ts +627 -0
  110. package/src/core/modules/permissions/interfaces/permissions.interface.ts +125 -0
  111. package/src/core/modules/permissions/permissions-scanner.ts +1011 -0
  112. package/src/core.module.ts +62 -4
  113. package/src/index.ts +20 -0
@@ -0,0 +1,150 @@
1
+ import { Logger } from '@nestjs/common';
2
+
3
+ import { RoleEnum } from '../enums/role.enum';
4
+ import { ConfigService } from '../services/config.service';
5
+ import { RequestContext } from '../services/request-context.service';
6
+
7
+ const logger = new Logger('mongooseRoleGuardPlugin');
8
+
9
+ /**
10
+ * Mongoose plugin that prevents unauthorized users from escalating roles.
11
+ * Uses RequestContext (AsyncLocalStorage) to access the current user.
12
+ *
13
+ * **When are role changes allowed?**
14
+ * 1. No user context (system operations, seeding, CLI scripts) → allowed
15
+ * 2. No currentUser on request (e.g. signUp — user not logged in) → allowed
16
+ * 3. User has ADMIN role → allowed
17
+ * 4. User has one of the configured `allowedRoles` → allowed
18
+ * 5. `RequestContext.runWithBypassRoleGuard()` is active → allowed
19
+ * 6. `CrudService.process()` with `force: true` → allowed (auto-bypasses)
20
+ *
21
+ * **When are role changes blocked?**
22
+ * - Logged-in non-admin user without bypass → blocked
23
+ * - On save (new): roles set to `[]`
24
+ * - On save (existing): roles reverted to original
25
+ * - On update: roles stripped from update object
26
+ *
27
+ * **Configuration** via `security.mongooseRoleGuardPlugin`:
28
+ * - `true` — Only ADMIN can assign roles (default)
29
+ * - `{ allowedRoles: ['ORGA', 'HR_MANAGER'] }` — Additional roles that can assign roles
30
+ * - `false` — Plugin disabled entirely
31
+ *
32
+ * **Bypass for authorized service code** (e.g. HR system creating users with roles):
33
+ * ```typescript
34
+ * import { RequestContext } from '@lenne.tech/nest-server';
35
+ *
36
+ * // Wrap the database operation in runWithBypassRoleGuard
37
+ * await RequestContext.runWithBypassRoleGuard(async () => {
38
+ * await this.mainDbModel.create({ email, roles: ['EMPLOYEE'] });
39
+ * });
40
+ *
41
+ * // Or use CrudService.process() with force: true
42
+ * return this.process(
43
+ * async () => this.mainDbModel.findByIdAndUpdate(id, { roles }),
44
+ * { serviceOptions, force: true },
45
+ * );
46
+ * ```
47
+ */
48
+ export function mongooseRoleGuardPlugin(schema) {
49
+ // Pre-save hook
50
+ schema.pre('save', function () {
51
+ if (!this.isModified('roles')) {
52
+ return;
53
+ }
54
+
55
+ if (isRoleChangeAllowed()) {
56
+ return;
57
+ }
58
+
59
+ // Unauthorized: prevent role escalation
60
+ const currentUser = RequestContext.getCurrentUser();
61
+ logger.debug(
62
+ `[nest-server] mongooseRoleGuardPlugin: Blocked role change on ${this.isNew ? 'new' : 'existing'} document by user ${currentUser?.id || 'unknown'}`,
63
+ );
64
+ if (this.isNew) {
65
+ this['roles'] = [];
66
+ } else {
67
+ // Revert to original value
68
+ this.unmarkModified('roles');
69
+ }
70
+ });
71
+
72
+ // Pre-findOneAndUpdate hook
73
+ schema.pre('findOneAndUpdate', function () {
74
+ handleUpdateRoleGuard(this.getUpdate());
75
+ });
76
+
77
+ // Pre-updateOne hook
78
+ schema.pre('updateOne', function () {
79
+ handleUpdateRoleGuard(this.getUpdate());
80
+ });
81
+
82
+ // Pre-updateMany hook
83
+ schema.pre('updateMany', function () {
84
+ handleUpdateRoleGuard(this.getUpdate());
85
+ });
86
+ }
87
+
88
+ /**
89
+ * Check if the current user is allowed to modify roles.
90
+ * Returns true if:
91
+ * - No user context (system operation)
92
+ * - bypassRoleGuard is active (via RequestContext.runWithBypassRoleGuard())
93
+ * - User has ADMIN role
94
+ * - User has one of the configured allowedRoles
95
+ */
96
+ function isRoleChangeAllowed(): boolean {
97
+ const currentUser = RequestContext.getCurrentUser();
98
+ // No user context (system operation) → allow
99
+ if (!currentUser) {
100
+ return true;
101
+ }
102
+ // Explicit bypass (e.g. signUp with default roles, authorized service operations)
103
+ if (RequestContext.isBypassRoleGuard()) {
104
+ return true;
105
+ }
106
+ // Admin → always allow
107
+ if (currentUser.hasRole?.([RoleEnum.ADMIN]) || currentUser.roles?.includes(RoleEnum.ADMIN)) {
108
+ return true;
109
+ }
110
+
111
+ // Check configured allowedRoles
112
+ const pluginConfig = ConfigService.configFastButReadOnly?.security?.mongooseRoleGuardPlugin;
113
+ if (pluginConfig && typeof pluginConfig === 'object' && 'allowedRoles' in pluginConfig && pluginConfig.allowedRoles) {
114
+ const allowedRoles: string[] = pluginConfig.allowedRoles as string[];
115
+ if (currentUser.roles?.some((role: string) => allowedRoles.includes(role))) {
116
+ return true;
117
+ }
118
+ }
119
+
120
+ return false;
121
+ }
122
+
123
+ function handleUpdateRoleGuard(update: any) {
124
+ if (!update) {
125
+ return;
126
+ }
127
+
128
+ const hasRolesUpdate = update.roles || update.$set?.roles || update.$push?.roles || update.$addToSet?.roles;
129
+ if (!hasRolesUpdate) {
130
+ return;
131
+ }
132
+
133
+ if (isRoleChangeAllowed()) {
134
+ return;
135
+ }
136
+
137
+ // Unauthorized: remove roles changes with debug log
138
+ const currentUser = RequestContext.getCurrentUser();
139
+ logger.debug(`Stripped unauthorized roles change from user ${currentUser?.id || 'unknown'}`);
140
+ delete update.roles;
141
+ if (update.$set?.roles) {
142
+ delete update.$set.roles;
143
+ }
144
+ if (update.$push?.roles) {
145
+ delete update.$push.roles;
146
+ }
147
+ if (update.$addToSet?.roles) {
148
+ delete update.$addToSet.roles;
149
+ }
150
+ }
@@ -74,8 +74,8 @@ export class ConfigService {
74
74
 
75
75
  // Init subject handling
76
76
  if (!isInitialized) {
77
- ConfigService._configSubject$.subscribe((config) => {
78
- ConfigService._frozenConfigSubject$.next(deepFreeze(cloneDeep(config)));
77
+ ConfigService._configSubject$.subscribe((value) => {
78
+ ConfigService._frozenConfigSubject$.next(deepFreeze(cloneDeep(value)));
79
79
  });
80
80
  }
81
81
 
@@ -0,0 +1,25 @@
1
+ import { CoreModel } from '../models/core-model.model';
2
+
3
+ /**
4
+ * Central registry mapping Mongoose model names to CoreModel classes.
5
+ * Populated automatically by ModuleService constructors.
6
+ */
7
+ export class ModelRegistry {
8
+ private static models = new Map<string, new (...args: any[]) => CoreModel>();
9
+
10
+ static register(dbModelName: string, modelClass: new (...args: any[]) => CoreModel): void {
11
+ this.models.set(dbModelName, modelClass);
12
+ }
13
+
14
+ static getModelClass(dbModelName: string): (new (...args: any[]) => CoreModel) | undefined {
15
+ return this.models.get(dbModelName);
16
+ }
17
+
18
+ static getAll(): Map<string, new (...args: any[]) => CoreModel> {
19
+ return this.models;
20
+ }
21
+
22
+ static clear(): void {
23
+ this.models.clear();
24
+ }
25
+ }
@@ -5,10 +5,13 @@ import { ProcessType } from '../enums/process-type.enum';
5
5
  import { getStringIds, popAndMap } from '../helpers/db.helper';
6
6
  import { check } from '../helpers/input.helper';
7
7
  import { prepareInput, prepareOutput } from '../helpers/service.helper';
8
+ import { getTranslatablePropertyKeys, updateLanguage } from '../decorators/translatable.decorator';
8
9
  import { ServiceOptions } from '../interfaces/service-options.interface';
9
10
  import { CoreModel } from '../models/core-model.model';
10
11
  import { FieldSelection } from '../types/field-selection.type';
11
12
  import { ConfigService } from './config.service';
13
+ import { ModelRegistry } from './model-registry.service';
14
+ import { RequestContext } from './request-context.service';
12
15
 
13
16
  /**
14
17
  * Module service class to be extended by concrete module services
@@ -40,6 +43,11 @@ export abstract class ModuleService<T extends CoreModel = any> {
40
43
  this.configService = options?.configService;
41
44
  this.mainDbModel = options?.mainDbModel;
42
45
  this.mainModelConstructor = options?.mainModelConstructor;
46
+
47
+ // Auto-register model class for ResponseModelInterceptor lookups
48
+ if (options?.mainModelConstructor && options?.mainDbModel?.modelName) {
49
+ ModelRegistry.register(options.mainDbModel.modelName, options.mainModelConstructor);
50
+ }
43
51
  }
44
52
 
45
53
  /**
@@ -191,7 +199,10 @@ export abstract class ModuleService<T extends CoreModel = any> {
191
199
  }
192
200
 
193
201
  // Run service function
194
- let result = await serviceFunc(config);
202
+ // When force is enabled, bypass the Mongoose role guard plugin as well
203
+ let result = config.force
204
+ ? await RequestContext.runWithBypassRoleGuard(() => serviceFunc(config))
205
+ : await serviceFunc(config);
195
206
 
196
207
  // Pop and map main model
197
208
  if (config.processFieldSelection && config.fieldSelection && this.processFieldSelection) {
@@ -242,6 +253,85 @@ export abstract class ModuleService<T extends CoreModel = any> {
242
253
  return result;
243
254
  }
244
255
 
256
+ /**
257
+ * Simplified result processing for direct Mongoose queries.
258
+ * Handles population and output preparation without the full process() pipeline.
259
+ *
260
+ * Security (password hashing, role guard, createdBy/updatedBy, type mapping,
261
+ * field filtering, secret removal, translations) is handled automatically
262
+ * by the safety net (Mongoose plugins + interceptors).
263
+ *
264
+ * Use this when bypassing CrudService methods but still wanting
265
+ * population and custom prepareOutput() transformations.
266
+ *
267
+ * Note: This method does NOT perform authorization checks (checkRights).
268
+ * The caller is responsible for verifying permissions before calling this method.
269
+ *
270
+ * @example
271
+ * ```typescript
272
+ * // Direct Mongoose query with result processing
273
+ * const doc = await this.mainDbModel.findById(id).exec();
274
+ * return this.processResult(doc, serviceOptions);
275
+ *
276
+ * // Aggregate with result processing
277
+ * const results = await this.mainDbModel.aggregate(pipeline).exec();
278
+ * return this.processResult(results, serviceOptions);
279
+ * ```
280
+ */
281
+ async processResult(result: any, serviceOptions: ServiceOptions = {}): Promise<any> {
282
+ if (!result) {
283
+ return result;
284
+ }
285
+
286
+ // Population (if GraphQL field selection is available)
287
+ if (serviceOptions.fieldSelection && this.processFieldSelection) {
288
+ const populateOpts = serviceOptions.processFieldSelection || {};
289
+ const items = Array.isArray(result) ? result : [result];
290
+ for (const item of items) {
291
+ await this.processFieldSelection(item, serviceOptions.fieldSelection, populateOpts);
292
+ }
293
+ }
294
+
295
+ // Custom prepareOutput (type mapping, ObjectId conversion, custom overrides)
296
+ if (this.prepareOutput) {
297
+ result = await this.prepareOutput(result, serviceOptions);
298
+ }
299
+
300
+ return result;
301
+ }
302
+
303
+ /**
304
+ * Apply translation-aware input processing.
305
+ * Auto-detects @Translatable fields on the model and routes non-base-language
306
+ * values into _translations.
307
+ *
308
+ * @param input - The input data to transform
309
+ * @param oldDoc - The existing document from DB (needed for value comparison)
310
+ * @param language - Language code (defaults to RequestContext.getLanguage())
311
+ * @returns The transformed input with translations stored in _translations
312
+ *
313
+ * @example
314
+ * ```typescript
315
+ * // In a custom update method:
316
+ * const oldDoc = await this.mainDbModel.findById(id).lean();
317
+ * input = this.applyTranslationInput(input, oldDoc, serviceOptions.language);
318
+ * return this.mainDbModel.findByIdAndUpdate(id, input, { returnDocument: 'after' }).exec();
319
+ * ```
320
+ */
321
+ protected applyTranslationInput(input: any, oldDoc: any, language?: string): any {
322
+ language = language || RequestContext.getLanguage();
323
+ if (!language) {
324
+ return input;
325
+ }
326
+
327
+ const translatableFields = getTranslatablePropertyKeys(this.mainModelConstructor);
328
+ if (translatableFields.length === 0) {
329
+ return input;
330
+ }
331
+
332
+ return updateLanguage(language, input, oldDoc, translatableFields);
333
+ }
334
+
245
335
  /**
246
336
  * Prepare input before save
247
337
  */
@@ -0,0 +1,69 @@
1
+ import { AsyncLocalStorage } from 'async_hooks';
2
+
3
+ export interface IRequestContext {
4
+ currentUser?: {
5
+ id: string;
6
+ hasRole?: (roles: string[]) => boolean;
7
+ roles?: string[];
8
+ };
9
+ language?: string;
10
+ /** When true, mongooseRoleGuardPlugin allows role changes regardless of user permissions */
11
+ bypassRoleGuard?: boolean;
12
+ }
13
+
14
+ /**
15
+ * Request-scoped context using AsyncLocalStorage.
16
+ * Provides access to the current user in Mongoose hooks and other
17
+ * places where NestJS request scope is not available.
18
+ */
19
+ export class RequestContext {
20
+ private static storage = new AsyncLocalStorage<IRequestContext>();
21
+
22
+ static run<T>(context: IRequestContext, fn: () => T): T {
23
+ return this.storage.run(context, fn);
24
+ }
25
+
26
+ static get(): IRequestContext | undefined {
27
+ return this.storage.getStore();
28
+ }
29
+
30
+ static getCurrentUser(): IRequestContext['currentUser'] | undefined {
31
+ return this.storage.getStore()?.currentUser;
32
+ }
33
+
34
+ static getLanguage(): string | undefined {
35
+ return this.storage.getStore()?.language;
36
+ }
37
+
38
+ /**
39
+ * Check if the role guard bypass is active for the current context.
40
+ */
41
+ static isBypassRoleGuard(): boolean {
42
+ return this.storage.getStore()?.bypassRoleGuard === true;
43
+ }
44
+
45
+ /**
46
+ * Run a function with the role guard bypass enabled.
47
+ * The current context (user, language) is preserved; only bypassRoleGuard is added.
48
+ *
49
+ * Use this when authorized code needs to set roles on users, e.g.:
50
+ * - signUp with default roles
51
+ * - Admin panel where a non-admin role (e.g. HR_MANAGER) creates users with roles
52
+ * - System setup creating initial admin
53
+ *
54
+ * @example
55
+ * ```typescript
56
+ * await RequestContext.runWithBypassRoleGuard(async () => {
57
+ * await this.mainDbModel.findByIdAndUpdate(userId, { roles: ['EMPLOYEE'] });
58
+ * });
59
+ * ```
60
+ */
61
+ static runWithBypassRoleGuard<T>(fn: () => T): T {
62
+ const currentStore = this.storage.getStore();
63
+ const context: IRequestContext = {
64
+ ...currentStore,
65
+ bypassRoleGuard: true,
66
+ };
67
+ return this.storage.run(context, fn);
68
+ }
69
+ }
@@ -40,8 +40,8 @@ const createPassportContext =
40
40
  try {
41
41
  request.authInfo = info;
42
42
  return resolve(callback(err, user, info));
43
- } catch (err) {
44
- reject(err);
43
+ } catch (callbackError) {
44
+ reject(callbackError);
45
45
  }
46
46
  })(request, response, (err: any) => (err ? reject(err) : resolve(undefined))),
47
47
  );
@@ -342,9 +342,9 @@ export class CoreBetterAuthResolver {
342
342
  // 1. accessToken (JWT plugin enriched response)
343
343
  // 2. token (top-level, some BetterAuth versions)
344
344
  // 3. session.token (session-based fallback)
345
- const responseAny = response as any;
345
+ const tokenResponse = response as any;
346
346
  const rawToken =
347
- responseAny.accessToken || responseAny.token || (hasSession(response) ? response.session.token : undefined);
347
+ tokenResponse.accessToken || tokenResponse.token || (hasSession(response) ? response.session.token : undefined);
348
348
  const token = await this.resolveJwtToken(rawToken);
349
349
 
350
350
  return {
@@ -0,0 +1,56 @@
1
+ # Permissions Module Integration Checklist
2
+
3
+ ## Reference Implementation
4
+
5
+ - Local: `node_modules/@lenne.tech/nest-server/src/server/` (scanned at runtime)
6
+ - GitHub: https://github.com/lenneTech/nest-server/tree/develop/src/core/modules/permissions
7
+
8
+ ## Quick Setup
9
+
10
+ The Permissions module requires **no files to create** in the consuming project. It only needs a configuration entry.
11
+
12
+ ### 1. Enable in config.env.ts
13
+
14
+ Add the `permissions` property to your environment configuration:
15
+
16
+ ```typescript
17
+ // config.env.ts - local/development only
18
+ permissions: true,
19
+ ```
20
+
21
+ **WHY only dev/local?** This is a development tool that exposes security metadata. It should never run in production.
22
+
23
+ ### 2. Verify (Optional)
24
+
25
+ Start your server and navigate to:
26
+
27
+ - `http://localhost:3000/permissions` - HTML dashboard
28
+ - `http://localhost:3000/permissions/json` - Raw JSON
29
+
30
+ ## Configuration Options
31
+
32
+ | Config | Access | Description |
33
+ | ------------------------------------ | ---------- | ---------------------------------------------------- |
34
+ | `true` | Admin only | Default, requires `RoleEnum.ADMIN` at `/permissions` |
35
+ | `{ role: 'S_EVERYONE' }` | Any role | Custom role for access |
36
+ | `{ role: false }` | No auth | No authentication required |
37
+ | `{ path: 'admin/permissions' }` | Admin only | Custom endpoint path (default: `permissions`) |
38
+ | `{ role: false, path: 'dev/perms' }` | No auth | Custom path + no auth |
39
+ | `{ enabled: false }` | Disabled | Explicitly disabled |
40
+ | `undefined` | Disabled | Default (not configured) |
41
+
42
+ ## Verification Checklist
43
+
44
+ - [ ] `permissions: true` added to local/dev config only
45
+ - [ ] Server starts without errors
46
+ - [ ] `/permissions` returns HTML dashboard (requires admin login)
47
+ - [ ] `/permissions/json` returns JSON report
48
+ - [ ] Production config does NOT include `permissions`
49
+
50
+ ## Common Mistakes
51
+
52
+ | Mistake | Symptom | Fix |
53
+ | ---------------------------- | ---------------------------------- | ------------------------------------------------------------ |
54
+ | Enabled in production config | Security metadata exposed publicly | Only enable in `local` / `dev` environments |
55
+ | Missing admin token | 401 Unauthorized on `/permissions` | Login as admin first, or use `{ role: false }` for local dev |
56
+ | No modules found | Empty report | Ensure `src/server/modules/` exists with module directories |
@@ -0,0 +1,102 @@
1
+ # Permissions Report Module
2
+
3
+ A development tool that scans `src/server/modules/` for `@Roles`, `@Restricted`, and `securityCheck()` usage, then generates an interactive HTML dashboard.
4
+
5
+ ## Purpose
6
+
7
+ Provides a real-time overview of all role-based access control configuration across your project, helping identify:
8
+
9
+ - Endpoints without `@Roles` protection
10
+ - Models without `@Restricted` class-level restrictions
11
+ - Models missing `securityCheck()` overrides
12
+ - Fields without role restrictions
13
+
14
+ ## Endpoints
15
+
16
+ The base path defaults to `/permissions` but can be customized via the `path` config option.
17
+
18
+ | Method | Path | Content-Type | Description |
19
+ | ------ | ------------------ | ------------------ | ----------------------------------------- |
20
+ | `GET` | `/{path}` | `text/html` | Interactive HTML dashboard |
21
+ | `GET` | `/{path}/json` | `application/json` | JSON report with `stats` field |
22
+ | `GET` | `/{path}/markdown` | `text/plain` | Markdown report (optimized for AI agents) |
23
+ | `POST` | `/{path}/rescan` | `application/json` | Force a rescan (rate limited: 1 per 10s) |
24
+
25
+ ## JSON Stats
26
+
27
+ The JSON report includes a `stats` field with pre-calculated metrics:
28
+
29
+ ```json
30
+ {
31
+ "stats": {
32
+ "totalModules": 5,
33
+ "totalModels": 8,
34
+ "totalEndpoints": 42,
35
+ "totalSubObjects": 3,
36
+ "totalWarnings": 12,
37
+ "endpointCoverage": 85,
38
+ "securityCoverage": 62,
39
+ "warningsByType": {
40
+ "NO_RESTRICTION": 3,
41
+ "NO_ROLES": 2,
42
+ "NO_SECURITY_CHECK": 3,
43
+ "UNRESTRICTED_FIELD": 2,
44
+ "UNRESTRICTED_METHOD": 2
45
+ }
46
+ }
47
+ }
48
+ ```
49
+
50
+ - **endpointCoverage**: % of methods with @Roles (own or class-level)
51
+ - **securityCoverage**: % of models with both @Restricted AND securityCheck()
52
+
53
+ ## Configuration
54
+
55
+ Add `permissions` to your `config.env.ts`:
56
+
57
+ ```typescript
58
+ // Enable with admin-only access (default)
59
+ permissions: true,
60
+
61
+ // Enable with custom role
62
+ permissions: { role: 'S_EVERYONE' },
63
+
64
+ // Enable without authentication
65
+ permissions: { role: false },
66
+
67
+ // Custom endpoint path (e.g. /admin/permissions instead of /permissions)
68
+ permissions: { path: 'admin/permissions' },
69
+
70
+ // Explicitly disabled
71
+ permissions: { enabled: false },
72
+ ```
73
+
74
+ Follows the [Boolean Shorthand Pattern](/.claude/rules/configurable-features.md).
75
+
76
+ ## How It Works
77
+
78
+ 1. **Lazy scanning**: The first request to `/permissions` triggers a full scan using [ts-morph](https://ts-morph.com/) to parse TypeScript AST
79
+ 2. **Caching**: Results are cached in memory until a `.ts` file changes in `src/server/`
80
+ 3. **File watching**: A recursive `fs.watch` on `src/server/` invalidates the cache on changes
81
+ 4. **Inheritance resolution**: Walks `node_modules/@lenne.tech/nest-server` to resolve inherited fields from `Core*` base classes
82
+ 5. **Security gap detection**: Compares found decorators against expected patterns and reports warnings
83
+
84
+ ## Access Control
85
+
86
+ By default, only users with `RoleEnum.ADMIN` can access the endpoints. This is applied via `Reflect.defineMetadata('roles', ...)` at module registration time, which is read by `RolesGuard`.
87
+
88
+ ## Architecture
89
+
90
+ ```
91
+ CorePermissionsModule.forRoot(config)
92
+ -> CorePermissionsController (REST endpoints)
93
+ -> CorePermissionsService (caching, file watcher, HTML/Markdown generation)
94
+ -> permissions-scanner.ts (standalone AST scanning, shared with lt CLI)
95
+ ```
96
+
97
+ The architecture is split into:
98
+
99
+ - **`permissions-scanner.ts`** (standalone, no NestJS dependencies): All AST-based scan logic using `ts-morph`. This is the single source of truth, also used by the `lt server permissions` CLI command via dynamic import.
100
+ - **`CorePermissionsService`**: Caching, rate limiting, file watching, HTML/Markdown generation. Delegates scanning to `scanPermissions()`.
101
+ - **`CorePermissionsController`**: REST endpoints with dynamically configurable base path.
102
+ - **`CorePermissionsModule`**: DynamicModule with `forRoot()`, configures role and path via `Reflect.defineMetadata`.
@@ -0,0 +1,34 @@
1
+ import { Controller, Get, Header, Headers, Post } from '@nestjs/common';
2
+
3
+ import { CorePermissionsService } from './core-permissions.service';
4
+ import type { PermissionsReport } from './interfaces/permissions.interface';
5
+
6
+ @Controller()
7
+ export class CorePermissionsController {
8
+ constructor(private readonly permissionsService: CorePermissionsService) {}
9
+
10
+ @Get()
11
+ @Header('Content-Type', 'text/html')
12
+ async getPermissionsHtml(@Headers('authorization') authHeader?: string): Promise<string> {
13
+ await this.permissionsService.getOrScan();
14
+ return this.permissionsService.generateHtml(authHeader);
15
+ }
16
+
17
+ @Get('json')
18
+ async getPermissionsJson(): Promise<PermissionsReport> {
19
+ return this.permissionsService.getOrScan();
20
+ }
21
+
22
+ @Get('markdown')
23
+ @Header('Content-Type', 'text/plain; charset=utf-8')
24
+ async getPermissionsMarkdown(): Promise<string> {
25
+ await this.permissionsService.getOrScan();
26
+ return this.permissionsService.generateMarkdown();
27
+ }
28
+
29
+ @Post('rescan')
30
+ async rescan() {
31
+ await this.permissionsService.scan();
32
+ return { message: 'Rescan completed', timestamp: new Date().toISOString() };
33
+ }
34
+ }
@@ -0,0 +1,36 @@
1
+ import { DynamicModule, Module } from '@nestjs/common';
2
+ import { PATH_METADATA } from '@nestjs/common/constants';
3
+
4
+ import { RoleEnum } from '../../common/enums/role.enum';
5
+
6
+ import { CorePermissionsController } from './core-permissions.controller';
7
+ import { CorePermissionsService } from './core-permissions.service';
8
+ import type { IPermissions } from './interfaces/permissions.interface';
9
+
10
+ @Module({})
11
+ export class CorePermissionsModule {
12
+ static forRoot(config: boolean | IPermissions): DynamicModule {
13
+ const role = typeof config === 'object' && config.role !== undefined ? config.role : RoleEnum.ADMIN;
14
+
15
+ const path = typeof config === 'object' && config.path ? config.path : 'permissions';
16
+
17
+ // Apply role-based access control at the class level using Reflect.defineMetadata
18
+ // instead of the @Roles() decorator because the role value is determined at runtime
19
+ // from the configuration. RolesGuard reads this metadata to enforce access control.
20
+ if (role !== false) {
21
+ Reflect.defineMetadata('roles', [role], CorePermissionsController);
22
+ }
23
+
24
+ // Override the controller's @Controller() path with the configured path.
25
+ // This allows users to serve the permissions report under a custom route (e.g. 'admin/permissions')
26
+ // while keeping the default 'permissions' path when no custom path is specified.
27
+ Reflect.defineMetadata(PATH_METADATA, path, CorePermissionsController);
28
+
29
+ return {
30
+ controllers: [CorePermissionsController],
31
+ exports: [CorePermissionsService],
32
+ module: CorePermissionsModule,
33
+ providers: [{ provide: 'PERMISSIONS_PATH', useValue: path }, CorePermissionsService],
34
+ };
35
+ }
36
+ }