@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
@@ -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
+ }
@@ -1,3 +1,4 @@
1
+ import { BadRequestException } from '@nestjs/common';
1
2
  import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
2
3
  import { randomBytes } from 'crypto';
3
4
  import { diskStorage, memoryStorage } from 'multer';
@@ -147,9 +148,14 @@ function normalizeMimeType(value: string): string {
147
148
  * `options.allowScriptableTypes` is set — that is what closes the hole for
148
149
  * expressions that already exist in consumer projects.
149
150
  *
150
- * Rejections are reported as a real `Error`. Passing a bare string (as this
151
- * helper did before) leaves multer with an "error" that has no `message`, which
152
- * NestJS's `transformException` cannot map to a 4xx.
151
+ * Rejections are reported as a `BadRequestException`, so the caller gets a 400
152
+ * with a readable reason. An `HttpException` is required, not merely tidier:
153
+ * NestJS's `transformException` returns any NON-`HttpException` unchanged — its
154
+ * switch matches only multer's own message constants (`LIMIT_FILE_SIZE`,
155
+ * `LIMIT_UNEXPECTED_FILE`, …), and a message written here matches none of them.
156
+ * A bare `new Error()` therefore still surfaced to the client as a 500, which
157
+ * reads as "the server broke" for what is in fact a refused file. See
158
+ * `tests/unit/file-upload-rejection-status.spec.ts`.
153
159
  */
154
160
  export function multerFileFilter(
155
161
  accept: RegExp | UploadAllowList = IMAGE_UPLOAD_ALLOW_LIST,
@@ -163,7 +169,7 @@ export function multerFileFilter(
163
169
  !options?.allowScriptableTypes &&
164
170
  (SCRIPTABLE_UPLOAD_MIME_TYPES.includes(mimeType) || SCRIPTABLE_UPLOAD_EXTENSIONS.includes(extension))
165
171
  ) {
166
- return cb(new Error(`File upload rejected: ${mimeType || 'unknown type'} may execute as script`));
172
+ return cb(new BadRequestException(`File upload rejected: ${mimeType || 'unknown type'} may execute as script`));
167
173
  }
168
174
 
169
175
  const accepted =
@@ -174,7 +180,7 @@ export function multerFileFilter(
174
180
  if (accepted) {
175
181
  return cb(null, true);
176
182
  }
177
- cb(new Error(`File upload only supports the following filetypes - ${describeAccept(accept)}`));
183
+ cb(new BadRequestException(`File upload only supports the following filetypes - ${describeAccept(accept)}`));
178
184
  };
179
185
  }
180
186
 
@@ -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
 
@@ -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