@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,104 @@
1
+ import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
2
+ import { GqlContextType, GqlExecutionContext } from '@nestjs/graphql';
3
+ import { Observable } from 'rxjs';
4
+ import { map } from 'rxjs/operators';
5
+
6
+ import { applyTranslationsRecursively } from '../helpers/service.helper';
7
+
8
+ /**
9
+ * Interceptor that automatically applies translations from _translations
10
+ * based on the Accept-Language header of the request.
11
+ *
12
+ * This ensures translations work even when developers bypass CrudService.process()
13
+ * and use direct Mongoose operations.
14
+ *
15
+ * Execution order on response:
16
+ * 1. ResponseModelInterceptor (plain → model)
17
+ * 2. TranslateResponseInterceptor (applies translations) ← THIS
18
+ * 3. CheckSecurityInterceptor (securityCheck())
19
+ * 4. CheckResponseInterceptor (@Restricted fields)
20
+ */
21
+ @Injectable()
22
+ export class TranslateResponseInterceptor implements NestInterceptor {
23
+ intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
24
+ const language = this.getLanguage(context);
25
+
26
+ if (!language) {
27
+ return next.handle();
28
+ }
29
+
30
+ return next.handle().pipe(
31
+ map((data) => {
32
+ if (!data || typeof data !== 'object') {
33
+ return data;
34
+ }
35
+ this.applyTranslations(data, language);
36
+ return data;
37
+ }),
38
+ );
39
+ }
40
+
41
+ private applyTranslations(data: any, language: string): void {
42
+ // Early bailout: skip if no _translations anywhere in the response
43
+ if (!this.hasTranslations(data)) {
44
+ return;
45
+ }
46
+
47
+ if (Array.isArray(data)) {
48
+ for (const item of data) {
49
+ if (item && typeof item === 'object') {
50
+ applyTranslationsRecursively(item, language);
51
+ }
52
+ }
53
+ } else if (data.items && Array.isArray(data.items)) {
54
+ // Wrapper objects (e.g. { items: [...], totalCount })
55
+ for (const item of data.items) {
56
+ if (item && typeof item === 'object') {
57
+ applyTranslationsRecursively(item, language);
58
+ }
59
+ }
60
+ } else {
61
+ applyTranslationsRecursively(data, language);
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Quick check if _translations exists at the top level of the response.
67
+ * Avoids expensive recursive traversal when no translations are present.
68
+ */
69
+ private hasTranslations(data: any): boolean {
70
+ if (!data || typeof data !== 'object') {
71
+ return false;
72
+ }
73
+ if (Array.isArray(data)) {
74
+ return data.length > 0 && data[0] && typeof data[0] === 'object' && '_translations' in data[0];
75
+ }
76
+ if (data.items && Array.isArray(data.items)) {
77
+ return (
78
+ data.items.length > 0 && data.items[0] && typeof data.items[0] === 'object' && '_translations' in data.items[0]
79
+ );
80
+ }
81
+ return '_translations' in data;
82
+ }
83
+
84
+ private getLanguage(context: ExecutionContext): string | null {
85
+ // GraphQL context
86
+ try {
87
+ if (context.getType<GqlContextType>() === 'graphql') {
88
+ const gqlContext = GqlExecutionContext.create(context);
89
+ const req = gqlContext.getContext()?.req;
90
+ return req?.headers?.['accept-language'] || null;
91
+ }
92
+ } catch {
93
+ // Not a GraphQL context
94
+ }
95
+
96
+ // HTTP context
97
+ try {
98
+ const req = context.switchToHttp()?.getRequest();
99
+ return req?.headers?.['accept-language'] || null;
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+ }
@@ -13,6 +13,7 @@ import { CollationOptions } from 'mongodb';
13
13
  import * as SMTPTransport from 'nodemailer/lib/smtp-transport';
14
14
 
15
15
  import { Falsy } from '../types/falsy.type';
16
+ import { IPermissions } from '../../modules/permissions/interfaces/permissions.interface';
16
17
  import { CronJobConfigWithTimeZone } from './cron-job-config-with-time-zone.interface';
17
18
  import { CronJobConfigWithUtcOffset } from './cron-job-config-with-utc-offset.interface';
18
19
  import { MailjetOptions } from './mailjet-options.interface';
@@ -1317,6 +1318,31 @@ export interface IServerOptions {
1317
1318
  uri: string;
1318
1319
  };
1319
1320
 
1321
+ /**
1322
+ * Permissions report module (development tool).
1323
+ *
1324
+ * When enabled, provides an interactive HTML report at /permissions
1325
+ * showing all @Roles, @Restricted decorators, and security gaps.
1326
+ *
1327
+ * Follows the "presence implies enabled" pattern:
1328
+ * - `true`: Enabled with defaults (admin-only access)
1329
+ * - `{ role: 'S_EVERYONE' }`: Enabled with custom role for access
1330
+ * - `{ role: false }`: Enabled without auth check
1331
+ * - `{ enabled: false }`: Explicitly disabled
1332
+ *
1333
+ * @default undefined (disabled)
1334
+ *
1335
+ * @example
1336
+ * ```typescript
1337
+ * // Enable for local/development (admin-only)
1338
+ * permissions: true,
1339
+ *
1340
+ * // Enable with public access (no auth needed)
1341
+ * permissions: { role: false },
1342
+ * ```
1343
+ */
1344
+ permissions?: boolean | IPermissions;
1345
+
1320
1346
  /**
1321
1347
  * Port number of the server
1322
1348
  * e.g. 8080
@@ -1400,6 +1426,26 @@ export interface IServerOptions {
1400
1426
  * default = true
1401
1427
  */
1402
1428
  noteCheckedObjects?: boolean;
1429
+
1430
+ /**
1431
+ * Whether to remove known secret fields as a fallback safety net.
1432
+ * Applied after securityCheck() to catch any remaining secrets.
1433
+ * default = true
1434
+ *
1435
+ * @since 11.18.0
1436
+ */
1437
+ removeSecretFields?: boolean;
1438
+
1439
+ /**
1440
+ * List of field names to remove in the fallback secret removal.
1441
+ * Only used when removeSecretFields is true.
1442
+ * Note: security.secretFields (global) takes precedence over this setting if both are defined.
1443
+ * Use this for interceptor-specific overrides, or security.secretFields for a global default.
1444
+ * default = ['password', 'verificationToken', 'passwordResetToken', 'refreshTokens', 'tempTokens']
1445
+ *
1446
+ * @since 11.18.0
1447
+ */
1448
+ secretFields?: string[];
1403
1449
  };
1404
1450
 
1405
1451
  /**
@@ -1423,6 +1469,146 @@ export interface IServerOptions {
1423
1469
  */
1424
1470
  nonWhitelistedFields?: 'strip' | 'error' | false;
1425
1471
  };
1472
+
1473
+ /**
1474
+ * Mongoose password hashing plugin - automatically hashes passwords on save/update
1475
+ * (save, findOneAndUpdate, updateOne, updateMany).
1476
+ * Prevents plaintext passwords even when CrudService.process() is bypassed.
1477
+ * Already-hashed values (BCrypt pattern /^\$2[aby]\$\d+\$/) are detected and skipped
1478
+ * to prevent double-hashing.
1479
+ *
1480
+ * For sentinel/lock values (e.g. '!LOCKED:REQUIRES_PASSWORD_RESET'), configure
1481
+ * skipPatterns so they are preserved as-is and not hashed.
1482
+ *
1483
+ * default = true (opt-out via false)
1484
+ *
1485
+ * @example
1486
+ * ```typescript
1487
+ * // Default: hash all passwords
1488
+ * mongoosePasswordPlugin: true,
1489
+ *
1490
+ * // Skip sentinel values used for account locking
1491
+ * mongoosePasswordPlugin: { skipPatterns: ['^!LOCKED:'] },
1492
+ *
1493
+ * // Disable entirely
1494
+ * mongoosePasswordPlugin: false,
1495
+ * ```
1496
+ *
1497
+ * @since 11.18.0
1498
+ */
1499
+ mongoosePasswordPlugin?:
1500
+ | boolean
1501
+ | {
1502
+ /**
1503
+ * Regex patterns for password values that should NOT be hashed.
1504
+ * Use this for sentinel/lock values like '!LOCKED:REQUIRES_PASSWORD_RESET'
1505
+ * that are intentionally invalid hashes to prevent login.
1506
+ * Patterns are matched against the raw password value.
1507
+ *
1508
+ * @example ['^!LOCKED:', '^\\$NOHASH\\$']
1509
+ */
1510
+ skipPatterns?: (string | RegExp)[];
1511
+ };
1512
+
1513
+ /**
1514
+ * Mongoose audit fields plugin - automatically sets createdBy/updatedBy fields
1515
+ * on save/update operations.
1516
+ * Uses RequestContext (AsyncLocalStorage) to access the current user —
1517
+ * requires RequestContextMiddleware to be active (registered automatically by CoreModule).
1518
+ * Only activates on schemas that have createdBy and/or updatedBy fields defined.
1519
+ * default = true (opt-out via false)
1520
+ *
1521
+ * @since 11.18.0
1522
+ */
1523
+ mongooseAuditFieldsPlugin?: boolean;
1524
+
1525
+ /**
1526
+ * Mongoose role guard plugin - prevents unauthorized users from escalating roles
1527
+ * via save/update operations.
1528
+ * Uses RequestContext (AsyncLocalStorage) to access the current user —
1529
+ * requires RequestContextMiddleware to be active (registered automatically by CoreModule).
1530
+ * System operations without a request context (e.g. migrations) are allowed through.
1531
+ *
1532
+ * By default, only ADMIN users can assign roles. Use allowedRoles to permit
1533
+ * additional roles (e.g. ORGA, COMPANY_ADMIN) to assign roles to other users.
1534
+ *
1535
+ * default = true (opt-out via false)
1536
+ *
1537
+ * @example
1538
+ * ```typescript
1539
+ * // Default: only ADMIN can assign roles
1540
+ * mongooseRoleGuardPlugin: true,
1541
+ *
1542
+ * // Allow ADMIN and custom roles to assign roles
1543
+ * mongooseRoleGuardPlugin: { allowedRoles: ['ORGA', 'COMPANY_ADMIN'] },
1544
+ *
1545
+ * // Disable entirely
1546
+ * mongooseRoleGuardPlugin: false,
1547
+ * ```
1548
+ *
1549
+ * @since 11.18.0
1550
+ */
1551
+ mongooseRoleGuardPlugin?:
1552
+ | boolean
1553
+ | {
1554
+ /**
1555
+ * Additional roles (beyond ADMIN) that are allowed to assign roles to other users.
1556
+ * ADMIN is always implicitly allowed — no need to include it here.
1557
+ */
1558
+ allowedRoles?: string[];
1559
+ };
1560
+
1561
+ /**
1562
+ * ResponseModelInterceptor - auto-converts plain objects/Mongoose documents
1563
+ * to model instances with securityCheck() and correct constructor metadata.
1564
+ * Ensures @Restricted field filtering and securityCheck() work even when
1565
+ * CrudService.process() is bypassed.
1566
+ *
1567
+ * Model class resolution order:
1568
+ * 1. Explicit @ResponseModel(ModelClass) decorator
1569
+ * 2. GraphQL TypeMetadataStorage (automatic for @Query/@Mutation return types)
1570
+ * 3. Swagger @ApiOkResponse / @ApiCreatedResponse type (automatic for REST)
1571
+ * 4. No conversion (existing interceptors work as before)
1572
+ *
1573
+ * Results are cached per route handler for zero-cost subsequent lookups.
1574
+ * default = true (opt-out via false)
1575
+ *
1576
+ * @since 11.18.0
1577
+ */
1578
+ responseModelInterceptor?:
1579
+ | boolean
1580
+ | {
1581
+ /**
1582
+ * Log a warning when auto-conversion from plain object to model occurs.
1583
+ * Useful for identifying services that bypass CrudService.
1584
+ * default = false
1585
+ */
1586
+ debug?: boolean;
1587
+ };
1588
+
1589
+ /**
1590
+ * TranslateResponseInterceptor - automatically applies _translations
1591
+ * to response objects based on the Accept-Language header of the request.
1592
+ * Reads @Translatable field values from _translations and overwrites
1593
+ * the base-language field with the requested translation.
1594
+ * Includes early bailout: skips recursive traversal when no _translations
1595
+ * are present in the response.
1596
+ * Ensures translations work even when CrudService.process() is bypassed.
1597
+ * default = true (opt-out via false)
1598
+ *
1599
+ * @since 11.18.0
1600
+ */
1601
+ translateResponseInterceptor?: boolean;
1602
+
1603
+ /**
1604
+ * Global list of field names that should always be removed from responses.
1605
+ * This is a fallback safety net applied after all other security checks.
1606
+ * Takes precedence over checkSecurityInterceptor.secretFields if both are set.
1607
+ * default = ['password', 'verificationToken', 'passwordResetToken', 'refreshTokens', 'tempTokens']
1608
+ *
1609
+ * @since 11.18.0
1610
+ */
1611
+ secretFields?: string[];
1426
1612
  };
1427
1613
 
1428
1614
  /**
@@ -0,0 +1,25 @@
1
+ import { Injectable, NestMiddleware } from '@nestjs/common';
2
+ import { NextFunction, Request, Response } from 'express';
3
+
4
+ import { IRequestContext, RequestContext } from '../services/request-context.service';
5
+
6
+ /**
7
+ * Middleware that wraps each request in a RequestContext (AsyncLocalStorage).
8
+ * Uses a lazy getter for currentUser so that the user is resolved at access time,
9
+ * not at middleware execution time. This ensures that auth middleware that runs
10
+ * after this middleware still sets req.user before it's read.
11
+ */
12
+ @Injectable()
13
+ export class RequestContextMiddleware implements NestMiddleware {
14
+ use(req: Request, _res: Response, next: NextFunction) {
15
+ const context: IRequestContext = {
16
+ get currentUser() {
17
+ return (req as any).user || undefined;
18
+ },
19
+ get language() {
20
+ return req.headers?.['accept-language'] || undefined;
21
+ },
22
+ };
23
+ RequestContext.run(context, () => next());
24
+ }
25
+ }
@@ -244,10 +244,10 @@ async function validateWithInheritance(object: any, originalPlainValue: any): Pr
244
244
  // Special handling for validators with arrays when 'each' option is set
245
245
  // The 'each' property indicates validation should be applied to each array element
246
246
  const isArrayValue = Array.isArray(propertyValue);
247
- const shouldValidateEach =
247
+ const shouldValidateEachItem =
248
248
  metadata.each === true || (metadata as any).validationOptions?.each === true;
249
249
 
250
- if (isArrayValue && shouldValidateEach) {
250
+ if (isArrayValue && shouldValidateEachItem) {
251
251
  // Validate each array element individually
252
252
  const results: boolean[] = [];
253
253
  for (const item of propertyValue) {
@@ -1,6 +1,6 @@
1
1
  import { Plugin } from '@nestjs/apollo';
2
2
  import { GraphQLSchemaHost } from '@nestjs/graphql';
3
- import { ApolloServerPlugin, GraphQLRequestListener } from 'apollo-server-plugin-base';
3
+ import type { ApolloServerPlugin, BaseContext, GraphQLRequestListener } from '@apollo/server';
4
4
  import { GraphQLError } from 'graphql';
5
5
  import { fieldExtensionsEstimator, getComplexity, simpleEstimator } from 'graphql-query-complexity';
6
6
 
@@ -13,7 +13,7 @@ export class ComplexityPlugin implements ApolloServerPlugin {
13
13
  private configService: ConfigService,
14
14
  ) {}
15
15
 
16
- async requestDidStart(): Promise<GraphQLRequestListener> {
16
+ async requestDidStart(): Promise<GraphQLRequestListener<BaseContext>> {
17
17
  const maxComplexity: number = this.configService.getFastButReadOnly('graphQl.maxComplexity');
18
18
  const { schema } = this.gqlSchemaHost;
19
19
 
@@ -0,0 +1,74 @@
1
+ import { RequestContext } from '../services/request-context.service';
2
+
3
+ /**
4
+ * Mongoose plugin that automatically sets createdBy/updatedBy fields.
5
+ * Uses RequestContext (AsyncLocalStorage) to access the current user.
6
+ *
7
+ * Behavior:
8
+ * - No user context (system operations, seeding): fields are not set
9
+ * - New documents: sets createdBy (if not already set) and updatedBy
10
+ * - Existing documents on save: sets updatedBy
11
+ * - Update operations: sets updatedBy
12
+ *
13
+ * Only activates on schemas that have createdBy and/or updatedBy fields defined.
14
+ */
15
+ export function mongooseAuditFieldsPlugin(schema) {
16
+ const hasCreatedBy = !!schema.path('createdBy');
17
+ const hasUpdatedBy = !!schema.path('updatedBy');
18
+
19
+ // Skip schemas without audit fields (e.g. BetterAuth sessions, third-party schemas)
20
+ if (!hasCreatedBy && !hasUpdatedBy) {
21
+ return;
22
+ }
23
+
24
+ // Pre-save hook
25
+ schema.pre('save', function () {
26
+ const currentUser = RequestContext.getCurrentUser();
27
+ if (!currentUser?.id) {
28
+ return;
29
+ }
30
+
31
+ if (hasCreatedBy && this.isNew && !this['createdBy']) {
32
+ this['createdBy'] = currentUser.id;
33
+ }
34
+ if (hasUpdatedBy) {
35
+ this['updatedBy'] = currentUser.id;
36
+ }
37
+ });
38
+
39
+ // Pre-update hooks (only needed for updatedBy)
40
+ if (hasUpdatedBy) {
41
+ const updateHook = function () {
42
+ const currentUser = RequestContext.getCurrentUser();
43
+ if (!currentUser?.id) {
44
+ return;
45
+ }
46
+
47
+ const update = this.getUpdate();
48
+ if (!update) {
49
+ return;
50
+ }
51
+
52
+ // Handle both flat updates and $set operator
53
+ update['updatedBy'] = currentUser.id;
54
+ if (update.$set) {
55
+ update.$set['updatedBy'] = currentUser.id;
56
+ }
57
+
58
+ // Handle upsert: set createdBy on insert via $setOnInsert
59
+ // Only add if createdBy is not already in the update (avoids MongoDB path conflict)
60
+ if (hasCreatedBy && !update['createdBy'] && !update.$set?.['createdBy']) {
61
+ if (!update.$setOnInsert) {
62
+ update.$setOnInsert = {};
63
+ }
64
+ if (!update.$setOnInsert['createdBy']) {
65
+ update.$setOnInsert['createdBy'] = currentUser.id;
66
+ }
67
+ }
68
+ };
69
+
70
+ schema.pre('findOneAndUpdate', updateHook);
71
+ schema.pre('updateOne', updateHook);
72
+ schema.pre('updateMany', updateHook);
73
+ }
74
+ }
@@ -0,0 +1,100 @@
1
+ import bcrypt = require('bcrypt');
2
+ import { sha256 } from 'js-sha256';
3
+
4
+ import { ConfigService } from '../services/config.service';
5
+
6
+ /**
7
+ * Mongoose plugin that automatically hashes passwords before saving to the database.
8
+ * Handles save(), findOneAndUpdate(), updateOne(), and updateMany() operations.
9
+ *
10
+ * Prevents plaintext passwords from being stored even when developers bypass
11
+ * CrudService.process() and use direct Mongoose operations.
12
+ *
13
+ * Detects already-hashed values (BCrypt pattern) to prevent double-hashing.
14
+ *
15
+ * For sentinel/lock values (e.g. '!LOCKED:REQUIRES_PASSWORD_RESET'), configure
16
+ * skipPatterns in security.mongoosePasswordPlugin to preserve them as-is.
17
+ */
18
+ export function mongoosePasswordPlugin(schema) {
19
+ // Pre-save hook
20
+ schema.pre('save', async function () {
21
+ if (!this.isModified('password') || !this['password']) {
22
+ return;
23
+ }
24
+ this['password'] = await hashPassword(this['password']);
25
+ });
26
+
27
+ // Pre-findOneAndUpdate hook
28
+ schema.pre('findOneAndUpdate', async function () {
29
+ await hashUpdatePassword(this.getUpdate());
30
+ });
31
+
32
+ // Pre-updateOne hook
33
+ schema.pre('updateOne', async function () {
34
+ await hashUpdatePassword(this.getUpdate());
35
+ });
36
+
37
+ // Pre-updateMany hook
38
+ schema.pre('updateMany', async function () {
39
+ await hashUpdatePassword(this.getUpdate());
40
+ });
41
+ }
42
+
43
+ export async function hashUpdatePassword(update: any) {
44
+ if (!update) {
45
+ return;
46
+ }
47
+ if (update.password) {
48
+ update.password = await hashPassword(update.password);
49
+ }
50
+ if (update.$set?.password) {
51
+ update.$set.password = await hashPassword(update.$set.password);
52
+ }
53
+ }
54
+
55
+ // Compiled RegExp cache (built once on first call)
56
+ let compiledSkipPatterns: RegExp[] | null = null;
57
+
58
+ /**
59
+ * Reset the compiled skip patterns cache.
60
+ * Use in test environments where ConfigService is reset between test suites.
61
+ */
62
+ export function resetSkipPatternsCache(): void {
63
+ compiledSkipPatterns = null;
64
+ }
65
+
66
+ function getSkipPatterns(): RegExp[] {
67
+ if (compiledSkipPatterns !== null) {
68
+ return compiledSkipPatterns;
69
+ }
70
+ const pluginConfig = ConfigService.configFastButReadOnly?.security?.mongoosePasswordPlugin;
71
+ if (pluginConfig && typeof pluginConfig === 'object' && 'skipPatterns' in pluginConfig && pluginConfig.skipPatterns) {
72
+ compiledSkipPatterns = (pluginConfig.skipPatterns as (string | RegExp)[]).map((p) =>
73
+ p instanceof RegExp ? p : new RegExp(p),
74
+ );
75
+ } else {
76
+ compiledSkipPatterns = [];
77
+ }
78
+ return compiledSkipPatterns;
79
+ }
80
+
81
+ export async function hashPassword(password: string): Promise<string> {
82
+ // Already BCrypt-hashed → skip
83
+ if (/^\$2[aby]\$\d+\$/.test(password)) {
84
+ return password;
85
+ }
86
+
87
+ // Check configured skip patterns (e.g. sentinel/lock values)
88
+ const skipPatterns = getSkipPatterns();
89
+ if (skipPatterns.some((pattern) => pattern.test(password))) {
90
+ return password;
91
+ }
92
+
93
+ // SHA256 pre-hash if configured and password is not already SHA256
94
+ const sha256Enabled = ConfigService.configFastButReadOnly?.sha256;
95
+ if (sha256Enabled && !/^[a-f0-9]{64}$/i.test(password)) {
96
+ password = sha256(password);
97
+ }
98
+
99
+ return bcrypt.hash(password, 10);
100
+ }