@lenne.tech/nest-server 11.34.0 → 11.35.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 (151) hide show
  1. package/.claude/rules/architecture.md +3 -1
  2. package/.claude/rules/configurable-features.md +3 -3
  3. package/.claude/rules/role-system.md +281 -0
  4. package/CLAUDE.md +11 -1
  5. package/FRAMEWORK-API.md +7 -3
  6. package/dist/core/common/decorators/nested-type.registry.d.ts +2 -0
  7. package/dist/core/common/decorators/nested-type.registry.js +17 -0
  8. package/dist/core/common/decorators/nested-type.registry.js.map +1 -0
  9. package/dist/core/common/decorators/restricted.decorator.d.ts +2 -1
  10. package/dist/core/common/decorators/restricted.decorator.js +52 -16
  11. package/dist/core/common/decorators/restricted.decorator.js.map +1 -1
  12. package/dist/core/common/decorators/restrictions-checked.marker.d.ts +4 -0
  13. package/dist/core/common/decorators/restrictions-checked.marker.js +30 -0
  14. package/dist/core/common/decorators/restrictions-checked.marker.js.map +1 -0
  15. package/dist/core/common/decorators/unified-field.decorator.d.ts +1 -1
  16. package/dist/core/common/decorators/unified-field.decorator.js +6 -3
  17. package/dist/core/common/decorators/unified-field.decorator.js.map +1 -1
  18. package/dist/core/common/enums/role.enum.d.ts +8 -0
  19. package/dist/core/common/enums/role.enum.js +28 -1
  20. package/dist/core/common/enums/role.enum.js.map +1 -1
  21. package/dist/core/common/helpers/execution-context-request.helper.d.ts +7 -0
  22. package/dist/core/common/helpers/execution-context-request.helper.js +31 -0
  23. package/dist/core/common/helpers/execution-context-request.helper.js.map +1 -0
  24. package/dist/core/common/helpers/graphql-ws-context.helper.d.ts +15 -0
  25. package/dist/core/common/helpers/graphql-ws-context.helper.js +96 -0
  26. package/dist/core/common/helpers/graphql-ws-context.helper.js.map +1 -0
  27. package/dist/core/common/helpers/gridfs.helper.js +1 -1
  28. package/dist/core/common/helpers/gridfs.helper.js.map +1 -1
  29. package/dist/core/common/helpers/validation-message.helper.d.ts +3 -0
  30. package/dist/core/common/helpers/validation-message.helper.js +41 -0
  31. package/dist/core/common/helpers/validation-message.helper.js.map +1 -0
  32. package/dist/core/common/interceptors/check-security.interceptor.js +2 -1
  33. package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
  34. package/dist/core/common/interceptors/response-model.interceptor.js +3 -2
  35. package/dist/core/common/interceptors/response-model.interceptor.js.map +1 -1
  36. package/dist/core/common/interfaces/server-options.interface.d.ts +5 -0
  37. package/dist/core/common/pipes/map-and-validate.pipe.js +16 -6
  38. package/dist/core/common/pipes/map-and-validate.pipe.js.map +1 -1
  39. package/dist/core/common/plugins/mongoose-system-role.plugin.d.ts +1 -0
  40. package/dist/core/common/plugins/mongoose-system-role.plugin.js +106 -0
  41. package/dist/core/common/plugins/mongoose-system-role.plugin.js.map +1 -0
  42. package/dist/core/common/plugins/mongoose-tenant.plugin.js +134 -9
  43. package/dist/core/common/plugins/mongoose-tenant.plugin.js.map +1 -1
  44. package/dist/core/common/services/core-s3.service.js +4 -1
  45. package/dist/core/common/services/core-s3.service.js.map +1 -1
  46. package/dist/core/common/services/core-tenant-context.registry.d.ts +10 -0
  47. package/dist/core/common/services/core-tenant-context.registry.js +12 -0
  48. package/dist/core/common/services/core-tenant-context.registry.js.map +1 -0
  49. package/dist/core/modules/auth/guards/roles.guard.d.ts +1 -1
  50. package/dist/core/modules/auth/guards/roles.guard.js +2 -2
  51. package/dist/core/modules/auth/guards/roles.guard.js.map +1 -1
  52. package/dist/core/modules/better-auth/better-auth-roles.guard.js +2 -10
  53. package/dist/core/modules/better-auth/better-auth-roles.guard.js.map +1 -1
  54. package/dist/core/modules/file/core-file.service.d.ts +3 -1
  55. package/dist/core/modules/file/core-file.service.js +62 -7
  56. package/dist/core/modules/file/core-file.service.js.map +1 -1
  57. package/dist/core/modules/file/file-access.helper.d.ts +18 -0
  58. package/dist/core/modules/file/file-access.helper.js +46 -0
  59. package/dist/core/modules/file/file-access.helper.js.map +1 -0
  60. package/dist/core/modules/file/file-metadata.helper.js +1 -1
  61. package/dist/core/modules/file/file-metadata.helper.js.map +1 -1
  62. package/dist/core/modules/file/file-roles.config.d.ts +10 -0
  63. package/dist/core/modules/file/file-roles.config.js +78 -0
  64. package/dist/core/modules/file/file-roles.config.js.map +1 -0
  65. package/dist/core/modules/file/file-roles.helper.d.ts +2 -2
  66. package/dist/core/modules/file/file-roles.helper.js +8 -20
  67. package/dist/core/modules/file/file-roles.helper.js.map +1 -1
  68. package/dist/core/modules/tenant/core-role-scope.registry.d.ts +28 -0
  69. package/dist/core/modules/tenant/core-role-scope.registry.js +93 -0
  70. package/dist/core/modules/tenant/core-role-scope.registry.js.map +1 -0
  71. package/dist/core/modules/tenant/core-tenant.guard.d.ts +5 -0
  72. package/dist/core/modules/tenant/core-tenant.guard.js +37 -11
  73. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  74. package/dist/core/modules/tenant/core-tenant.helpers.d.ts +9 -1
  75. package/dist/core/modules/tenant/core-tenant.helpers.js +65 -4
  76. package/dist/core/modules/tenant/core-tenant.helpers.js.map +1 -1
  77. package/dist/core/modules/tenant/core-tenant.module.js +5 -0
  78. package/dist/core/modules/tenant/core-tenant.module.js.map +1 -1
  79. package/dist/core/modules/tenant/core-tenant.service.d.ts +1 -0
  80. package/dist/core/modules/tenant/core-tenant.service.js +25 -0
  81. package/dist/core/modules/tenant/core-tenant.service.js.map +1 -1
  82. package/dist/core/modules/tus/core-tus.service.d.ts +2 -0
  83. package/dist/core/modules/tus/core-tus.service.js +34 -4
  84. package/dist/core/modules/tus/core-tus.service.js.map +1 -1
  85. package/dist/core/modules/tus/index.d.ts +1 -0
  86. package/dist/core/modules/tus/index.js +1 -0
  87. package/dist/core/modules/tus/index.js.map +1 -1
  88. package/dist/core/modules/tus/tus.constants.d.ts +1 -0
  89. package/dist/core/modules/tus/tus.constants.js +2 -1
  90. package/dist/core/modules/tus/tus.constants.js.map +1 -1
  91. package/dist/core/modules/user/core-user.service.js +5 -0
  92. package/dist/core/modules/user/core-user.service.js.map +1 -1
  93. package/dist/core/modules/user/inputs/core-user.input.js +8 -0
  94. package/dist/core/modules/user/inputs/core-user.input.js.map +1 -1
  95. package/dist/core.module.js +10 -0
  96. package/dist/core.module.js.map +1 -1
  97. package/dist/index.d.ts +3 -0
  98. package/dist/index.js +3 -0
  99. package/dist/index.js.map +1 -1
  100. package/dist/server/modules/file/file.service.js +4 -1
  101. package/dist/server/modules/file/file.service.js.map +1 -1
  102. package/dist/test/test.helper.d.ts +1 -0
  103. package/dist/test/test.helper.js +26 -6
  104. package/dist/test/test.helper.js.map +1 -1
  105. package/dist/tsconfig.build.tsbuildinfo +1 -1
  106. package/docs/REQUEST-LIFECYCLE.md +60 -1
  107. package/migration-guides/11.34.0-to-11.34.1.md +132 -0
  108. package/migration-guides/11.34.x-to-11.35.x.md +832 -0
  109. package/package.json +1 -1
  110. package/src/core/common/decorators/nested-type.registry.ts +42 -0
  111. package/src/core/common/decorators/restricted.decorator.ts +149 -21
  112. package/src/core/common/decorators/restrictions-checked.marker.ts +87 -0
  113. package/src/core/common/decorators/unified-field.decorator.ts +4 -3
  114. package/src/core/common/enums/role.enum.ts +116 -0
  115. package/src/core/common/helpers/execution-context-request.helper.ts +102 -0
  116. package/src/core/common/helpers/graphql-ws-context.helper.ts +192 -0
  117. package/src/core/common/helpers/gridfs.helper.ts +18 -2
  118. package/src/core/common/helpers/validation-message.helper.ts +83 -0
  119. package/src/core/common/interceptors/check-security.interceptor.ts +4 -1
  120. package/src/core/common/interceptors/response-model.interceptor.ts +3 -2
  121. package/src/core/common/interfaces/server-options.interface.ts +139 -7
  122. package/src/core/common/pipes/map-and-validate.pipe.ts +25 -7
  123. package/src/core/common/plugins/mongoose-system-role.plugin.ts +193 -0
  124. package/src/core/common/plugins/mongoose-tenant.plugin.ts +244 -12
  125. package/src/core/common/services/core-s3.service.ts +9 -1
  126. package/src/core/common/services/core-tenant-context.registry.ts +50 -0
  127. package/src/core/modules/auth/guards/roles.guard.ts +7 -5
  128. package/src/core/modules/better-auth/better-auth-roles.guard.ts +7 -13
  129. package/src/core/modules/file/INTEGRATION-CHECKLIST.md +95 -1
  130. package/src/core/modules/file/README.md +61 -0
  131. package/src/core/modules/file/core-file.service.ts +166 -9
  132. package/src/core/modules/file/file-access.helper.ts +149 -0
  133. package/src/core/modules/file/file-metadata.helper.ts +13 -5
  134. package/src/core/modules/file/file-roles.config.ts +202 -0
  135. package/src/core/modules/file/file-roles.helper.ts +11 -40
  136. package/src/core/modules/tenant/README.md +20 -1
  137. package/src/core/modules/tenant/core-role-scope.registry.ts +195 -0
  138. package/src/core/modules/tenant/core-tenant.guard.ts +92 -12
  139. package/src/core/modules/tenant/core-tenant.helpers.ts +139 -7
  140. package/src/core/modules/tenant/core-tenant.module.ts +12 -0
  141. package/src/core/modules/tenant/core-tenant.service.ts +56 -0
  142. package/src/core/modules/tus/README.md +46 -7
  143. package/src/core/modules/tus/core-tus.service.ts +94 -5
  144. package/src/core/modules/tus/index.ts +1 -0
  145. package/src/core/modules/tus/tus.constants.ts +17 -1
  146. package/src/core/modules/user/core-user.service.ts +12 -0
  147. package/src/core/modules/user/inputs/core-user.input.ts +28 -2
  148. package/src/core.module.ts +62 -1
  149. package/src/index.ts +3 -0
  150. package/src/server/modules/file/file.service.ts +45 -2
  151. package/src/test/test.helper.ts +54 -11
@@ -0,0 +1,192 @@
1
+ // `require`, NOT an ESM import: `@nestjs/graphql` builds the schema with the instance Node's CJS
2
+ // cache holds, and graphql REFUSES a schema "from another module or realm". An ESM import can resolve
3
+ // to a second copy of the module (a bundler's interop namespace, or a duplicated install), and the
4
+ // wrapper would then reject the very schema it was handed. The repo already uses this form for
5
+ // `graphql-upload` and `lodash` for the same reason.
6
+ import graphql = require('graphql');
7
+
8
+ import { getTenantContextResolver } from '../services/core-tenant-context.registry';
9
+ import { ConfigService } from '../services/config.service';
10
+ import { IRequestContext, RequestContext } from '../services/request-context.service';
11
+
12
+ /**
13
+ * A `RequestContext` for GraphQL operations that arrive over a WEBSOCKET.
14
+ *
15
+ * WHY THIS EXISTS — the gap it closes is a silent, complete tenant bypass:
16
+ *
17
+ * `RequestContextMiddleware` is Express middleware (`consumer.apply(...).forRoutes('*')`). A
18
+ * WebSocket UPGRADE is not a `request` event, so it traverses no middleware — and `CoreTenantGuard`
19
+ * cannot help either, because its `getRequest()` looks for `context.req`, which a subscription
20
+ * context (the graphql-ws `extra` object) does not have, so the guard returns `true` without
21
+ * deciding anything.
22
+ *
23
+ * The consequence was invisible from the query itself. `mongooseTenantPlugin.shouldBypass()` reads
24
+ * "no RequestContext" as "system operation, no filter" — correct for a cron job or a migration, and
25
+ * exactly wrong here. So a tenant-scoped read performed while delivering a subscription message
26
+ * returned EVERY tenant's rows, and the plugin's own safety net (which throws when a tenant-scoped
27
+ * schema is touched without a tenant) never fired, because there was no context to notice.
28
+ *
29
+ * WHERE IT HOOKS IN: `GqlSubscriptionService` reads `execute` and `subscribe` off the GraphQL module
30
+ * options and hands them to BOTH WebSocket transports (`graphql-ws` and `subscriptions-transport-ws`),
31
+ * defaulting to graphql-js's own. Those two functions are the only place where the whole WS operation
32
+ * — including every field resolver and the `resolve`/`filter` callbacks of a subscription — is
33
+ * reachable as one call. HTTP does not go through them (Apollo runs its own pipeline), so wrapping
34
+ * them touches the WS path only.
35
+ *
36
+ * THE ASYNC-ITERATOR PART IS LOAD-BEARING. `subscribe()` returns an AsyncIterator, and graphql-js
37
+ * runs the per-event execution — `resolve`, `filter`, every field resolver — inside that iterator's
38
+ * `next()`, which the transport pulls LONG AFTER `subscribe()` returned. Establishing the context
39
+ * only around the `subscribe()` call therefore covers the initial subscribe and nothing else: the
40
+ * store is gone by the time the first message is delivered. Wrapping `next()` (and `return`/`throw`)
41
+ * is what puts every delivered message inside the context.
42
+ */
43
+
44
+ /** Minimal shape of what graphql-js `execute`/`subscribe` receive. */
45
+ interface GraphQlArgs {
46
+ contextValue?: any;
47
+ [key: string]: any;
48
+ }
49
+
50
+ type ExecuteFn = (args: GraphQlArgs) => any;
51
+
52
+ /**
53
+ * Read the tenant header out of whatever the transport left on the context.
54
+ *
55
+ * Both WS transports are covered, and the reason there are three sources is that they genuinely
56
+ * differ: `graphql-ws` gets `extra.headers` from `CoreModule`'s `onConnect` (the client's
57
+ * `connectionParams`), `subscriptions-transport-ws` returns `{ headers }` from its own `onConnect`,
58
+ * and the raw upgrade request is the fallback for a project with a custom `onConnect` that forwards
59
+ * neither. Header names are matched case-insensitively — `connectionParams` are client-supplied JSON
60
+ * and arrive with whatever casing the client used, unlike Node's lower-cased HTTP headers.
61
+ */
62
+ function readTenantHeader(contextValue: any): string | undefined {
63
+ const headerName = (ConfigService.configFastButReadOnly?.multiTenancy?.headerName ?? 'x-tenant-id').toLowerCase();
64
+ const sources = [contextValue?.headers, contextValue?.connectionParams, contextValue?.request?.headers];
65
+ for (const source of sources) {
66
+ if (!source || typeof source !== 'object') {
67
+ continue;
68
+ }
69
+ for (const [key, value] of Object.entries(source as Record<string, unknown>)) {
70
+ if (key.toLowerCase() === headerName && typeof value === 'string' && value) {
71
+ return value;
72
+ }
73
+ }
74
+ }
75
+ return undefined;
76
+ }
77
+
78
+ /** The user the connection authenticated as, as `CoreModule`'s `onConnect` recorded it. */
79
+ function readUser(contextValue: any): any {
80
+ return contextValue?.user ?? contextValue?.extra?.user ?? undefined;
81
+ }
82
+
83
+ /**
84
+ * Build the context for one WebSocket operation.
85
+ *
86
+ * Returns `undefined` when the operation is NOT a WebSocket one (an HTTP context carries `req`, and
87
+ * the middleware has already established a context for it) — wrapping that again would replace a
88
+ * live, lazily-evaluated context with a snapshot.
89
+ */
90
+ export async function buildWsRequestContext(contextValue: any): Promise<IRequestContext | undefined> {
91
+ if (!contextValue || typeof contextValue !== 'object' || contextValue.req) {
92
+ return undefined;
93
+ }
94
+
95
+ const user = readUser(contextValue);
96
+ const context: IRequestContext = { currentUser: user };
97
+
98
+ // Language: same source as the HTTP path, when the transport carried it.
99
+ const acceptLanguage =
100
+ contextValue.headers?.['accept-language'] ??
101
+ contextValue.connectionParams?.['accept-language'] ??
102
+ contextValue.request?.headers?.['accept-language'];
103
+ if (typeof acceptLanguage === 'string') {
104
+ context.language = acceptLanguage;
105
+ }
106
+
107
+ const resolver = getTenantContextResolver();
108
+ if (resolver) {
109
+ // A failure here must NOT be answered with an unscoped context — that is the leak this file
110
+ // exists to close. Leaving the tenant fields unset makes the plugin's safety net refuse
111
+ // tenant-scoped reads, which is the safe direction.
112
+ try {
113
+ Object.assign(context, await resolver.resolve(user, readTenantHeader(contextValue)));
114
+ } catch {
115
+ // Intentionally swallowed: see above. Non-tenant operations keep working.
116
+ }
117
+ }
118
+
119
+ return context;
120
+ }
121
+
122
+ /**
123
+ * Run an AsyncIterator's pulls inside `context`.
124
+ *
125
+ * `next()` is where graphql-js executes the per-event selection set, so this is what carries the
126
+ * context into every delivered message rather than only into the initial subscribe.
127
+ */
128
+ export function withRequestContextAsyncIterator<T>(
129
+ iterator: AsyncIterator<T> & { [Symbol.asyncIterator]?: () => AsyncIterator<T> },
130
+ context: IRequestContext,
131
+ ): AsyncIterableIterator<T> {
132
+ const wrapped: AsyncIterableIterator<T> = {
133
+ next: (...args: [] | [undefined]) => RequestContext.run(context, () => iterator.next(...(args as []))),
134
+ [Symbol.asyncIterator]() {
135
+ return wrapped;
136
+ },
137
+ };
138
+ if (iterator.return) {
139
+ wrapped.return = (value?: any) => RequestContext.run(context, () => iterator.return!(value));
140
+ }
141
+ if (iterator.throw) {
142
+ wrapped.throw = (error?: any) => RequestContext.run(context, () => iterator.throw!(error));
143
+ }
144
+ return wrapped;
145
+ }
146
+
147
+ /** Is this an AsyncIterator (a subscription source) rather than a plain execution result? */
148
+ function isAsyncIterable(value: any): boolean {
149
+ return !!value && typeof value === 'object' && typeof value[Symbol.asyncIterator] === 'function';
150
+ }
151
+
152
+ /**
153
+ * Wrap graphql-js `execute` so a query/mutation sent over the WebSocket runs in a request context.
154
+ *
155
+ * Not only subscriptions: `graphql-ws` carries queries and mutations too, and those had the same gap.
156
+ */
157
+ export function createRequestContextAwareExecute(execute: ExecuteFn): ExecuteFn {
158
+ return async (args: GraphQlArgs) => {
159
+ const context = await buildWsRequestContext(args?.contextValue);
160
+ if (!context) {
161
+ return execute(args);
162
+ }
163
+ return RequestContext.run(context, () => execute(args));
164
+ };
165
+ }
166
+
167
+ /** Wrap graphql-js `subscribe` so both the subscribe AND every delivered message run in a context. */
168
+ export function createRequestContextAwareSubscribe(subscribe: ExecuteFn): ExecuteFn {
169
+ return async (args: GraphQlArgs) => {
170
+ const context = await buildWsRequestContext(args?.contextValue);
171
+ if (!context) {
172
+ return subscribe(args);
173
+ }
174
+ const result = await RequestContext.run(context, () => subscribe(args));
175
+ return isAsyncIterable(result) ? withRequestContextAsyncIterator(result as AsyncIterator<any>, context) : result;
176
+ };
177
+ }
178
+
179
+ /**
180
+ * The pair `CoreModule` installs — graphql's own `execute` / `subscribe`, wrapped.
181
+ *
182
+ * Zero-arg factories rather than pre-built constants so nothing is captured at module-evaluation
183
+ * time, and so `core.module.ts` needs no graphql import of its own (see the `require` note at the top
184
+ * of this file for why that import form matters).
185
+ */
186
+ export function buildRequestContextAwareExecute(): (args: GraphQlArgs) => any {
187
+ return createRequestContextAwareExecute(graphql.execute as any);
188
+ }
189
+
190
+ export function buildRequestContextAwareSubscribe(): (args: GraphQlArgs) => any {
191
+ return createRequestContextAwareSubscribe(graphql.subscribe as any);
192
+ }
@@ -198,10 +198,26 @@ export class GridFSHelper {
198
198
  }
199
199
 
200
200
  /**
201
- * Find file metadata by filename
201
+ * Find file metadata by filename — the MOST RECENT file with that name.
202
+ *
203
+ * The sort is load-bearing, not cosmetic. `bucket.find({ filename })` returns natural order,
204
+ * which in practice is the OLDEST document first, while `openDownloadStreamByName()` defaults to
205
+ * `revision: -1` — the NEWEST. Filenames are unique in no store and are client-supplied, so with
206
+ * two files of one name this method answered one document and the download served another: an
207
+ * ownership rule built on `getRawFileInfoByName()` approved the caller's own file and the stream
208
+ * handed over the other one.
209
+ *
210
+ * Newest-first (rather than oldest-first) because it is the BYTES semantics that must not move:
211
+ * GridFS treats a re-upload under the same name as a new revision and serves the latest, so
212
+ * aligning the metadata lookup with that keeps every existing download answering the same bytes
213
+ * and only corrects the document describing them. `_id` breaks the tie, so two files written in
214
+ * the same millisecond still order totally.
202
215
  */
203
216
  static async findFileByName(bucket: GridFSBucket, filename: string): Promise<GridFSFileInfo | null> {
204
- const files = await bucket.find({ filename }).toArray();
217
+ // `uploadDate` FIRST: MongoDB applies sort keys in document order, so leading with `_id` would
218
+ // make the tie-break the primary key and could disagree with the driver's own by-name revision
219
+ // order (which sorts on `uploadDate` alone).
220
+ const files = await bucket.find({ filename }, { limit: 1, sort: { uploadDate: -1, _id: -1 } }).toArray();
205
221
  return files.length > 0 ? GridFSHelper.normalizeFileInfo(files[0]) : null;
206
222
  }
207
223
 
@@ -0,0 +1,83 @@
1
+ import { ValidationArguments } from 'class-validator';
2
+
3
+ /**
4
+ * Renders a single constraint for use in an error message.
5
+ *
6
+ * Mirror of class-validator's internal `constraintToString`
7
+ * (`class-validator/cjs/validation/ValidationUtils`), which is NOT part of its public API.
8
+ *
9
+ * See {@link replaceMessageSpecialTokens} for why this lives here and what holds it in sync.
10
+ */
11
+ export function constraintToString(constraint: unknown): string {
12
+ if (Array.isArray(constraint)) {
13
+ return constraint.join(', ');
14
+ }
15
+ if (typeof constraint === 'symbol') {
16
+ constraint = constraint.description;
17
+ }
18
+ return `${constraint}`;
19
+ }
20
+
21
+ /**
22
+ * Resolves a message (string or function) and interpolates the special tokens
23
+ * $constraint1..N, $value, $property and $target.
24
+ *
25
+ * Mirror of class-validator's internal `ValidationUtils.replaceMessageSpecialTokens`
26
+ * (`class-validator/cjs/validation/ValidationUtils`), which is NOT part of its public API.
27
+ * `MapAndValidatePipe` re-implements the validation executor and therefore has to render messages
28
+ * exactly the way class-validator's own `validate()` would — otherwise the same decorator produces
29
+ * two different strings depending on which of the two ran.
30
+ *
31
+ * WHY IT IS NOT BARREL-EXPORTED
32
+ * Deliberately absent from `src/index.ts`. It exists to reproduce a dependency's internal
33
+ * behaviour, so it has to stay free to follow that dependency; exporting it would owe consumers
34
+ * backward compatibility for a surface we do not control. Vendor-mode consumers resolve their own
35
+ * class-validator, so the copy in `src/core/` can meet a version this repo never installed —
36
+ * `tests/unit/validation-message-mirror.spec.ts` compares it against the INSTALLED implementation
37
+ * over a fixture table so a divergence surfaces as a failing test rather than as two renderers of
38
+ * one contract.
39
+ *
40
+ * SECURITY — `$value` echoes the SUBMITTED value back to the client, and validation errors are
41
+ * never scrubbed: `security.secretFields` is applied by `CheckSecurityInterceptor` on the RESPONSE
42
+ * path, whereas the `BadRequestException` raised from the pipe goes to the exception filter and is
43
+ * forwarded verbatim. The check below is a TYPE guard (boolean | number | string), not a secrecy
44
+ * guard — it happily admits a password or a token. No built-in message uses `$value`, so reaching
45
+ * it is opt-in: never put `$value` in a custom message on a sensitive field.
46
+ */
47
+ export function replaceMessageSpecialTokens(
48
+ message: ((args: ValidationArguments) => string) | string,
49
+ validationArguments: ValidationArguments,
50
+ ): string {
51
+ let messageString = '';
52
+ if (typeof message === 'function') {
53
+ messageString = message(validationArguments);
54
+ } else if (typeof message === 'string') {
55
+ messageString = message;
56
+ }
57
+
58
+ if (messageString && Array.isArray(validationArguments.constraints)) {
59
+ validationArguments.constraints.forEach((constraint, index) => {
60
+ messageString = messageString.replace(
61
+ new RegExp(`\\$constraint${index + 1}`, 'g'),
62
+ constraintToString(constraint),
63
+ );
64
+ });
65
+ }
66
+
67
+ if (
68
+ messageString &&
69
+ validationArguments.value !== undefined &&
70
+ validationArguments.value !== null &&
71
+ ['boolean', 'number', 'string'].includes(typeof validationArguments.value)
72
+ ) {
73
+ messageString = messageString.replace(/\$value/g, `${validationArguments.value}`);
74
+ }
75
+ if (messageString) {
76
+ messageString = messageString.replace(/\$property/g, validationArguments.property);
77
+ }
78
+ if (messageString) {
79
+ messageString = messageString.replace(/\$target/g, validationArguments.targetName);
80
+ }
81
+
82
+ return messageString;
83
+ }
@@ -3,6 +3,7 @@ import { Observable } from 'rxjs';
3
3
  import { map } from 'rxjs/operators';
4
4
 
5
5
  import { getContextData } from '../helpers/context.helper';
6
+ import { hasRestrictionsCheckedMarker } from '../decorators/restrictions-checked.marker';
6
7
  import { getStringIds } from '../helpers/db.helper';
7
8
  import { processDeep } from '../helpers/input.helper';
8
9
  import { ConfigService } from '../services/config.service';
@@ -66,7 +67,9 @@ export class CheckSecurityInterceptor implements NestInterceptor {
66
67
  objectData = data;
67
68
 
68
69
  // Check if data already checked
69
- if (this.config.noteCheckedObjects && data?._objectAlreadyCheckedForRestrictions) {
70
+ // Recognised by the framework MARKER, not by a truthy property of that name — see
71
+ // restrictions-checked.marker.ts for why a document must not be able to skip securityCheck().
72
+ if (this.config.noteCheckedObjects && hasRestrictionsCheckedMarker(data)) {
70
73
  return data;
71
74
  }
72
75
 
@@ -6,6 +6,7 @@ import { resolveResponseModelClass } from '../helpers/interceptor.helper';
6
6
  import { CoreModel } from '../models/core-model.model';
7
7
  import { ConfigService } from '../services/config.service';
8
8
  import { ModelRegistry } from '../services/model-registry.service';
9
+ import { hasRestrictionsCheckedMarker } from '../decorators/restrictions-checked.marker';
9
10
 
10
11
  /**
11
12
  * Interceptor that automatically converts plain objects and Mongoose documents
@@ -48,7 +49,7 @@ export class ResponseModelInterceptor implements NestInterceptor {
48
49
  }
49
50
 
50
51
  // Already processed by another interceptor
51
- if (data._objectAlreadyCheckedForRestrictions) {
52
+ if (hasRestrictionsCheckedMarker(data)) {
52
53
  return data;
53
54
  }
54
55
 
@@ -90,7 +91,7 @@ export class ResponseModelInterceptor implements NestInterceptor {
90
91
  }
91
92
 
92
93
  // Already processed
93
- if (item._objectAlreadyCheckedForRestrictions) {
94
+ if (hasRestrictionsCheckedMarker(item)) {
94
95
  return item;
95
96
  }
96
97
 
@@ -855,6 +855,41 @@ export interface IErrorCode {
855
855
  * therefore be expressed in `checkRights()`, not by a role name alone.
856
856
  */
857
857
  export interface IFileConfig {
858
+ /**
859
+ * WHICH PROJECT CLASS this deployment is — the per-file rule, as a declaration instead of code.
860
+ *
861
+ * The role knobs below are the coarse audience filter ("may this caller reach the route at all").
862
+ * They cannot express "…but only their own", because that sentence needs data. That is what
863
+ * `CoreFileService.checkRights()` is for — and shipping it only as an `@example` to copy went wrong
864
+ * twice in this framework's own history, both times permissively. So the four shapes are presets:
865
+ *
866
+ * | value | project class |
867
+ * |-------------------|------------------------------------------------------------------|
868
+ * | `'custom'` | you override `checkRights()` yourself — **the default**, the framework abstains |
869
+ * | `'public'` | open: anyone may read and write; the role gate is the whole policy |
870
+ * | `'authenticated'` | login-restricted: every signed-in user may use every file |
871
+ * | `'owner'` | per-user: only the uploader, plus ADMIN |
872
+ * | `'tenant'` | per-tenant: only within one's own validated tenant, plus ADMIN |
873
+ *
874
+ * `'owner'` and `'tenant'` read `metadata.ownerId` / `metadata.tenantId`, which the service STAMPS
875
+ * as it writes once one of them is active — so an upload through `CoreFileService` is authorizable
876
+ * without any project code. Files written BEFORE the preset was enabled carry no such metadata and
877
+ * are therefore ADMIN-only; that is the fail-closed direction, and a one-off backfill fixes it.
878
+ *
879
+ * Two things this setting never does: it never overrides an explicit `checkRights()` override (the
880
+ * override IS the rule), and it never widens the role gate. `'public'` still requires
881
+ * `downloadRoles: [S_EVERYONE]` — declaring the class and opening the gate are two decisions on
882
+ * purpose.
883
+ *
884
+ * An UNKNOWN value resolves to `'owner'`, not to `'custom'`: a typo means somebody believes they
885
+ * have an ownership rule, and confirming that belief is the one error that cannot be seen from
886
+ * outside.
887
+ *
888
+ * @default 'custom'
889
+ * @since 11.35.0
890
+ */
891
+ access?: 'authenticated' | 'custom' | 'owner' | 'public' | 'tenant';
892
+
858
893
  /**
859
894
  * Roles allowed to DELETE files (`deleteFile` mutation).
860
895
  *
@@ -996,9 +1031,28 @@ export interface IMultiTenancy {
996
1031
 
997
1032
  /**
998
1033
  * Model names (NOT collection names) to exclude from tenant filtering.
999
- * These schemas will not have tenant isolation applied.
1000
- * The TenantMember model is always excluded automatically.
1001
- * @example ['User', 'Session']
1034
+ *
1035
+ * **This is an OFF SWITCH for data isolation, per model.** A listed schema gets no tenant filter at
1036
+ * all: every query on it sees every tenant's rows. That is correct for a genuinely global
1037
+ * collection, and it is a data leak for anything else.
1038
+ *
1039
+ * A schema is only affected if it HAS a `tenantId` field — the plugin attaches to no other. So
1040
+ * listing a model that declares `tenantId` overrides its author's intent, and since 11.35.0 the
1041
+ * plugin logs a warning naming the model when that happens. **Do not list a model just because a
1042
+ * `populate()` returns null** — that is the isolation working; scope the reference instead, or
1043
+ * confirm the referenced collection really is global.
1044
+ *
1045
+ * `User` is the one to think hardest about. It belongs here only when users are GLOBAL (one account
1046
+ * reaches several tenants, memberships carry the scope). If your users are per-tenant — they have a
1047
+ * `tenantId` — listing `User` switches isolation off for the account collection, which is usually
1048
+ * the last place you want it off. Earlier versions of this doc suggested `['User', 'Session']` as an
1049
+ * example; that suggestion is withdrawn, because it is only right for the global-user model.
1050
+ *
1051
+ * The membership model (`multiTenancy.membershipModel`, default `TenantMember`) is added
1052
+ * automatically and needs no entry — membership is tenant-spanning by design.
1053
+ *
1054
+ * @default []
1055
+ * @example [] // start here; add a model only when you can say why it is global
1002
1056
  */
1003
1057
  excludeSchemas?: string[];
1004
1058
 
@@ -1039,23 +1093,86 @@ export interface IMultiTenancy {
1039
1093
  * @default { member: 1, manager: 2, owner: 3 }
1040
1094
  * @since 11.21.0
1041
1095
  *
1096
+ * SECURITY: never name a tenant role after a GLOBAL role. `RoleEnum.ADMIN` (`'admin'`) and every
1097
+ * other member of `GLOBAL_ONLY_ROLES` is rejected here — a tenant role of that name used to
1098
+ * satisfy `@Roles(RoleEnum.ADMIN)` in tenant context, i.e. a customer could grant themselves
1099
+ * platform authority. Use a tenant-specific name (`tenantAdmin`, `spaceAdmin`) instead, and
1100
+ * declare your own platform-wide roles in {@link globalOnlyRoles}. The boot check refuses a
1101
+ * hierarchy that violates this.
1102
+ *
1042
1103
  * @example
1043
1104
  * ```typescript
1044
1105
  * // config.env.ts
1045
1106
  * multiTenancy: {
1046
- * roleHierarchy: { viewer: 1, editor: 2, manager: 2, admin: 3, owner: 4 }
1107
+ * roleHierarchy: { viewer: 1, editor: 2, manager: 2, tenantAdmin: 3, owner: 4 }
1047
1108
  * }
1048
1109
  *
1049
1110
  * // roles.ts
1050
1111
  * import { createHierarchyRoles } from '@lenne.tech/nest-server';
1051
- * export const HR = createHierarchyRoles({ viewer: 1, editor: 2, manager: 2, admin: 3, owner: 4 });
1112
+ * export const HR = createHierarchyRoles({ viewer: 1, editor: 2, manager: 2, tenantAdmin: 3, owner: 4 });
1052
1113
  *
1053
1114
  * // resolver.ts
1054
- * @Roles(HR.EDITOR) // requires level >= 2 (editor, manager, admin, owner)
1115
+ * @Roles(HR.EDITOR) // requires level >= 2 (editor, manager, tenantAdmin, owner)
1055
1116
  * ```
1056
1117
  */
1057
1118
  roleHierarchy?: Record<string, number>;
1058
1119
 
1120
+ /**
1121
+ * Project-defined roles that carry GLOBAL (platform-wide) authority.
1122
+ *
1123
+ * `RoleEnum.ADMIN` is always treated this way; this option extends the set with your own.
1124
+ * A role listed here is ALWAYS resolved against `user.roles` and NEVER against a tenant
1125
+ * membership role — no matter what the `X-Tenant-Id` header says.
1126
+ *
1127
+ * **Why this matters.** Membership roles are customer-assigned free text, and in tenant context
1128
+ * required roles are otherwise compared against `membership.role`. Without this list, a global
1129
+ * endpoint guarded by `@Roles('auditor')` is reachable by any tenant member whose membership role
1130
+ * happens to be `'auditor'` — granted by their own tenant owner, not by you.
1131
+ *
1132
+ * Declaring a role here is therefore a security statement: "only the platform may grant this".
1133
+ * A role that appears BOTH here and in {@link roleHierarchy} fails the boot check, because it
1134
+ * would have to be resolved against two different sources at once.
1135
+ *
1136
+ * @default [] (only RoleEnum.ADMIN is global)
1137
+ * @since 11.35.0
1138
+ *
1139
+ * @example
1140
+ * ```typescript
1141
+ * multiTenancy: {
1142
+ * roleHierarchy: { member: 1, tenantAdmin: 2, owner: 3 }, // per tenant
1143
+ * globalOnlyRoles: ['auditor', 'support'], // platform-wide
1144
+ * }
1145
+ * ```
1146
+ */
1147
+ globalOnlyRoles?: string[];
1148
+
1149
+ /**
1150
+ * Refuse membership roles that are not declared anywhere (deny by default).
1151
+ *
1152
+ * When `true`, `addMember()` / `updateMemberRole()` accept only roles present in
1153
+ * {@link roleHierarchy} or {@link additionalMembershipRoles}. When `false` (default, backward
1154
+ * compatible), any non-reserved string is accepted.
1155
+ *
1156
+ * An undeclared membership role can never GRANT anything either way — the guards only match
1157
+ * declared tenant roles — but with this on, the mistake surfaces as a 400 at assignment time
1158
+ * instead of as a membership that silently authorizes nothing.
1159
+ *
1160
+ * @default false
1161
+ * @since 11.35.0
1162
+ */
1163
+ strictMembershipRoles?: boolean;
1164
+
1165
+ /**
1166
+ * Membership roles that are valid but carry no hierarchy level (exact-match roles).
1167
+ *
1168
+ * Only consulted when {@link strictMembershipRoles} is `true`. Use it for roles like `'auditor'`
1169
+ * that are meaningful per tenant but do not sit in the level ordering.
1170
+ *
1171
+ * @default []
1172
+ * @since 11.35.0
1173
+ */
1174
+ additionalMembershipRoles?: string[];
1175
+
1059
1176
  /**
1060
1177
  * TTL in milliseconds for the tenant guard's in-memory membership cache.
1061
1178
  * The cache avoids repeated DB lookups when the same user accesses the same tenant.
@@ -2210,7 +2327,8 @@ export interface IServerOptions {
2210
2327
  *
2211
2328
  * // Enable with excluded schemas and custom header
2212
2329
  * multiTenancy: {
2213
- * excludeSchemas: ['User', 'Session'],
2330
+ * // excludeSchemas turns isolation OFF per model — see IMultiTenancy.excludeSchemas
2331
+ * excludeSchemas: [],
2214
2332
  * headerName: 'x-tenant-id',
2215
2333
  * },
2216
2334
  * ```
@@ -2852,6 +2970,20 @@ export interface IS3Config {
2852
2970
  presignedDownloads?:
2853
2971
  | boolean
2854
2972
  | {
2973
+ /**
2974
+ * Explicitly disable while keeping the settings around — the repo-wide
2975
+ * "presence implies enabled, unless `enabled: false`" pattern.
2976
+ *
2977
+ * It is spelled out in the type because it is REACHABLE WITHOUT THE TYPE: `NEST_SERVER_CONFIG`
2978
+ * and the `NSC__*` variables deliver plain JSON, so `{"enabled": false}` could always be set —
2979
+ * and `CoreS3Service` used to read any object as "enabled", which meant presigned downloads
2980
+ * came on while the boot warning (which does honour the key) stayed silent. Two code paths
2981
+ * answering one question differently is how a bearer-capability download gets switched on by
2982
+ * accident.
2983
+ * @default true (when the config object is present)
2984
+ */
2985
+ enabled?: boolean;
2986
+
2855
2987
  /**
2856
2988
  * Presigned URL validity in seconds.
2857
2989
  *
@@ -20,6 +20,7 @@ import {
20
20
  maxLength,
21
21
  min,
22
22
  minLength,
23
+ ValidationArguments,
23
24
  ValidationError,
24
25
  } from 'class-validator';
25
26
  import { ValidationMetadata } from 'class-validator/types/metadata/ValidationMetadata';
@@ -27,6 +28,7 @@ import { inspect } from 'util';
27
28
 
28
29
  import { getUnifiedFieldKeys, nestedTypeRegistry } from '../decorators/unified-field.decorator';
29
30
  import { isBasicType } from '../helpers/input.helper';
31
+ import { replaceMessageSpecialTokens } from '../helpers/validation-message.helper';
30
32
  import { ConfigService } from '../services/config.service';
31
33
  import { ErrorCode } from '../../modules/error-code/error-codes';
32
34
 
@@ -268,18 +270,21 @@ async function validateWithInheritance(object: any, originalPlainValue: any): Pr
268
270
  isValid = validationResult instanceof Promise ? await validationResult : validationResult;
269
271
  }
270
272
 
271
- // Get default message and constraint name if validation failed
273
+ // Get message and constraint name if validation failed
272
274
  if (!isValid) {
273
275
  // Use metadata.name for the constraint key (e.g., "isEmail", "isString")
274
276
  const constraintName = metadata.name || 'customValidation';
275
277
 
276
- if (typeof constraintInstance.defaultMessage === 'function') {
277
- errorMessage = constraintInstance.defaultMessage(validationArgs);
278
- // Replace $property placeholder with actual property name
279
- errorMessage = errorMessage.replace(/\$property/g, propertyName);
280
- } else {
281
- errorMessage = `${propertyName} failed custom validation`;
278
+ // A custom message from ValidationOptions takes precedence over the
279
+ // constraint's default message — same order as class-validator's executor
280
+ let messageTemplate: string | ((args: ValidationArguments) => string) | undefined =
281
+ metadata.message as string | ((args: ValidationArguments) => string) | undefined;
282
+ if (!messageTemplate && typeof constraintInstance.defaultMessage === 'function') {
283
+ messageTemplate = constraintInstance.defaultMessage(validationArgs);
282
284
  }
285
+ errorMessage = messageTemplate
286
+ ? replaceMessageSpecialTokens(messageTemplate, validationArgs)
287
+ : `${propertyName} failed custom validation`;
283
288
 
284
289
  // Add to constraints with the proper name
285
290
  propertyError.constraints[constraintName] = errorMessage;
@@ -578,6 +583,19 @@ async function validateWithInheritance(object: any, originalPlainValue: any): Pr
578
583
 
579
584
  // Add constraint violation if validation failed
580
585
  if (!isValid) {
586
+ // A custom message from ValidationOptions takes precedence over the built-in message
587
+ if (metadata.message) {
588
+ errorMessage = replaceMessageSpecialTokens(
589
+ metadata.message as string | ((args: ValidationArguments) => string),
590
+ {
591
+ constraints: metadata.constraints || [],
592
+ object: tempInstance,
593
+ property: propertyName,
594
+ targetName: targetClass.name,
595
+ value: propertyValue,
596
+ },
597
+ );
598
+ }
581
599
  propertyError.constraints[constraintType] = errorMessage;
582
600
  }
583
601
  }