@lenne.tech/nest-server 11.34.1 → 11.35.1

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/file.helper.js +3 -2
  25. package/dist/core/common/helpers/file.helper.js.map +1 -1
  26. package/dist/core/common/helpers/graphql-ws-context.helper.d.ts +15 -0
  27. package/dist/core/common/helpers/graphql-ws-context.helper.js +96 -0
  28. package/dist/core/common/helpers/graphql-ws-context.helper.js.map +1 -0
  29. package/dist/core/common/helpers/gridfs.helper.js +1 -1
  30. package/dist/core/common/helpers/gridfs.helper.js.map +1 -1
  31. package/dist/core/common/interceptors/check-security.interceptor.js +2 -1
  32. package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
  33. package/dist/core/common/interceptors/response-model.interceptor.js +3 -2
  34. package/dist/core/common/interceptors/response-model.interceptor.js.map +1 -1
  35. package/dist/core/common/interfaces/server-options.interface.d.ts +5 -0
  36. package/dist/core/common/plugins/mongoose-system-role.plugin.d.ts +1 -0
  37. package/dist/core/common/plugins/mongoose-system-role.plugin.js +106 -0
  38. package/dist/core/common/plugins/mongoose-system-role.plugin.js.map +1 -0
  39. package/dist/core/common/plugins/mongoose-tenant.plugin.js +134 -9
  40. package/dist/core/common/plugins/mongoose-tenant.plugin.js.map +1 -1
  41. package/dist/core/common/services/core-s3.service.js +4 -1
  42. package/dist/core/common/services/core-s3.service.js.map +1 -1
  43. package/dist/core/common/services/core-tenant-context.registry.d.ts +10 -0
  44. package/dist/core/common/services/core-tenant-context.registry.js +12 -0
  45. package/dist/core/common/services/core-tenant-context.registry.js.map +1 -0
  46. package/dist/core/modules/auth/guards/roles.guard.d.ts +1 -1
  47. package/dist/core/modules/auth/guards/roles.guard.js +2 -2
  48. package/dist/core/modules/auth/guards/roles.guard.js.map +1 -1
  49. package/dist/core/modules/better-auth/better-auth-roles.guard.js +2 -10
  50. package/dist/core/modules/better-auth/better-auth-roles.guard.js.map +1 -1
  51. package/dist/core/modules/file/core-file-access-audit.initializer.d.ts +13 -0
  52. package/dist/core/modules/file/core-file-access-audit.initializer.js +109 -0
  53. package/dist/core/modules/file/core-file-access-audit.initializer.js.map +1 -0
  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 +31 -0
  63. package/dist/core/modules/file/file-roles.config.js +127 -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 +16 -34
  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 +13 -1
  96. package/dist/core.module.js.map +1 -1
  97. package/dist/index.d.ts +7 -0
  98. package/dist/index.js +7 -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.x-to-11.35.x.md +846 -0
  108. package/migration-guides/11.35.0-to-11.35.1.md +152 -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/file.helper.ts +11 -5
  117. package/src/core/common/helpers/graphql-ws-context.helper.ts +192 -0
  118. package/src/core/common/helpers/gridfs.helper.ts +18 -2
  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/plugins/mongoose-system-role.plugin.ts +193 -0
  123. package/src/core/common/plugins/mongoose-tenant.plugin.ts +244 -12
  124. package/src/core/common/services/core-s3.service.ts +9 -1
  125. package/src/core/common/services/core-tenant-context.registry.ts +50 -0
  126. package/src/core/modules/auth/guards/roles.guard.ts +7 -5
  127. package/src/core/modules/better-auth/better-auth-roles.guard.ts +7 -13
  128. package/src/core/modules/file/INTEGRATION-CHECKLIST.md +95 -1
  129. package/src/core/modules/file/README.md +71 -0
  130. package/src/core/modules/file/core-file-access-audit.initializer.ts +164 -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 +357 -0
  135. package/src/core/modules/file/file-roles.helper.ts +24 -60
  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 +72 -3
  149. package/src/index.ts +7 -0
  150. package/src/server/modules/file/file.service.ts +45 -2
  151. package/src/test/test.helper.ts +54 -11
@@ -0,0 +1,152 @@
1
+ # Migration Guide: 11.35.0 → 11.35.1
2
+
3
+ ## Overview
4
+
5
+ | Category | Details |
6
+ |----------|---------|
7
+ | **Breaking Changes** | None in signatures. One **client-visible** change: a refused upload now answers **400** instead of **500**, with Nest's standard error envelope instead of an internal-error one (§1) |
8
+ | **Bugfixes** | `multerFileFilter` rejections finally map to a 4xx (§1). Seven documented helper names are reachable from the package root for the first time (§2). The file-access boot warning can no longer be fooled by a subclassed controller (§3) |
9
+ | **Migration Effort** | Nothing to configure. Read §1 if a frontend, a test, or an alerting rule branches on the status or body of a rejected upload. **Read §3 if you subclass `CoreFileController` / `CoreFileResolver` and re-declare a member** — you may see a new warning at startup, and it is probably telling you something true |
10
+
11
+ ---
12
+
13
+ ## Quick Migration
14
+
15
+ ```bash
16
+ pnpm update @lenne.tech/nest-server@11.35.1
17
+ pnpm run build
18
+ pnpm test
19
+ ```
20
+
21
+ No configuration change is required, and no source change is required in a typical project.
22
+
23
+ **Vendor-mode projects:** §1 and §2 are single-file and independent —
24
+ `src/core/common/helpers/file.helper.ts` and `src/index.ts`. §3 is **not**: it adds
25
+ `src/core/modules/file/core-file-access-audit.initializer.ts` and changes
26
+ `file-roles.config.ts`, `file-roles.helper.ts` and `core.module.ts` together. Taking the initializer
27
+ without the `core.module.ts` provider registration gives you a file that is never constructed — a
28
+ security check that silently does not run, which is worse than not having it. Sync those four
29
+ together.
30
+
31
+ ---
32
+
33
+ ## 1. A refused upload answers 400, not 500
34
+
35
+ 11.32.4 §8 changed `multerFileFilter`'s rejection from a bare string to `new Error(...)`, intending a
36
+ clean 400. It never produced one.
37
+
38
+ `transformException` in `@nestjs/platform-express` returns any **non-`HttpException`** unchanged. Its
39
+ switch matches only multer's own message constants (`LIMIT_FILE_SIZE`, `LIMIT_UNEXPECTED_FILE`, …),
40
+ and a message this helper writes matches none of them — so the rejection fell off the end of the
41
+ switch, reached Nest's exception layer as a plain error, and the client was told the **server** had
42
+ failed for a file the **client** sent wrong.
43
+
44
+ Both rejection paths are affected and both are fixed: the allow-list refusal, and the
45
+ scriptable-type refusal (`text/html`, `image/svg+xml`) added by 11.32.4 §6.
46
+
47
+ | | Before | After |
48
+ |---|---|---|
49
+ | Status | `500 Internal Server Error` | `400 Bad Request` |
50
+ | Body | internal-error envelope | Nest's standard 400 shape |
51
+ | Message text | unchanged | unchanged — same text, still built from the accept list |
52
+
53
+ **What to check:**
54
+
55
+ - **Frontends** that branch on the status of an upload response. A rejected file was previously
56
+ indistinguishable from a storage outage; it now identifies itself, and the message is worth showing
57
+ to the user.
58
+ - **Tests** pinned to the broken output. A test asserting `500` on a refused upload fails and should
59
+ be updated to `400`.
60
+ - **Alerting.** Refused uploads no longer raise 5xx. If a dashboard counted them, its numbers drop —
61
+ that is the fix, not a regression. Conversely, an alert that was tuned to tolerate that noise can
62
+ be tightened again.
63
+
64
+ A consumer catching the rejection in code is unaffected: `BadRequestException extends HttpException
65
+ extends Error`, so an `instanceof Error` check keeps working.
66
+
67
+ ---
68
+
69
+ ## 2. Seven documented helpers are now exported from the package root
70
+
71
+ The 11.34.x → 11.35.x guide's *New Exports* block lists names that `src/index.ts` never exported, so
72
+ importing them from `@lenne.tech/nest-server` failed to compile:
73
+
74
+ | Name | Module |
75
+ |---|---|
76
+ | `resolveGuardRequest` | `common/helpers/execution-context-request.helper` |
77
+ | `buildRequestContextAwareExecute` | `common/helpers/graphql-ws-context.helper` |
78
+ | `buildRequestContextAwareSubscribe` | same |
79
+ | `createRequestContextAwareExecute` | same |
80
+ | `createRequestContextAwareSubscribe` | same |
81
+ | `getTenantContextResolver` | `common/services/core-tenant-context.registry` |
82
+ | `setTenantContextResolver` | same |
83
+
84
+ Purely additive — three `export * from` lines. Nothing was renamed or removed, and no existing import
85
+ changes meaning.
86
+
87
+ **Who this affected:** npm-mode consumers only, and only those who followed the guide. In-tree and
88
+ vendor-mode consumers import over relative paths and never touched the barrel, which is also why
89
+ nothing in this repo noticed: no framework module, test or e2e spec imports from `src/index.ts`, so a
90
+ helper could be written, wired, documented and shipped with the full suite green.
91
+ `tests/unit/public-api-barrel.spec.ts` now reads the expected names out of the migration guides
92
+ themselves and walks the re-export graph, so a documented-but-unexported name fails at test time
93
+ rather than at a consumer's `import`.
94
+
95
+ ---
96
+
97
+ ## 3. The file-access warning now sees a subclassed controller
98
+
99
+ The boot warning that reports "the file gate is open and nothing declares a per-file policy" read
100
+ **configuration** — `file.downloadRoles` and friends. That is the correct source for a member you
101
+ inherit, and the wrong one for a member you re-declare.
102
+
103
+ ```typescript
104
+ @Controller('files')
105
+ export class FileController extends CoreFileController {
106
+ @Get('id/:id')
107
+ @Roles(RoleEnum.S_EVERYONE) // anonymous downloads
108
+ override async getFileById(...) { return super.getFileById(...); }
109
+ }
110
+ ```
111
+
112
+ `applyFileRoles()` writes the configured roles onto `CoreFileController.prototype.getFileById`, but
113
+ decorator metadata lives on the **function object** — your override is a different function, it keeps
114
+ its own `@Roles()`, and that is the function Nest registers. The configuration never reaches the
115
+ route. The old warning asked `resolveRoles('downloadRoles', config)`, got `[ADMIN]`, and stayed quiet
116
+ while the route served everyone. Two consumer projects shipped exactly that.
117
+
118
+ `CoreFileAccessAuditInitializer` now reads the roles off the class you actually registered, at
119
+ `onApplicationBootstrap` (the earliest point at which the route table exists), and names the member:
120
+
121
+ ```
122
+ A registered file endpoint is open beyond platform admins through roles declared in your own class,
123
+ not through configuration (FileController.getFileById → ["s_everyone"]), and no per-file policy is
124
+ declared: file.access is unset and CoreFileService.checkRights() is not overridden. …
125
+ ```
126
+
127
+ **Nothing changes about who may reach what.** Your `@Roles()` still wins; the audit reports and does
128
+ not correct. That is deliberate: rewriting an override's metadata would silently relax a route a
129
+ project pinned on purpose — which is why the TUS module's approach, where configuration wins over the
130
+ override, was not adopted here.
131
+
132
+ **What to do if the warning appears:**
133
+
134
+ | If the open access is | Then |
135
+ |---|---|
136
+ | intended, with a per-file rule | declare it: `file.access: 'owner' \| 'tenant' \| …`, or override `CoreFileService.checkRights()`. Either silences the warning, for both halves of the check |
137
+ | intended, genuinely public files | nothing is broken — but the warning is the reminder that ids are enumerable and the routes are not rate-limited |
138
+ | **not** intended | inherit the member instead of re-declaring it, so `file.downloadRoles` governs it again |
139
+
140
+ **No new warning if you do not subclass**, and none if you subclass without re-declaring a governed
141
+ member — the audit only reports roles the configuration cannot account for, so it never duplicates
142
+ the existing configuration-side warning.
143
+
144
+ Governed members: `getFileById`, `getFile` (controller); `getFileInfo`, `uploadFile`, `uploadFiles`,
145
+ `deleteFile` (resolver).
146
+
147
+ ---
148
+
149
+ ## Module Documentation
150
+
151
+ - File module, upload filters and access rules: [`src/core/modules/file/README.md`](../src/core/modules/file/README.md)
152
+ - Request lifecycle and exception handling: [`docs/REQUEST-LIFECYCLE.md`](../docs/REQUEST-LIFECYCLE.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lenne.tech/nest-server",
3
- "version": "11.34.1",
3
+ "version": "11.35.1",
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.