@lenne.tech/nest-server 11.17.0 → 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.
- package/dist/config.env.js +2 -2
- package/dist/config.env.js.map +1 -1
- package/dist/core/common/decorators/response-model.decorator.d.ts +3 -0
- package/dist/core/common/decorators/response-model.decorator.js +8 -0
- package/dist/core/common/decorators/response-model.decorator.js.map +1 -0
- package/dist/core/common/helpers/db.helper.js +2 -2
- package/dist/core/common/helpers/db.helper.js.map +1 -1
- package/dist/core/common/helpers/filter.helper.js +3 -3
- package/dist/core/common/helpers/filter.helper.js.map +1 -1
- package/dist/core/common/helpers/input.helper.js +2 -2
- package/dist/core/common/helpers/input.helper.js.map +1 -1
- package/dist/core/common/helpers/interceptor.helper.d.ts +3 -0
- package/dist/core/common/helpers/interceptor.helper.js +84 -0
- package/dist/core/common/helpers/interceptor.helper.js.map +1 -0
- package/dist/core/common/helpers/service.helper.d.ts +1 -0
- package/dist/core/common/helpers/service.helper.js +1 -0
- package/dist/core/common/helpers/service.helper.js.map +1 -1
- package/dist/core/common/interceptors/check-security.interceptor.d.ts +2 -0
- package/dist/core/common/interceptors/check-security.interceptor.js +43 -1
- package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
- package/dist/core/common/interceptors/response-model.interceptor.d.ts +13 -0
- package/dist/core/common/interceptors/response-model.interceptor.js +107 -0
- package/dist/core/common/interceptors/response-model.interceptor.js.map +1 -0
- package/dist/core/common/interceptors/translate-response.interceptor.d.ts +8 -0
- package/dist/core/common/interceptors/translate-response.interceptor.js +85 -0
- package/dist/core/common/interceptors/translate-response.interceptor.js.map +1 -0
- package/dist/core/common/interfaces/server-options.interface.d.ts +14 -0
- package/dist/core/common/middleware/request-context.middleware.d.ts +5 -0
- package/dist/core/common/middleware/request-context.middleware.js +29 -0
- package/dist/core/common/middleware/request-context.middleware.js.map +1 -0
- package/dist/core/common/pipes/map-and-validate.pipe.js +2 -2
- package/dist/core/common/pipes/map-and-validate.pipe.js.map +1 -1
- package/dist/core/common/plugins/complexity.plugin.d.ts +2 -2
- package/dist/core/common/plugins/mongoose-audit-fields.plugin.d.ts +1 -0
- package/dist/core/common/plugins/mongoose-audit-fields.plugin.js +51 -0
- package/dist/core/common/plugins/mongoose-audit-fields.plugin.js.map +1 -0
- package/dist/core/common/plugins/mongoose-password.plugin.d.ts +4 -0
- package/dist/core/common/plugins/mongoose-password.plugin.js +69 -0
- package/dist/core/common/plugins/mongoose-password.plugin.js.map +1 -0
- package/dist/core/common/plugins/mongoose-role-guard.plugin.d.ts +1 -0
- package/dist/core/common/plugins/mongoose-role-guard.plugin.js +80 -0
- package/dist/core/common/plugins/mongoose-role-guard.plugin.js.map +1 -0
- package/dist/core/common/services/config.service.js +2 -2
- package/dist/core/common/services/config.service.js.map +1 -1
- package/dist/core/common/services/model-registry.service.d.ts +8 -0
- package/dist/core/common/services/model-registry.service.js +20 -0
- package/dist/core/common/services/model-registry.service.js.map +1 -0
- package/dist/core/common/services/module.service.d.ts +2 -0
- package/dist/core/common/services/module.service.js +36 -1
- package/dist/core/common/services/module.service.js.map +1 -1
- package/dist/core/common/services/request-context.service.d.ts +18 -0
- package/dist/core/common/services/request-context.service.js +32 -0
- package/dist/core/common/services/request-context.service.js.map +1 -0
- package/dist/core/modules/auth/guards/auth.guard.js +2 -2
- package/dist/core/modules/auth/guards/auth.guard.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.resolver.js +2 -2
- package/dist/core/modules/better-auth/core-better-auth.resolver.js.map +1 -1
- package/dist/core.module.js +36 -0
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/server/modules/file/file-info.model.d.ts +12 -12
- package/dist/server/modules/user/user.model.d.ts +33 -33
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +35 -30
- package/src/config.env.ts +2 -2
- package/src/core/common/decorators/response-model.decorator.ts +31 -0
- package/src/core/common/helpers/db.helper.ts +2 -2
- package/src/core/common/helpers/filter.helper.ts +3 -3
- package/src/core/common/helpers/input.helper.ts +2 -2
- package/src/core/common/helpers/interceptor.helper.ts +132 -0
- package/src/core/common/helpers/service.helper.ts +1 -1
- package/src/core/common/interceptors/check-security.interceptor.ts +44 -1
- package/src/core/common/interceptors/response-model.interceptor.ts +135 -0
- package/src/core/common/interceptors/translate-response.interceptor.ts +104 -0
- package/src/core/common/interfaces/server-options.interface.ts +160 -0
- package/src/core/common/middleware/request-context.middleware.ts +25 -0
- package/src/core/common/pipes/map-and-validate.pipe.ts +2 -2
- package/src/core/common/plugins/complexity.plugin.ts +2 -2
- package/src/core/common/plugins/mongoose-audit-fields.plugin.ts +74 -0
- package/src/core/common/plugins/mongoose-password.plugin.ts +100 -0
- package/src/core/common/plugins/mongoose-role-guard.plugin.ts +150 -0
- package/src/core/common/services/config.service.ts +2 -2
- package/src/core/common/services/model-registry.service.ts +25 -0
- package/src/core/common/services/module.service.ts +91 -1
- package/src/core/common/services/request-context.service.ts +69 -0
- package/src/core/modules/auth/guards/auth.guard.ts +2 -2
- package/src/core/modules/better-auth/core-better-auth.resolver.ts +2 -2
- package/src/core.module.ts +55 -4
- package/src/index.ts +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/nest-server",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.18.0",
|
|
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",
|
|
@@ -75,33 +75,33 @@
|
|
|
75
75
|
"dependencies": {
|
|
76
76
|
"@apollo/server": "5.4.0",
|
|
77
77
|
"@as-integrations/express5": "1.1.2",
|
|
78
|
-
"@better-auth/passkey": "1.4
|
|
78
|
+
"@better-auth/passkey": "1.5.4",
|
|
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.16",
|
|
82
|
+
"@nestjs/core": "11.1.16",
|
|
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.16",
|
|
88
88
|
"@nestjs/schedule": "6.1.1",
|
|
89
89
|
"@nestjs/swagger": "11.2.6",
|
|
90
|
-
"@nestjs/terminus": "11.
|
|
91
|
-
"@nestjs/websockets": "11.1.
|
|
90
|
+
"@nestjs/terminus": "11.1.1",
|
|
91
|
+
"@nestjs/websockets": "11.1.16",
|
|
92
92
|
"@tus/file-store": "2.0.0",
|
|
93
93
|
"@tus/server": "2.3.0",
|
|
94
|
-
|
|
94
|
+
|
|
95
95
|
"bcrypt": "6.0.0",
|
|
96
|
-
"better-auth": "1.4
|
|
96
|
+
"better-auth": "1.5.4",
|
|
97
97
|
"class-transformer": "0.5.1",
|
|
98
98
|
"class-validator": "0.14.3",
|
|
99
99
|
"compression": "1.8.1",
|
|
100
100
|
"cookie-parser": "1.4.7",
|
|
101
|
-
"dotenv": "17.
|
|
102
|
-
"ejs": "
|
|
101
|
+
"dotenv": "17.3.1",
|
|
102
|
+
"ejs": "5.0.1",
|
|
103
103
|
"express": "5.2.1",
|
|
104
|
-
"graphql": "16.
|
|
104
|
+
"graphql": "16.13.1",
|
|
105
105
|
"graphql-query-complexity": "1.1.0",
|
|
106
106
|
"graphql-subscriptions": "3.0.0",
|
|
107
107
|
"graphql-upload": "15.0.2",
|
|
@@ -109,8 +109,8 @@
|
|
|
109
109
|
"json-to-graphql-query": "2.3.0",
|
|
110
110
|
"lodash": "4.17.23",
|
|
111
111
|
"mongodb": "7.0.0",
|
|
112
|
-
"mongoose": "9.
|
|
113
|
-
"multer": "2.
|
|
112
|
+
"mongoose": "9.2.4",
|
|
113
|
+
"multer": "2.1.1",
|
|
114
114
|
"node-mailjet": "6.0.11",
|
|
115
115
|
"nodemailer": "8.0.1",
|
|
116
116
|
"passport": "0.7.0",
|
|
@@ -124,32 +124,31 @@
|
|
|
124
124
|
"@compodoc/compodoc": "1.2.1",
|
|
125
125
|
"@nestjs/cli": "11.0.16",
|
|
126
126
|
"@nestjs/schematics": "11.0.9",
|
|
127
|
-
"@nestjs/testing": "11.1.
|
|
128
|
-
"@swc/cli": "0.
|
|
129
|
-
"@swc/core": "1.15.
|
|
127
|
+
"@nestjs/testing": "11.1.16",
|
|
128
|
+
"@swc/cli": "0.8.0",
|
|
129
|
+
"@swc/core": "1.15.18",
|
|
130
130
|
"@types/compression": "1.8.1",
|
|
131
131
|
"@types/cookie-parser": "1.4.10",
|
|
132
132
|
"@types/ejs": "3.1.5",
|
|
133
|
-
"@types/express": "
|
|
134
|
-
"@types/lodash": "4.17.
|
|
135
|
-
"@types/multer": "2.
|
|
136
|
-
"@types/node": "25.
|
|
137
|
-
"@types/nodemailer": "7.0.
|
|
133
|
+
"@types/express": "5.0.6",
|
|
134
|
+
"@types/lodash": "4.17.24",
|
|
135
|
+
"@types/multer": "2.1.0",
|
|
136
|
+
"@types/node": "25.3.5",
|
|
137
|
+
"@types/nodemailer": "7.0.11",
|
|
138
138
|
"@types/passport": "1.0.17",
|
|
139
|
-
"@types/supertest": "
|
|
139
|
+
"@types/supertest": "7.2.0",
|
|
140
140
|
"@vitest/coverage-v8": "4.0.18",
|
|
141
141
|
"@vitest/ui": "4.0.18",
|
|
142
142
|
"ansi-colors": "4.1.3",
|
|
143
143
|
"find-file-up": "2.0.1",
|
|
144
144
|
"husky": "9.1.7",
|
|
145
|
-
"nodemon": "3.1.
|
|
145
|
+
"nodemon": "3.1.14",
|
|
146
146
|
"npm-watch": "0.13.0",
|
|
147
147
|
"otpauth": "9.5.0",
|
|
148
|
-
"oxfmt": "0.
|
|
149
|
-
"oxlint": "1.
|
|
150
|
-
"rimraf": "6.1.
|
|
148
|
+
"oxfmt": "0.36.0",
|
|
149
|
+
"oxlint": "1.51.0",
|
|
150
|
+
"rimraf": "6.1.3",
|
|
151
151
|
"supertest": "7.2.2",
|
|
152
|
-
"ts-loader": "9.5.4",
|
|
153
152
|
"ts-morph": "27.0.2",
|
|
154
153
|
"ts-node": "10.9.2",
|
|
155
154
|
"tsconfig-paths": "4.2.0",
|
|
@@ -158,7 +157,7 @@
|
|
|
158
157
|
"unplugin-swc": "1.5.9",
|
|
159
158
|
"vite": "7.3.1",
|
|
160
159
|
"vite-plugin-node": "7.0.0",
|
|
161
|
-
"vite-tsconfig-paths": "6.1.
|
|
160
|
+
"vite-tsconfig-paths": "6.1.1",
|
|
162
161
|
"vitest": "4.0.18"
|
|
163
162
|
},
|
|
164
163
|
"main": "dist/index.js",
|
|
@@ -178,7 +177,13 @@
|
|
|
178
177
|
"packageManager": "pnpm@10.29.2",
|
|
179
178
|
"pnpm": {
|
|
180
179
|
"overrides": {
|
|
181
|
-
"
|
|
180
|
+
"minimatch@<3.1.4": "3.1.4",
|
|
181
|
+
"minimatch@>=9.0.0 <9.0.7": "9.0.7",
|
|
182
|
+
"minimatch@>=10.0.0 <10.2.3": "10.2.4",
|
|
183
|
+
"rollup@>=4.0.0 <4.59.0": "4.59.0",
|
|
184
|
+
"serialize-javascript@<=7.0.2": "7.0.4",
|
|
185
|
+
"ajv@<6.14.0": "6.14.0",
|
|
186
|
+
"ajv@>=7.0.0-alpha.0 <8.18.0": "8.18.0"
|
|
182
187
|
},
|
|
183
188
|
"onlyBuiltDependencies": [
|
|
184
189
|
"bcrypt",
|
package/src/config.env.ts
CHANGED
|
@@ -123,7 +123,7 @@ const config: { [env: string]: IServerOptions } = {
|
|
|
123
123
|
collation: {
|
|
124
124
|
locale: 'de',
|
|
125
125
|
},
|
|
126
|
-
modelDocumentation:
|
|
126
|
+
modelDocumentation: false,
|
|
127
127
|
uri: 'mongodb://127.0.0.1/nest-server-ci',
|
|
128
128
|
},
|
|
129
129
|
permissions: true,
|
|
@@ -369,7 +369,7 @@ const config: { [env: string]: IServerOptions } = {
|
|
|
369
369
|
collation: {
|
|
370
370
|
locale: 'de',
|
|
371
371
|
},
|
|
372
|
-
modelDocumentation:
|
|
372
|
+
modelDocumentation: false,
|
|
373
373
|
uri: 'mongodb://127.0.0.1/nest-server-e2e',
|
|
374
374
|
},
|
|
375
375
|
permissions: true,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { SetMetadata } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
import { CoreModel } from '../models/core-model.model';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Metadata key used to store the explicit response model class on a handler.
|
|
7
|
+
* Shared between the @ResponseModel() decorator and interceptor.helper.ts.
|
|
8
|
+
*/
|
|
9
|
+
export const RESPONSE_MODEL_KEY = 'response_model_class';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Decorator to explicitly specify the model class for automatic response conversion.
|
|
13
|
+
*
|
|
14
|
+
* In most cases this is NOT needed because the type is resolved automatically:
|
|
15
|
+
* - GraphQL: from @Query/@Mutation return type metadata
|
|
16
|
+
* - REST: from @ApiOkResponse({ type: Model }) / @ApiCreatedResponse({ type: Model })
|
|
17
|
+
*
|
|
18
|
+
* Use this decorator only when automatic resolution fails or when no Swagger
|
|
19
|
+
* decorators are present on a REST endpoint.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```typescript
|
|
23
|
+
* @ResponseModel(User)
|
|
24
|
+
* @Get(':id')
|
|
25
|
+
* async getUser(@Param('id') id: string): Promise<User> {
|
|
26
|
+
* return this.userService.mainDbModel.findById(id).exec();
|
|
27
|
+
* }
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export const ResponseModel = (modelClass: new (...args: any[]) => CoreModel) =>
|
|
31
|
+
SetMetadata(RESPONSE_MODEL_KEY, modelClass);
|
|
@@ -626,8 +626,8 @@ export async function setPopulates<T = Document | Query<any, any>>(
|
|
|
626
626
|
|
|
627
627
|
// Query => Chaining
|
|
628
628
|
if (queryOrDocument instanceof Query) {
|
|
629
|
-
for (const
|
|
630
|
-
queryOrDocument = (queryOrDocument as any).populate(
|
|
629
|
+
for (const populateOption of populateOptions) {
|
|
630
|
+
queryOrDocument = (queryOrDocument as any).populate(populateOption);
|
|
631
631
|
}
|
|
632
632
|
|
|
633
633
|
// Document => Non chaining
|
|
@@ -177,7 +177,7 @@ export function generateFilterQuery<T = any>(
|
|
|
177
177
|
// Process single filter
|
|
178
178
|
if (filter.singleFilter) {
|
|
179
179
|
// Init variables
|
|
180
|
-
const { convertToObjectId, isReference, not, options } = filter.singleFilter;
|
|
180
|
+
const { convertToObjectId, isReference, not, options: filterOptions } = filter.singleFilter;
|
|
181
181
|
let field = filter.singleFilter.field;
|
|
182
182
|
let value = filter.singleFilter.value;
|
|
183
183
|
|
|
@@ -232,11 +232,11 @@ export function generateFilterQuery<T = any>(
|
|
|
232
232
|
result[field] = not
|
|
233
233
|
? {
|
|
234
234
|
$not: {
|
|
235
|
-
$options:
|
|
235
|
+
$options: filterOptions || '',
|
|
236
236
|
$regex: new RegExp(value),
|
|
237
237
|
},
|
|
238
238
|
}
|
|
239
|
-
: { $options:
|
|
239
|
+
: { $options: filterOptions || '', $regex: new RegExp(value) };
|
|
240
240
|
break;
|
|
241
241
|
}
|
|
242
242
|
}
|
|
@@ -388,9 +388,9 @@ export function clone(object: any, options?: { checkResult?: boolean; circles?:
|
|
|
388
388
|
throw new Error('Cloned object differs from original object', { cause: e });
|
|
389
389
|
}
|
|
390
390
|
return clonedWithCircles;
|
|
391
|
-
} catch (
|
|
391
|
+
} catch (innerError) {
|
|
392
392
|
if (config.debug) {
|
|
393
|
-
console.debug(
|
|
393
|
+
console.debug(innerError, 'rfcd with circles did not work => automatic use of _.clone!');
|
|
394
394
|
}
|
|
395
395
|
return _.cloneDeep(object);
|
|
396
396
|
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { ExecutionContext } from '@nestjs/common';
|
|
2
|
+
import { GqlExecutionContext } from '@nestjs/graphql';
|
|
3
|
+
|
|
4
|
+
import { RESPONSE_MODEL_KEY } from '../decorators/response-model.decorator';
|
|
5
|
+
import { CoreModel } from '../models/core-model.model';
|
|
6
|
+
|
|
7
|
+
// Cache: handler function → resolved model class (or null)
|
|
8
|
+
// Handler references are stable per route, so the cache is bounded by the number of routes.
|
|
9
|
+
const resolvedModelCache = new Map<Function, (new (...args: any[]) => CoreModel) | null>();
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolve the expected model class for a handler's response.
|
|
13
|
+
* Results are cached per handler function for zero-cost subsequent lookups.
|
|
14
|
+
*
|
|
15
|
+
* Priority:
|
|
16
|
+
* 1. Explicit @ResponseModel(ModelClass) decorator
|
|
17
|
+
* 2. GraphQL TypeMetadataStorage lookup (automatic for @Query/@Mutation)
|
|
18
|
+
* 3. Swagger @ApiOkResponse / @ApiCreatedResponse type (automatic for REST)
|
|
19
|
+
* 4. null (no auto-mapping)
|
|
20
|
+
*/
|
|
21
|
+
export function resolveResponseModelClass(context: ExecutionContext): (new (...args: any[]) => CoreModel) | null {
|
|
22
|
+
const handler = context.getHandler();
|
|
23
|
+
|
|
24
|
+
// Return cached result (hit after first request per route)
|
|
25
|
+
if (resolvedModelCache.has(handler)) {
|
|
26
|
+
return resolvedModelCache.get(handler);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const result = resolveResponseModelClassUncached(context, handler);
|
|
30
|
+
resolvedModelCache.set(handler, result);
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function resolveResponseModelClassUncached(
|
|
35
|
+
context: ExecutionContext,
|
|
36
|
+
handler: Function,
|
|
37
|
+
): (new (...args: any[]) => CoreModel) | null {
|
|
38
|
+
// 1. Explicit @ResponseModel decorator
|
|
39
|
+
const explicit = Reflect.getMetadata(RESPONSE_MODEL_KEY, handler);
|
|
40
|
+
if (explicit) {
|
|
41
|
+
return explicit;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 2. GraphQL TypeMetadataStorage lookup
|
|
45
|
+
try {
|
|
46
|
+
const gqlContext = GqlExecutionContext.create(context);
|
|
47
|
+
const info = gqlContext.getInfo?.();
|
|
48
|
+
if (info) {
|
|
49
|
+
return resolveFromGraphQlMetadata(context);
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// Not a GraphQL context
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 3. Swagger @ApiOkResponse / @ApiCreatedResponse type (for REST controllers)
|
|
56
|
+
const swaggerType = resolveFromSwaggerMetadata(handler);
|
|
57
|
+
if (swaggerType) {
|
|
58
|
+
return swaggerType;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve model class from GraphQL @Query/@Mutation metadata
|
|
66
|
+
*/
|
|
67
|
+
function resolveFromGraphQlMetadata(context: ExecutionContext): (new (...args: any[]) => CoreModel) | null {
|
|
68
|
+
try {
|
|
69
|
+
// Uses @nestjs/graphql internal path to access TypeMetadataStorage — the only way to resolve
|
|
70
|
+
// @Query/@Mutation return types at runtime. This path has been stable since @nestjs/graphql v10.
|
|
71
|
+
// Wrapped in try/catch so a path change in a future version degrades gracefully (returns null).
|
|
72
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
73
|
+
const { TypeMetadataStorage } = require('@nestjs/graphql/dist/schema-builder/storages/type-metadata.storage');
|
|
74
|
+
|
|
75
|
+
const handler = context.getHandler();
|
|
76
|
+
const target = context.getClass();
|
|
77
|
+
const methodName = handler.name;
|
|
78
|
+
|
|
79
|
+
// Search queries and mutations
|
|
80
|
+
const allMetadata = [...TypeMetadataStorage.getQueriesMetadata(), ...TypeMetadataStorage.getMutationsMetadata()];
|
|
81
|
+
|
|
82
|
+
const meta = allMetadata.find((m: any) => m.target === target && m.methodName === methodName);
|
|
83
|
+
|
|
84
|
+
if (meta?.typeFn) {
|
|
85
|
+
const resolvedType = meta.typeFn();
|
|
86
|
+
if (resolvedType && typeof resolvedType === 'function' && isCoreModelSubclass(resolvedType)) {
|
|
87
|
+
return resolvedType as new (...args: any[]) => CoreModel;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
// TypeMetadataStorage not available or other issue
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Resolve model class from Swagger @ApiOkResponse / @ApiCreatedResponse metadata.
|
|
99
|
+
* Reads the `type` from the response metadata stored by @nestjs/swagger decorators.
|
|
100
|
+
*/
|
|
101
|
+
function resolveFromSwaggerMetadata(handler: Function): (new (...args: any[]) => CoreModel) | null {
|
|
102
|
+
try {
|
|
103
|
+
const SWAGGER_API_RESPONSE_KEY = 'swagger/apiResponse';
|
|
104
|
+
const responses = Reflect.getMetadata(SWAGGER_API_RESPONSE_KEY, handler);
|
|
105
|
+
if (!responses || typeof responses !== 'object') {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Check common success status codes (200 OK, 201 Created)
|
|
110
|
+
for (const statusCode of [200, 201]) {
|
|
111
|
+
const responseMeta = responses[statusCode];
|
|
112
|
+
if (responseMeta?.type && typeof responseMeta.type === 'function' && isCoreModelSubclass(responseMeta.type)) {
|
|
113
|
+
return responseMeta.type as new (...args: any[]) => CoreModel;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
// Swagger not available or metadata not found
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isCoreModelSubclass(cls: Function): boolean {
|
|
124
|
+
let proto = cls.prototype;
|
|
125
|
+
while (proto) {
|
|
126
|
+
if (proto.constructor === CoreModel || proto.constructor?.name === 'CoreModel') {
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
proto = Object.getPrototypeOf(proto);
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
@@ -336,7 +336,7 @@ export function prepareServiceOptions(
|
|
|
336
336
|
return serviceOptions;
|
|
337
337
|
}
|
|
338
338
|
|
|
339
|
-
function applyTranslationsRecursively(obj: any, language: string, visited: WeakSet<object> = new WeakSet()) {
|
|
339
|
+
export function applyTranslationsRecursively(obj: any, language: string, visited: WeakSet<object> = new WeakSet()) {
|
|
340
340
|
if (typeof obj !== 'object' || obj === null) {
|
|
341
341
|
return;
|
|
342
342
|
}
|
|
@@ -15,6 +15,8 @@ export class CheckSecurityInterceptor implements NestInterceptor {
|
|
|
15
15
|
config = {
|
|
16
16
|
debug: false,
|
|
17
17
|
noteCheckedObjects: true,
|
|
18
|
+
removeSecretFields: true,
|
|
19
|
+
secretFields: ['password', 'verificationToken', 'passwordResetToken', 'refreshTokens', 'tempTokens'],
|
|
18
20
|
};
|
|
19
21
|
|
|
20
22
|
constructor(private readonly configService: ConfigService) {
|
|
@@ -22,6 +24,11 @@ export class CheckSecurityInterceptor implements NestInterceptor {
|
|
|
22
24
|
if (typeof configuration === 'object') {
|
|
23
25
|
this.config = { ...this.config, ...configuration };
|
|
24
26
|
}
|
|
27
|
+
// Allow overriding secretFields from security config
|
|
28
|
+
const globalSecretFields = this.configService.getFastButReadOnly('security.secretFields');
|
|
29
|
+
if (Array.isArray(globalSecretFields)) {
|
|
30
|
+
this.config.secretFields = globalSecretFields;
|
|
31
|
+
}
|
|
25
32
|
}
|
|
26
33
|
|
|
27
34
|
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
|
@@ -99,8 +106,44 @@ export class CheckSecurityInterceptor implements NestInterceptor {
|
|
|
99
106
|
);
|
|
100
107
|
};
|
|
101
108
|
|
|
109
|
+
// Fallback: Remove known secret fields regardless of model type (recursive into plain objects)
|
|
110
|
+
const isPlainLike = (val: any): boolean => {
|
|
111
|
+
if (!val || typeof val !== 'object' || Array.isArray(val)) return false;
|
|
112
|
+
// Skip Streams, Buffers, Dates, RegExps and other special objects
|
|
113
|
+
if (typeof val.pipe === 'function') return false;
|
|
114
|
+
if (Buffer.isBuffer(val)) return false;
|
|
115
|
+
if (val instanceof Date || val instanceof RegExp) return false;
|
|
116
|
+
const proto = Object.getPrototypeOf(val);
|
|
117
|
+
return proto === null || proto === Object.prototype || typeof val.constructor === 'function';
|
|
118
|
+
};
|
|
119
|
+
const removeSecrets = (data: any) => {
|
|
120
|
+
if (!this.config.removeSecretFields || !data || typeof data !== 'object') {
|
|
121
|
+
return data;
|
|
122
|
+
}
|
|
123
|
+
if (Array.isArray(data)) {
|
|
124
|
+
data.forEach(removeSecrets);
|
|
125
|
+
return data;
|
|
126
|
+
}
|
|
127
|
+
if (!isPlainLike(data)) {
|
|
128
|
+
return data;
|
|
129
|
+
}
|
|
130
|
+
for (const field of this.config.secretFields) {
|
|
131
|
+
if (field in data && data[field] !== undefined) {
|
|
132
|
+
data[field] = undefined;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Recurse into nested plain objects
|
|
136
|
+
for (const key of Object.keys(data)) {
|
|
137
|
+
const value = data[key];
|
|
138
|
+
if (value && typeof value === 'object' && !this.config.secretFields.includes(key)) {
|
|
139
|
+
removeSecrets(value);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return data;
|
|
143
|
+
};
|
|
144
|
+
|
|
102
145
|
// Check response
|
|
103
|
-
const result = next.handle().pipe(map(check));
|
|
146
|
+
const result = next.handle().pipe(map(check), map(removeSecrets));
|
|
104
147
|
if (this.config.debug && Date.now() - start >= (typeof this.config.debug === 'number' ? this.config.debug : 100)) {
|
|
105
148
|
console.warn(
|
|
106
149
|
`Duration for CheckResponseInterceptor is too long: ${Date.now() - start}ms`,
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common';
|
|
2
|
+
import { Observable } from 'rxjs';
|
|
3
|
+
import { map } from 'rxjs/operators';
|
|
4
|
+
|
|
5
|
+
import { resolveResponseModelClass } from '../helpers/interceptor.helper';
|
|
6
|
+
import { CoreModel } from '../models/core-model.model';
|
|
7
|
+
import { ConfigService } from '../services/config.service';
|
|
8
|
+
import { ModelRegistry } from '../services/model-registry.service';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Interceptor that automatically converts plain objects and Mongoose documents
|
|
12
|
+
* to CoreModel instances, enabling securityCheck() and @Restricted metadata.
|
|
13
|
+
*
|
|
14
|
+
* This is the safety net that ensures output security even when developers
|
|
15
|
+
* bypass CrudService.process() and use direct Mongoose queries.
|
|
16
|
+
*
|
|
17
|
+
* Execution order on response (NestJS runs APP_INTERCEPTOR in reverse registration order):
|
|
18
|
+
* 1. ResponseModelInterceptor (registered last → runs first on response)
|
|
19
|
+
* 2. CheckSecurityInterceptor (processes securityCheck())
|
|
20
|
+
* 3. CheckResponseInterceptor (filters @Restricted fields)
|
|
21
|
+
*/
|
|
22
|
+
@Injectable()
|
|
23
|
+
export class ResponseModelInterceptor implements NestInterceptor {
|
|
24
|
+
private readonly logger = new Logger(ResponseModelInterceptor.name);
|
|
25
|
+
private config = {
|
|
26
|
+
debug: false,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
constructor(private readonly configService: ConfigService) {
|
|
30
|
+
const configuration = this.configService.getFastButReadOnly('security.responseModelInterceptor');
|
|
31
|
+
if (typeof configuration === 'object') {
|
|
32
|
+
this.config = { ...this.config, ...configuration };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
|
37
|
+
const modelClass = resolveResponseModelClass(context);
|
|
38
|
+
|
|
39
|
+
return next.handle().pipe(
|
|
40
|
+
map((data) => {
|
|
41
|
+
if (!modelClass || data === null || data === undefined || typeof data !== 'object') {
|
|
42
|
+
return data;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Already the correct model instance
|
|
46
|
+
if (data instanceof modelClass) {
|
|
47
|
+
return data;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Already processed by another interceptor
|
|
51
|
+
if (data._objectAlreadyCheckedForRestrictions) {
|
|
52
|
+
return data;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return this.convertToModel(data, modelClass, context);
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private convertToModel(data: any, modelClass: new (...args: any[]) => CoreModel, context: ExecutionContext): any {
|
|
61
|
+
// Scalars/primitives pass through
|
|
62
|
+
if (typeof data !== 'object' || data === null) {
|
|
63
|
+
return data;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Array of items
|
|
67
|
+
if (Array.isArray(data)) {
|
|
68
|
+
return data.map((item) => this.convertSingleItem(item, modelClass, context));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Paginated wrapper objects from CrudService (e.g. { items: [...], totalCount, pagination })
|
|
72
|
+
// Identified by 'items' array + pagination markers to avoid false positives on models with 'items' field
|
|
73
|
+
if (Array.isArray(data.items) && ('totalCount' in data || 'pagination' in data)) {
|
|
74
|
+
data.items = data.items.map((item: any) => this.convertSingleItem(item, modelClass, context));
|
|
75
|
+
return data;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Single object
|
|
79
|
+
return this.convertSingleItem(data, modelClass, context);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private convertSingleItem(item: any, modelClass: new (...args: any[]) => CoreModel, context: ExecutionContext): any {
|
|
83
|
+
if (item === null || item === undefined || typeof item !== 'object') {
|
|
84
|
+
return item;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Already the correct type
|
|
88
|
+
if (item instanceof modelClass) {
|
|
89
|
+
return item;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Already processed
|
|
93
|
+
if (item._objectAlreadyCheckedForRestrictions) {
|
|
94
|
+
return item;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Convert Mongoose document to plain object first
|
|
98
|
+
const plain = typeof item.toObject === 'function' ? item.toObject() : item;
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const mapped = (modelClass as any).map(plain);
|
|
102
|
+
if (this.config.debug) {
|
|
103
|
+
const className = context.getClass()?.name;
|
|
104
|
+
const methodName = context.getHandler()?.name;
|
|
105
|
+
this.logger.warn(
|
|
106
|
+
`Auto-converted plain object to ${modelClass.name} in ${className}.${methodName}. Consider using CrudService methods.`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
return mapped;
|
|
110
|
+
} catch {
|
|
111
|
+
// If mapping fails, try resolving via ModelRegistry using the Mongoose model name
|
|
112
|
+
return this.tryRegistryFallback(plain) || item;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private tryRegistryFallback(item: any): any {
|
|
117
|
+
// Mongoose documents have a collection property with modelName
|
|
118
|
+
const modelName = item?.constructor?.modelName || item?.collection?.modelName;
|
|
119
|
+
if (!modelName) {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const registeredClass = ModelRegistry.getModelClass(modelName);
|
|
124
|
+
if (!registeredClass) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const plain = typeof item.toObject === 'function' ? item.toObject() : item;
|
|
130
|
+
return (registeredClass as any).map(plain);
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -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
|
+
}
|