@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.34.0",
3
+ "version": "11.35.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",
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Which CLASS a model property declares — the only record of that fact at runtime.
3
+ *
4
+ * DELIBERATELY IMPORT-FREE. Two files need this map and they sit on opposite ends of an import
5
+ * edge: `unified-field.decorator` writes it (and already imports `restricted.decorator` for
6
+ * `@Restricted`), while `restricted.decorator` reads it. Keeping the map here means the reader adds
7
+ * no import back into the writer — `restricted.decorator` drives field-level access control and its
8
+ * position on zero import cycles is a defended property, not an accident.
9
+ * See `.claude/rules/architecture.md` → "DI Token Placement (SWC-Safe)".
10
+ *
11
+ * WHY IT EXISTS AT ALL: TypeScript types are erased, and `design:type` degrades to `Array` for a
12
+ * typed array and is only emitted for decorated properties. So a nested value read out of MongoDB —
13
+ * an embedded subdocument, or an array of them — arrives as a PLAIN object whose constructor is
14
+ * `Object`. Nothing on the value itself can say what it was declared as, which means nothing can
15
+ * find the `@Restricted` / validation metadata that belongs to it. This map is what closes that gap.
16
+ *
17
+ * Filled by `@UnifiedField()` for every non-primitive property. A property declared without
18
+ * `@UnifiedField` is absent, and consumers must treat "absent" as "unknown", never as "no
19
+ * restrictions" — see `checkRestricted()` for how that is handled.
20
+ */
21
+
22
+ /** Key: `${className}.${propertyName}` — value: the declared (element) type. */
23
+ export const nestedTypeRegistry = new Map<string, any>();
24
+
25
+ /**
26
+ * The class a property of `ownerClass` declares, or `undefined` when nothing recorded it.
27
+ *
28
+ * Walks the PROTOTYPE CHAIN of the class, because `@UnifiedField()` registers under the class it was
29
+ * applied to — a property inherited from a base model is recorded under the BASE class name, and a
30
+ * lookup that only tried the leaf would miss it.
31
+ */
32
+ export function resolveNestedType(ownerClass: unknown, propertyKey: string): any {
33
+ let current: any = ownerClass;
34
+ while (current && current !== Object && typeof current === 'function') {
35
+ const found = nestedTypeRegistry.get(`${current.name}.${propertyKey}`);
36
+ if (found) {
37
+ return found;
38
+ }
39
+ current = Object.getPrototypeOf(current);
40
+ }
41
+ return undefined;
42
+ }
@@ -22,17 +22,37 @@
22
22
  * See .claude/rules/architecture.md → "DI Token Placement (SWC-Safe)".
23
23
  */
24
24
  import 'reflect-metadata';
25
+ import { Logger } from '@nestjs/common';
25
26
  import _ = require('lodash');
26
27
 
27
28
  import { ProcessType } from '../enums/process-type.enum';
28
- import { RoleEnum } from '../enums/role.enum';
29
+ // isSystemRole comes from the role.enum LEAF (import-free), not from core-tenant.helpers, so this
30
+ // adds no new import edge to a file whose TDZ-immunity is a deliberate safety property.
31
+ import { isSystemRole, RoleEnum } from '../enums/role.enum';
29
32
  import { accessDeniedException } from '../exceptions/access-denied.exception';
33
+ // From the import-free LEAF, never from unified-field.decorator: that file imports THIS one for
34
+ // `@Restricted`, so reading the map from there would put this file back on an import cycle.
35
+ import { resolveNestedType } from './nested-type.registry';
36
+ // From an import-free LEAF: the marker is read here and in both interceptors, and this file's
37
+ // position on zero import cycles is a defended property.
38
+ import {
39
+ hasRestrictionsCheckedMarker,
40
+ hasUnrecognizedRestrictionsFlag,
41
+ RESTRICTIONS_CHECKED_KEY,
42
+ } from './restrictions-checked.marker';
30
43
  // Import from the id.helper LEAF, never from db.helper: db.helper imports input.helper, which
31
44
  // imports this file back — that cycle is what the extraction removed. See id.helper's docblock.
32
45
  import { equalIds, getIncludedIds } from '../helpers/id.helper';
33
46
  import { RequestContext } from '../services/request-context.service';
34
47
  import { RequireAtLeastOne } from '../types/required-at-least-one.type';
35
- import { checkRoleAccess } from '../../modules/tenant/core-tenant.helpers';
48
+ import { checkRoleAccess, resolveGlobalAndTenantRoles } from '../../modules/tenant/core-tenant.helpers';
49
+
50
+ export {
51
+ hasRestrictionsCheckedMarker,
52
+ hasUnrecognizedRestrictionsFlag,
53
+ markRestrictionsChecked,
54
+ RESTRICTIONS_CHECKED_KEY,
55
+ } from './restrictions-checked.marker';
36
56
 
37
57
  /**
38
58
  * Restricted meta key
@@ -121,6 +141,66 @@ export function getRestricted(object: unknown, propertyKey?: string): Restricted
121
141
  return metadata;
122
142
  }
123
143
 
144
+ /**
145
+ * Resolve the NON-system portion of a field's required roles against the requester.
146
+ *
147
+ * Two things must be filtered before the roles reach `checkRoleAccess`, because that function
148
+ * matches non-hierarchy roles by exact string against a SINGLE available role — the tenant
149
+ * membership role, when a tenant context is active:
150
+ *
151
+ * 1. **System roles.** `S_SELF`, `S_CREATOR`, `S_VERIFIED`, `S_USER` and `S_EVERYONE` are decided
152
+ * by the dedicated checks in `checkRestricted` (ownership, verification, session). Passing them
153
+ * on means a membership role literally named `'s_self'` satisfies `@Restricted(S_SELF)` on
154
+ * ARBITRARY records — no ownership is ever compared. `CoreTenantGuard` already filters these at
155
+ * its own call site; this is the same filter for the field-level path.
156
+ *
157
+ * 2. **Global-only roles.** `RoleEnum.ADMIN` is platform authority and must be answered from
158
+ * `user.roles`, never from a customer-assigned membership role.
159
+ *
160
+ * Declared as a hoisted `function` so it stays temporal-dead-zone immune — this file sits on no
161
+ * import cycle today and the property is defended deliberately (see `.claude/rules/architecture.md`).
162
+ */
163
+ function checkFieldRoleAccess(roles: string[], user: any): boolean {
164
+ const { global: globalRoles, tenant: tenantRoles } = resolveGlobalAndTenantRoles(
165
+ roles.filter((role) => !isSystemRole(role)),
166
+ );
167
+
168
+ if (globalRoles.some((role) => user?.roles?.includes(role))) {
169
+ return true;
170
+ }
171
+
172
+ // The length guard is NOT redundant: checkRoleAccess returns TRUE for an empty required-roles
173
+ // list, so calling it with no tenant roles would unlock a field that only required a global one.
174
+ return tenantRoles.length > 0 && checkRoleAccess(tenantRoles, user?.roles, RequestContext.get()?.tenantRole);
175
+ }
176
+
177
+ /** Emitted at most once per process — a per-object warning on a hot path would be noise, not signal. */
178
+ let unrecognizedFlagWarned = false;
179
+
180
+ /**
181
+ * Warn once when an object carries `_objectAlreadyCheckedForRestrictions` without the framework marker.
182
+ *
183
+ * Declared as a hoisted `function` like everything else in this file, so it stays temporal-dead-zone
184
+ * immune. Uses the Nest `Logger` rather than `console.warn` for two reasons: it is the convention every
185
+ * other framework warning follows, and `console` output from a hot path is forwarded over vitest's
186
+ * worker RPC — a late one produces `Closing rpc while "onUserConsoleLog" was pending` in whichever
187
+ * spec happens to be tearing down. `@nestjs/common` is an external package and already in this file's
188
+ * transitive graph via `accessDeniedException`, so it adds no project import edge.
189
+ */
190
+ function warnUnrecognizedRestrictionsFlag(data: unknown): void {
191
+ if (unrecognizedFlagWarned || !hasUnrecognizedRestrictionsFlag(data)) {
192
+ return;
193
+ }
194
+ unrecognizedFlagWarned = true;
195
+ new Logger('Restricted').warn(
196
+ `[Restricted] An object carries "${RESTRICTIONS_CHECKED_KEY}" without the framework marker, so it ` +
197
+ 'is being CHECKED rather than skipped. Since 11.35.0 the opt-out is only honoured for objects ' +
198
+ 'marked via markRestrictionsChecked() — a plain truthy property could arrive from the database ' +
199
+ 'or a payload and would switch off field-level access control. If you set it deliberately, call ' +
200
+ 'markRestrictionsChecked(obj); if this came from stored data, remove the field.',
201
+ );
202
+ }
203
+
124
204
  /**
125
205
  * Check data for restricted properties (properties with `Restricted` decorator)
126
206
  * For special Roles and member of group checking the dbObject must be set in options
@@ -150,6 +230,7 @@ export function checkRestricted(
150
230
  throwError?: boolean;
151
231
  } = {},
152
232
  processedObjects: WeakSet<object> = new WeakSet(),
233
+ declaredType?: any,
153
234
  ) {
154
235
  // Act like Roles handling: checkObjectItself = false & mergeRoles = true
155
236
  // For Input: throwError = true
@@ -168,16 +249,52 @@ export function checkRestricted(
168
249
  };
169
250
 
170
251
  // Primitives
171
- if (!data || typeof data !== 'object' || (config.noteCheckedObjects && data._objectAlreadyCheckedForRestrictions)) {
252
+ // The "already checked" opt-out is recognised by the framework's MARKER, never by a truthy property
253
+ // of that name: nothing in src/ ever wrote the property, so a truthy one could only have come from
254
+ // outside — and a document carrying it disabled every field-level check for that object, silently,
255
+ // on the output path. See restrictions-checked.marker.ts.
256
+ if (!data || typeof data !== 'object' || (config.noteCheckedObjects && hasRestrictionsCheckedMarker(data))) {
172
257
  return data;
173
258
  }
174
259
 
260
+ // Say it out loud when the KEY is present without the marker. Two very different callers land here
261
+ // and they want opposite answers — a project that used to set the flag by hand has lost its opt-out,
262
+ // a document that carries it from a raw write has lost a bypass it should never have had. A warning
263
+ // states the situation without guessing which; honouring the value is what the marker exists to stop.
264
+ warnUnrecognizedRestrictionsFlag(data);
265
+
175
266
  // Prevent infinite recursion
176
267
  if (processedObjects.has(data)) {
177
268
  return data;
178
269
  }
179
270
  processedObjects.add(data);
180
271
 
272
+ // WHERE the restriction metadata for `data` lives.
273
+ //
274
+ // For an instance of a model class this is simply its constructor, and reading it off the object
275
+ // works — which is why the top level has always been checked correctly. One level down it stops
276
+ // working: `CoreModel.map()` is a shallow `Object.assign`, `prepareOutput()` maps only the target
277
+ // model, and `ResponseModelInterceptor` maps only the top-level item. So an embedded subdocument
278
+ // read out of MongoDB arrives as a PLAIN object whose constructor is `Object` — and `Object`
279
+ // carries no `@Restricted` metadata, so every nested restriction silently evaluated to "no
280
+ // restrictions at all" and the field was returned in full. `declaredType` closes that: the type
281
+ // the PARENT declared for this property, looked up in the nested-type registry.
282
+ //
283
+ // An unregistered nested type still resolves to nothing, exactly as before — the framework cannot
284
+ // invent a declaration. That is deliberate rather than fail-closed: nested values also legitimately
285
+ // hold free-form JSON, `Map`s and scalars, and refusing those would break far more than it
286
+ // protects. Declare nested types with `@UnifiedField({ type: () => X })` to have them enforced.
287
+ const metadataOwner: any =
288
+ data.constructor && data.constructor !== Object
289
+ ? data.constructor
290
+ : typeof declaredType === 'function'
291
+ ? declaredType
292
+ : undefined;
293
+ // What `Reflect.getMetadata` must walk for PROPERTY lookups: the instance when there is one, the
294
+ // declared class's prototype otherwise (metadata for property decorators is defined there, and
295
+ // Reflect traverses the prototype chain either way).
296
+ const metadataTarget: any = !metadataOwner || data.constructor === metadataOwner ? data : metadataOwner.prototype;
297
+
181
298
  // Array
182
299
  if (Array.isArray(data)) {
183
300
  if (data.length === 0) {
@@ -189,15 +306,18 @@ export function checkRestricted(
189
306
  // Per-item checks are only needed when S_CREATOR or S_SELF restrictions exist
190
307
  // (because createdBy/id differ per item).
191
308
  const sample = data[0];
192
- if (
193
- sample &&
194
- typeof sample === 'object' &&
195
- !Array.isArray(sample) &&
196
- sample.constructor &&
197
- sample.constructor !== Object
198
- ) {
309
+ // The element type: the sample's own class, or — for an array of embedded subdocuments, which
310
+ // arrive as plain objects — the type the parent property declared. `declaredType` is the ELEMENT
311
+ // type here: `@UnifiedField({ isArray: true, type: () => X })` registers `X`, not `X[]`.
312
+ const sampleOwner: any =
313
+ sample && typeof sample === 'object' && !Array.isArray(sample) && sample.constructor !== Object
314
+ ? sample.constructor
315
+ : typeof declaredType === 'function'
316
+ ? declaredType
317
+ : undefined;
318
+ if (sample && typeof sample === 'object' && !Array.isArray(sample) && sampleOwner) {
199
319
  // Check class-level restrictions once
200
- const classRestrictions = getRestricted(sample.constructor) || [];
320
+ const classRestrictions = getRestricted(sampleOwner) || [];
201
321
  if (classRestrictions.length) {
202
322
  const hasCreatorOrSelf = classRestrictions.some(
203
323
  (r) =>
@@ -223,7 +343,7 @@ export function checkRestricted(
223
343
  // also validates the class-level restriction as a standalone gate. With the
224
344
  // default checkObjectItself=false, class restrictions are merged into each
225
345
  // property's restrictions (properties get stripped if the class restriction denies).
226
- const sampleResult = checkRestricted(sample, user, config, processedObjects);
346
+ const sampleResult = checkRestricted(sample, user, config, processedObjects, declaredType);
227
347
  if (sampleResult === undefined || sampleResult === null) {
228
348
  // Class-level restriction blocks access → entire array is blocked
229
349
  if (config.throwError) {
@@ -236,7 +356,7 @@ export function checkRestricted(
236
356
  // are O(1) lookups, but we still need to recurse into nested properties per item.
237
357
  const result = [sampleResult];
238
358
  for (let i = 1; i < data.length; i++) {
239
- result.push(checkRestricted(data[i], user, config, processedObjects));
359
+ result.push(checkRestricted(data[i], user, config, processedObjects, declaredType));
240
360
  }
241
361
  if (!config.throwError && config.removeUndefinedFromResultArray) {
242
362
  return result.filter((item) => item !== undefined);
@@ -247,7 +367,7 @@ export function checkRestricted(
247
367
  }
248
368
 
249
369
  // Fallback: plain objects, mixed types, or S_CREATOR/S_SELF checks needed
250
- let result = data.map((item) => checkRestricted(item, user, config, processedObjects));
370
+ let result = data.map((item) => checkRestricted(item, user, config, processedObjects, declaredType));
251
371
  if (!config.throwError && config.removeUndefinedFromResultArray) {
252
372
  result = result.filter((item) => item !== undefined);
253
373
  }
@@ -256,7 +376,7 @@ export function checkRestricted(
256
376
 
257
377
  // Check function
258
378
  const validateRestricted = (restricted) => {
259
- if (config.noteCheckedObjects && data?._objectAlreadyCheckedForRestrictions) {
379
+ if (config.noteCheckedObjects && hasRestrictionsCheckedMarker(data)) {
260
380
  return true;
261
381
  }
262
382
 
@@ -320,7 +440,7 @@ export function checkRestricted(
320
440
  ((owner && 'createdBy' in owner && equalIds(owner.createdBy, user)) ||
321
441
  (config.allowCreatorOfParent && owner && !('createdBy' in owner) && config.isCreatorOfParent))) ||
322
442
  (roles.includes(RoleEnum.S_VERIFIED) && (user?.verified || user?.verifiedAt || user?.emailVerified)) ||
323
- (user?.id && checkRoleAccess(roles, user?.roles, RequestContext.get()?.tenantRole))
443
+ (user?.id && checkFieldRoleAccess(roles, user))
324
444
  ) {
325
445
  valid = true;
326
446
  }
@@ -375,7 +495,7 @@ export function checkRestricted(
375
495
  };
376
496
 
377
497
  // Check data object
378
- const objectRestrictions = getRestricted(data.constructor) || [];
498
+ const objectRestrictions = getRestricted(metadataOwner) || [];
379
499
  if (config.checkObjectItself) {
380
500
  const objectIsValid = validateRestricted(objectRestrictions);
381
501
  if (!objectIsValid) {
@@ -403,8 +523,9 @@ export function checkRestricted(
403
523
  continue;
404
524
  }
405
525
 
406
- // Check restricted
407
- const restricted = getRestricted(data, propertyKey) || [];
526
+ // Check restricted. `metadataTarget` is `data` for a real instance and the declared class's
527
+ // prototype for a plain nested object — see its definition above.
528
+ const restricted = getRestricted(metadataTarget, propertyKey) || [];
408
529
  const concatenatedRestrictions =
409
530
  config.mergeRoles && objectRestrictions.length ? _.uniq(objectRestrictions.concat(restricted)) : restricted;
410
531
  const valid = validateRestricted(concatenatedRestrictions);
@@ -420,8 +541,15 @@ export function checkRestricted(
420
541
  equalIds(parent, user) ||
421
542
  (parent && 'createdBy' in parent ? equalIds(parent.createdBy, user) : config.isCreatorOfParent);
422
543
 
423
- // Check deep
424
- data[propertyKey] = checkRestricted(data[propertyKey], user, config, processedObjects);
544
+ // Check deep — carrying the type this property DECLARES, so a nested plain object (an
545
+ // embedded subdocument, or an array of them) can still be matched against its own metadata.
546
+ data[propertyKey] = checkRestricted(
547
+ data[propertyKey],
548
+ user,
549
+ config,
550
+ processedObjects,
551
+ metadataOwner ? resolveNestedType(metadataOwner, propertyKey) : undefined,
552
+ );
425
553
  } else {
426
554
  if (config.debug) {
427
555
  console.debug(
@@ -0,0 +1,87 @@
1
+ /**
2
+ * "This object has already been checked" — as a MARKER the framework sets, not a property data carries.
3
+ *
4
+ * WHY THIS FILE EXISTS: three places short-circuit on
5
+ * `_objectAlreadyCheckedForRestrictions` — `checkRestricted()` returns the object untouched,
6
+ * `CheckSecurityInterceptor` skips `securityCheck()`, and `ResponseModelInterceptor` skips the
7
+ * Plain→Model conversion. The opt-out is legitimate: without it one response is checked three times.
8
+ *
9
+ * What was NOT legitimate is how it was recognised. Every read was a plain truthy test on a
10
+ * well-known property name, and nothing in `src/` ever wrote it — so the only way the flag could ever
11
+ * be set was from outside the framework. Which means a document carrying
12
+ * `_objectAlreadyCheckedForRestrictions: true` — from a raw write, a `strict: false` schema, a
13
+ * restored export — disabled EVERY field-level access check for that object, silently, on the output
14
+ * path where a skipped check produces no error at all. That is a data-shaped authorization switch,
15
+ * and data must never be able to flip one.
16
+ *
17
+ * The fix keeps the feature and removes the forgeability: the marker's VALUE is a module-private
18
+ * symbol. JSON, BSON and any client payload can produce the key, and none of them can produce that
19
+ * value. The property is also non-enumerable, so it never reaches a response body and cannot re-enter
20
+ * through a round trip as exactly the data shape above.
21
+ *
22
+ * DELIBERATELY IMPORT-FREE, so `restricted.decorator` (whose position on zero import cycles is a
23
+ * defended property) and both interceptors can read it without a new import edge between them.
24
+ * See `.claude/rules/architecture.md` → "DI Token Placement (SWC-Safe)".
25
+ */
26
+
27
+ /**
28
+ * The property name, unchanged since it is part of the observable API — a project may look for it.
29
+ * Only the way it is RECOGNISED changed.
30
+ */
31
+ export const RESTRICTIONS_CHECKED_KEY = '_objectAlreadyCheckedForRestrictions';
32
+
33
+ /**
34
+ * The only value that counts as "checked". Module-private on purpose: it is not exported, so nothing
35
+ * outside this file — and nothing that arrives as data — can reproduce it.
36
+ */
37
+ const MARKER = Symbol('restrictionsChecked');
38
+
39
+ /**
40
+ * Mark an object as already checked, so the framework's own layers do not check it again.
41
+ *
42
+ * Non-enumerable and non-writable: it must not appear in `Object.keys()`, in `JSON.stringify()` or in
43
+ * a Mongoose write. `configurable: true` so the same object can be re-marked without throwing.
44
+ *
45
+ * Returns the object, so it can be used inline.
46
+ */
47
+ export function markRestrictionsChecked<T>(data: T): T {
48
+ if (!data || typeof data !== 'object') {
49
+ return data;
50
+ }
51
+ Object.defineProperty(data, RESTRICTIONS_CHECKED_KEY, {
52
+ configurable: true,
53
+ enumerable: false,
54
+ value: MARKER,
55
+ writable: false,
56
+ });
57
+ return data;
58
+ }
59
+
60
+ /**
61
+ * Has the framework marked this object as checked?
62
+ *
63
+ * The identity comparison is the whole point — `!!data[KEY]` would be satisfied by any truthy value,
64
+ * including one that came out of the database.
65
+ */
66
+ export function hasRestrictionsCheckedMarker(data: unknown): boolean {
67
+ return !!data && typeof data === 'object' && (data as Record<string, unknown>)[RESTRICTIONS_CHECKED_KEY] === MARKER;
68
+ }
69
+
70
+ /**
71
+ * Does this object carry the KEY without the marker — i.e. an unforgeable-looking value that is not
72
+ * actually the marker?
73
+ *
74
+ * Used to warn once rather than to decide anything. Two callers can produce this state, and they want
75
+ * opposite answers: a project that used to set the flag by hand (`obj[KEY] = true`) has silently lost
76
+ * its opt-out and should hear about it, while a document that carries the field from a raw write has
77
+ * silently LOST a bypass it should never have had. A warning names the situation without guessing
78
+ * which one it is; treating it as "checked" is what this file exists to stop.
79
+ */
80
+ export function hasUnrecognizedRestrictionsFlag(data: unknown): boolean {
81
+ return (
82
+ !!data &&
83
+ typeof data === 'object' &&
84
+ RESTRICTIONS_CHECKED_KEY in (data as object) &&
85
+ (data as Record<string, unknown>)[RESTRICTIONS_CHECKED_KEY] !== MARKER
86
+ );
87
+ }
@@ -22,11 +22,12 @@ import {
22
22
  import { GraphQLScalarType, isEnumType } from 'graphql';
23
23
 
24
24
  import { RoleEnum } from '../enums/role.enum';
25
+ // Re-exported below so no import path broke. The map moved into an import-free leaf because
26
+ // `restricted.decorator` — which this file imports — now READS it; see that leaf's docblock.
27
+ import { nestedTypeRegistry } from './nested-type.registry';
25
28
  import { Restricted, RestrictedType } from './restricted.decorator';
26
29
 
27
- // Registry to store nested type information for validation
28
- // Key: `${className}.${propertyName}`, Value: nested type constructor
29
- export const nestedTypeRegistry = new Map<string, any>();
30
+ export { nestedTypeRegistry, resolveNestedType } from './nested-type.registry';
30
31
 
31
32
  /**
32
33
  * Registry to map enum objects to their names.
@@ -60,3 +60,119 @@ export enum RoleEnum {
60
60
  // User must be logged in (see context user, e.g. @CurrentUser)
61
61
  S_USER = 's_user',
62
62
  }
63
+
64
+ /**
65
+ * Prefix that marks a role as a system role (runtime-context check, never a stored role).
66
+ *
67
+ * This is the single source of truth for the prefix. Anything deriving the system-role rule —
68
+ * guards, the `@Restricted` evaluation, the storage guards — must build on it rather than
69
+ * hard-coding `'s_'` again.
70
+ */
71
+ export const SYSTEM_ROLE_PREFIX = 's_';
72
+
73
+ /**
74
+ * Is this role a system role, by the exact rule the runtime checks use?
75
+ *
76
+ * Case-SENSITIVE on purpose: `hasRole()` and `checkRoleAccess()` compare role strings exactly,
77
+ * so `'S_SELF'` is a different string from `'s_self'` and never grants anything. Widening this
78
+ * to case-insensitive would make the guards treat a legitimately-named project role such as
79
+ * `'S_Manager'` as a system role and change access decisions.
80
+ *
81
+ * Use this for RUNTIME checks. To decide whether a value may be STORED in `user.roles`, use
82
+ * {@link looksLikeSystemRole} — that question deserves the stricter, defensive answer.
83
+ *
84
+ * Declared as a hoisted `function` (not a `const` arrow) so it stays temporal-dead-zone immune
85
+ * on any import cycle — see `.claude/rules/architecture.md` → "DI Token Placement (SWC-Safe)".
86
+ */
87
+ export function isSystemRole(role: string): boolean {
88
+ return typeof role === 'string' && role.startsWith(SYSTEM_ROLE_PREFIX);
89
+ }
90
+
91
+ /**
92
+ * Could this value be mistaken for a system role once stored in `user.roles`?
93
+ *
94
+ * Deliberately BROADER than {@link isSystemRole}: trims surrounding whitespace and ignores case.
95
+ * A stored `' s_self'` or `'S_SELF'` grants nothing today, because `hasRole()` compares exactly —
96
+ * but that is an accident of the comparison, not a decision. Both become live the moment anything
97
+ * normalizes roles (a Mongoose `trim: true`, a CSV/LDAP import, a project sanitizer), and both
98
+ * pass an eyeball review as legitimate roles in the meantime.
99
+ *
100
+ * This is the predicate the storage guards use. It is intentionally strict enough to also reject
101
+ * a project role that merely happens to start with `s_` (e.g. `'s_manager'`) — the framework
102
+ * cannot tell those apart from a system role, and a false rejection is recoverable by renaming
103
+ * while a false acceptance is a silent authorization hole.
104
+ */
105
+ export function looksLikeSystemRole(role: unknown): boolean {
106
+ return typeof role === 'string' && role.trim().toLowerCase().startsWith(SYSTEM_ROLE_PREFIX);
107
+ }
108
+
109
+ /**
110
+ * Roles that carry GLOBAL, platform-wide authority and can therefore never be satisfied by a
111
+ * tenant membership role.
112
+ *
113
+ * The distinction exists because membership roles are customer-assigned free text: `addMember()`
114
+ * takes any non-empty string, and whoever may manage members is typically a tenant owner — a
115
+ * customer. If a membership role were compared by plain string equality against a framework role,
116
+ * that customer could mint platform-wide authority for themselves simply by naming their tenant
117
+ * role `'admin'`.
118
+ *
119
+ * The two levels are meant to be expressed separately:
120
+ * - **global admin** → `RoleEnum.ADMIN` in `user.roles` — access to every tenant
121
+ * - **tenant admin** → a membership role such as `'tenantAdmin'` / `'spaceAdmin'` — one tenant only
122
+ *
123
+ * Required roles from this set are always resolved against `user.roles`, never against
124
+ * `membership.role`, no matter what the tenant header says.
125
+ */
126
+ export const GLOBAL_ONLY_ROLES: readonly string[] = [RoleEnum.ADMIN];
127
+
128
+ /**
129
+ * Is this a role that only the platform may grant (never a tenant)?
130
+ *
131
+ * Hoisted `function` for the same temporal-dead-zone reason as {@link isSystemRole}.
132
+ */
133
+ export function isGlobalOnlyRole(role: string): boolean {
134
+ return typeof role === 'string' && GLOBAL_ONLY_ROLES.includes(role);
135
+ }
136
+
137
+ /**
138
+ * Could this value be mistaken for a global-only role once stored?
139
+ *
140
+ * Stands to {@link isGlobalOnlyRole} exactly as {@link looksLikeSystemRole} stands to
141
+ * {@link isSystemRole}: the runtime rule is exact (so `'ADMIN'` never grants anything), while the
142
+ * storage rule is defensive, because `' Admin '` reads as legitimate in a members list and becomes
143
+ * live the moment anything normalizes role strings.
144
+ */
145
+ export function looksLikeGlobalOnlyRole(role: unknown): boolean {
146
+ if (typeof role !== 'string') {
147
+ return false;
148
+ }
149
+ const normalized = role.trim().toLowerCase();
150
+ return GLOBAL_ONLY_ROLES.some((globalRole) => globalRole.toLowerCase() === normalized);
151
+ }
152
+
153
+ /**
154
+ * Is this value unusable as a tenant MEMBERSHIP role?
155
+ *
156
+ * True for system roles (which are runtime-context checks, not stored roles) and for global-only
157
+ * roles (which would cross the tenant boundary). Used to refuse such values at assignment time, so
158
+ * the dangerous membership never comes into existence in the first place.
159
+ *
160
+ * Uses the DEFENSIVE variant of both predicates — see {@link looksLikeSystemRole} and
161
+ * {@link looksLikeGlobalOnlyRole} for why storage deserves the stricter answer than runtime.
162
+ */
163
+ export function isForbiddenMembershipRole(role: unknown): boolean {
164
+ return looksLikeSystemRole(role) || looksLikeGlobalOnlyRole(role);
165
+ }
166
+
167
+ /**
168
+ * `class-validator` `@Matches()` pattern that ACCEPTS everything {@link looksLikeSystemRole}
169
+ * rejects, and vice versa.
170
+ *
171
+ * A negative lookahead, so a value is valid exactly when it does NOT begin (after optional leading
172
+ * whitespace, case-insensitively) with the system-role prefix. Kept next to the predicate it
173
+ * mirrors so the two cannot drift; `@Matches` needs a pattern and cannot take a function.
174
+ *
175
+ * Anchored with no quantifier over the input, so it is linear in input length — a 100 KB value
176
+ * costs the same as a 10-character one, with no backtracking to exploit.
177
+ */
178
+ export const SYSTEM_ROLE_REJECT_PATTERN = new RegExp(`^(?!\\s*${SYSTEM_ROLE_PREFIX})`, 'i');
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The request-like object a guard decides on, for EVERY transport — including the GraphQL WebSocket.
3
+ *
4
+ * WHY THIS EXISTS: all three role guards resolved the request the same way, and all three got the
5
+ * WebSocket wrong in the same way:
6
+ *
7
+ * ```typescript
8
+ * ctx.getContext()?.req || context.switchToHttp().getRequest()
9
+ * ```
10
+ *
11
+ * A subscription's GraphQL context is the graphql-ws `extra` object, which has no `req`. The HTTP
12
+ * fallback then runs on a GraphQL context, where `switchToHttp().getRequest()` yields the resolver's
13
+ * ROOT value — `undefined` at subscribe time. So the guards were handed nothing, and what they did
14
+ * with nothing differed in the worst possible way:
15
+ *
16
+ * - `CoreTenantGuard` hit `if (!request) return true` and GRANTED access without deciding anything.
17
+ * With multi-tenancy active the role guard PASSES non-system roles through to it, so `@Roles()` on a
18
+ * subscription was checked by nobody at all: a plain `member` reached a `tenantAdmin`-gated
19
+ * subscription and received its messages.
20
+ * - Without multi-tenancy the role guard found no token and refused EVERYONE, so the same decorator
21
+ * silently meant "locked" instead of "open".
22
+ *
23
+ * Both are wrong, and the first is a hole. Everything a guard needs is present on the subscription
24
+ * context — `CoreModule`'s `onConnect` records the authenticated `user` and the client's
25
+ * `connectionParams` as `headers` — it was simply never looked at.
26
+ *
27
+ * DELIBERATELY IMPORT-FREE apart from the Nest types it is typed against, so it stays usable from
28
+ * `src/core/common/**` and from `src/core/modules/**` alike without adding an import edge between
29
+ * them. See `.claude/rules/architecture.md` → "DI Token Placement (SWC-Safe)".
30
+ */
31
+ import type { ExecutionContext } from '@nestjs/common';
32
+
33
+ /**
34
+ * The shape a guard reads and writes. It is deliberately loose: on HTTP this IS the Express request,
35
+ * and on a WebSocket it is the graphql-ws `extra` object, which a guard may annotate the same way
36
+ * (`tenantId`, `tenantRole`, …) — those writes are inert on that path, because the Mongoose plugins
37
+ * read the WebSocket's tenant scope from `RequestContext` instead (see `graphql-ws-context.helper`).
38
+ */
39
+ export interface GuardRequestLike {
40
+ [key: string]: any;
41
+ headers?: Record<string, any>;
42
+ user?: any;
43
+ }
44
+
45
+ /**
46
+ * Is this object plausibly a request/subscription context rather than a resolver root value?
47
+ *
48
+ * The check is on the KEYS a guard actually reads. A payload object that happens to carry a `user`
49
+ * field would otherwise be mistaken for an authenticated request — and a subscription payload very
50
+ * plausibly does (`pubSub.publish('userCreated', user)`), which would turn the PUBLISHED user into
51
+ * the apparent caller. Requiring the graphql-ws marker (`socket` / `request` / `connectionParams`)
52
+ * rules that out: a payload does not carry those.
53
+ */
54
+ function isSubscriptionContext(value: any): boolean {
55
+ return (
56
+ !!value && typeof value === 'object' && ('socket' in value || 'connectionParams' in value || 'request' in value)
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Resolve the object a guard should decide on.
62
+ *
63
+ * Order matters:
64
+ * 1. `ctx.getContext().req` — HTTP GraphQL, and the shape every existing consumer expects.
65
+ * 2. The graphql-ws subscription context itself, when it looks like one (see above).
66
+ * 3. `switchToHttp().getRequest()` — REST controllers. On a GraphQL context this returns the
67
+ * resolver root, which is why it comes LAST and why step 2 must not fall through to it.
68
+ *
69
+ * @returns the request-like object, or `undefined` when the context carries none. A guard must treat
70
+ * `undefined` as "no information", never as "permitted" — that conflation is what this file fixes.
71
+ */
72
+ export function resolveGuardRequest(
73
+ context: ExecutionContext,
74
+ gqlContextOf: (context: ExecutionContext) => any,
75
+ ): GuardRequestLike | undefined {
76
+ let gqlContext: any;
77
+ try {
78
+ gqlContext = gqlContextOf(context);
79
+ } catch {
80
+ gqlContext = undefined;
81
+ }
82
+
83
+ if (gqlContext?.req) {
84
+ return gqlContext.req;
85
+ }
86
+
87
+ if (isSubscriptionContext(gqlContext)) {
88
+ // Normalize the two names the transports use, so a guard reads `headers` unconditionally:
89
+ // `graphql-ws` gets them from CoreModule's onConnect, `subscriptions-transport-ws` from its own,
90
+ // and the raw upgrade request is the fallback for a custom onConnect that forwards neither.
91
+ if (!gqlContext.headers) {
92
+ gqlContext.headers = gqlContext.connectionParams ?? gqlContext.request?.headers ?? {};
93
+ }
94
+ return gqlContext;
95
+ }
96
+
97
+ try {
98
+ return context.switchToHttp().getRequest();
99
+ } catch {
100
+ return undefined;
101
+ }
102
+ }