@lenne.tech/nest-server 11.35.0 → 11.36.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 (42) hide show
  1. package/.claude/rules/configurable-features.md +2 -1
  2. package/FRAMEWORK-API.md +1 -1
  3. package/dist/core/common/helpers/file.helper.js +3 -2
  4. package/dist/core/common/helpers/file.helper.js.map +1 -1
  5. package/dist/core/common/interfaces/server-options.interface.d.ts +1 -0
  6. package/dist/core/common/services/brevo.service.d.ts +1 -0
  7. package/dist/core/common/services/brevo.service.js +10 -2
  8. package/dist/core/common/services/brevo.service.js.map +1 -1
  9. package/dist/core/modules/better-auth/better-auth.config.js +1 -0
  10. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  11. package/dist/core/modules/better-auth/core-better-auth.controller.js +19 -0
  12. package/dist/core/modules/better-auth/core-better-auth.controller.js.map +1 -1
  13. package/dist/core/modules/file/core-file-access-audit.initializer.d.ts +13 -0
  14. package/dist/core/modules/file/core-file-access-audit.initializer.js +109 -0
  15. package/dist/core/modules/file/core-file-access-audit.initializer.js.map +1 -0
  16. package/dist/core/modules/file/file-roles.config.d.ts +21 -0
  17. package/dist/core/modules/file/file-roles.config.js +51 -2
  18. package/dist/core/modules/file/file-roles.config.js.map +1 -1
  19. package/dist/core/modules/file/file-roles.helper.d.ts +2 -2
  20. package/dist/core/modules/file/file-roles.helper.js +10 -16
  21. package/dist/core/modules/file/file-roles.helper.js.map +1 -1
  22. package/dist/core.module.js +3 -1
  23. package/dist/core.module.js.map +1 -1
  24. package/dist/index.d.ts +4 -0
  25. package/dist/index.js +4 -0
  26. package/dist/index.js.map +1 -1
  27. package/dist/tsconfig.build.tsbuildinfo +1 -1
  28. package/migration-guides/11.34.x-to-11.35.x.md +17 -3
  29. package/migration-guides/11.35.0-to-11.35.1.md +152 -0
  30. package/migration-guides/11.35.1-to-11.36.0.md +168 -0
  31. package/package.json +21 -21
  32. package/src/core/common/helpers/file.helper.ts +11 -5
  33. package/src/core/common/interfaces/server-options.interface.ts +24 -0
  34. package/src/core/common/services/brevo.service.ts +34 -6
  35. package/src/core/modules/better-auth/better-auth.config.ts +4 -0
  36. package/src/core/modules/better-auth/core-better-auth.controller.ts +38 -0
  37. package/src/core/modules/file/README.md +10 -0
  38. package/src/core/modules/file/core-file-access-audit.initializer.ts +164 -0
  39. package/src/core/modules/file/file-roles.config.ts +156 -1
  40. package/src/core/modules/file/file-roles.helper.ts +15 -22
  41. package/src/core.module.ts +10 -2
  42. package/src/index.ts +4 -0
@@ -24,6 +24,7 @@ import {
24
24
  ApiProperty,
25
25
  ApiTags,
26
26
  } from '@nestjs/swagger';
27
+ import { IsBoolean, IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator';
27
28
  import { Request, Response } from 'express';
28
29
 
29
30
  import { Roles } from '../../common/decorators/roles.decorator';
@@ -122,10 +123,18 @@ export class CoreBetterAuthResponse {
122
123
  * Sign-in input DTO
123
124
  */
124
125
  export class CoreBetterAuthSignInInput {
126
+ // Validated, not merely documented. Without these the sign-in endpoint — the
127
+ // most-probed surface of any deployment — answered malformed input with a
128
+ // 500 from the first property read, which tells a client to retry something
129
+ // that can never work and buries real faults in the same bucket.
125
130
  @ApiProperty({ description: 'User email address', example: 'user@example.com' })
131
+ @IsNotEmpty()
132
+ @IsEmail()
126
133
  email: string;
127
134
 
128
135
  @ApiProperty({ description: 'User password' })
136
+ @IsNotEmpty()
137
+ @IsString()
129
138
  password: string;
130
139
  }
131
140
 
@@ -134,15 +143,30 @@ export class CoreBetterAuthSignInInput {
134
143
  */
135
144
  export class CoreBetterAuthSignUpInput {
136
145
  @ApiProperty({ description: 'User email address', example: 'user@example.com' })
146
+ @IsNotEmpty()
147
+ @IsEmail()
137
148
  email: string;
138
149
 
139
150
  @ApiProperty({ description: 'Display name', example: 'John Doe', required: false })
151
+ @IsOptional()
152
+ @IsString()
140
153
  name?: string;
141
154
 
142
155
  @ApiProperty({ description: 'User password (min 8 characters)' })
156
+ @IsNotEmpty()
157
+ @IsString()
143
158
  password: string;
144
159
 
160
+ // Deliberately left without a validator, unlike its three neighbours. Whether this field is
161
+ // REQUIRED is a policy question the framework cannot answer here — it depends on
162
+ // `betterAuth.signUpValidation`, which the handler consults through
163
+ // CoreBetterAuthSignUpValidatorService before anything else happens. A `@IsNotEmpty()` here would
164
+ // hard-code "consent is mandatory" into the DTO and reject a sign-up for a deployment that never
165
+ // asked for consent, with a message that names the wrong cause. The others carry validators
166
+ // because "an email must look like an email" needs no policy.
145
167
  @ApiProperty({ description: 'Whether user accepted terms and privacy policy', required: false })
168
+ @IsBoolean()
169
+ @IsOptional()
146
170
  termsAndPrivacyAccepted?: boolean;
147
171
  }
148
172
 
@@ -329,6 +353,15 @@ export class CoreBetterAuthController {
329
353
  ): Promise<CoreBetterAuthResponse> {
330
354
  this.ensureEnabled();
331
355
 
356
+ // A request with no body at all reaches here as `undefined`, and every line
357
+ // below reads `input.email`. Without this the caller gets a 500 for what is
358
+ // plainly their own malformed request — and a 500 tells a client to retry
359
+ // later, which will never help. The legacy sign-in answered this case with
360
+ // a 400 "Missing input"; keep that contract.
361
+ if (!input) {
362
+ throw new BadRequestException('Missing input');
363
+ }
364
+
332
365
  const api = this.betterAuthService.getApi();
333
366
  if (!api) {
334
367
  throw new BadRequestException(ErrorCode.BETTERAUTH_API_NOT_AVAILABLE);
@@ -494,6 +527,11 @@ export class CoreBetterAuthController {
494
527
  this.ensureEnabled();
495
528
  this.betterAuthService.ensureSignUpEnabled();
496
529
 
530
+ // Same reasoning as in signIn: a body-less request must not become a 500.
531
+ if (!input) {
532
+ throw new BadRequestException('Missing input');
533
+ }
534
+
497
535
  // Validate sign-up input (termsAndPrivacyAccepted is required by default)
498
536
  if (this.signUpValidator) {
499
537
  this.signUpValidator.validateSignUpInput({ termsAndPrivacyAccepted: input.termsAndPrivacyAccepted });
@@ -346,6 +346,16 @@ override async getFileById(@Param('id') id: string, @Res() res: Response) {
346
346
  A class-level `@Roles()` on your subclass cannot relax an inherited member either: the inherited
347
347
  function carries its own handler-level roles, and the two are unioned rather than overridden.
348
348
 
349
+ > **Boot reports this, since 11.35.1 — but it only reports.** `CoreFileAccessAuditInitializer` reads
350
+ > the roles off the class you actually registered and warns when a member is open beyond platform
351
+ > admins for a reason the configuration cannot explain. So a subclass whose `getFileById()` carries
352
+ > `@Roles(RoleEnum.S_EVERYONE)` is now named at startup, with the member and the offending roles.
353
+ >
354
+ > It does **not** correct anything: your `@Roles()` still wins, because overwriting it would silently
355
+ > relax a route you may have pinned on purpose. **If you re-declare a download member, its `@Roles()`
356
+ > is your whole audience gate** — the warning tells you so, it does not close it for you. Inherit the
357
+ > member instead if you want `file.downloadRoles` to govern it.
358
+
349
359
  ### If you override `getFileInfo()`: `GET /files/id/:id` no longer calls it (11.33.0)
350
360
 
351
361
  Up to 11.32.x, `GET /files/id/:id` called the public `CoreFileService.getFileInfo()` and then let
@@ -0,0 +1,164 @@
1
+ import { Injectable, Logger, OnApplicationBootstrap, Optional } from '@nestjs/common';
2
+ import { DiscoveryService } from '@nestjs/core';
3
+
4
+ import { ConfigService } from '../../common/services/config.service';
5
+ import { CoreFileController } from './core-file.controller';
6
+ import { CoreFileResolver } from './core-file.resolver';
7
+ import { CoreFileService } from './core-file.service';
8
+ import {
9
+ FILE_ROLE_MEMBERS,
10
+ FileEndpointClassName,
11
+ ObservedFileHandler,
12
+ warnOnUndecidedEffectiveFileAccess,
13
+ } from './file-roles.config';
14
+
15
+ /**
16
+ * Audits the file endpoints a project ACTUALLY registered, and warns when one of them is open beyond
17
+ * platform admins for a reason the configuration cannot show.
18
+ *
19
+ * WHY THIS CANNOT LIVE WHERE THE OTHER FILE WARNINGS LIVE
20
+ * -------------------------------------------------------
21
+ * `warnOnUndecidedFileAccess()` runs in the `CoreFileService` constructor and reads CONFIGURATION.
22
+ * For an inherited member that is exactly right: `applyFileRoles()` writes the configured roles onto
23
+ * the base-class function, the subclass picks them up through the prototype chain, and configuration
24
+ * and reality agree.
25
+ *
26
+ * They stop agreeing when a project RE-DECLARES a member. Decorator metadata lives on the function
27
+ * object, so an override is a different function carrying its own `@Roles()` — and that is the
28
+ * function Nest registers. Answering "is the gate open?" from configuration then answers a question
29
+ * about a route that is not the one being served. Two consumer projects shipped anonymously readable
30
+ * downloads that way, and nothing said a word.
31
+ *
32
+ * Seeing the real answer needs the registered class, and the registered class only exists once Nest
33
+ * has built its route table. Neither the service constructor nor `CoreModule.forRoot()` — which runs
34
+ * before any of that — can reach it. Hence a provider, and hence `onApplicationBootstrap`.
35
+ *
36
+ * WHY DISCOVERY RATHER THAN A CONFIG OPTION
37
+ * ------------------------------------------
38
+ * The file module ships abstract classes only: there is no `CoreFileModule.forRoot()` to hand a class
39
+ * to, and a class reference cannot travel through `config.env.ts` (it has to survive
40
+ * `NEST_SERVER_CONFIG` / `NSC__*`, i.e. JSON). Asking every consumer to register their controller in
41
+ * a second place would also make the audit opt-in — and an opt-in audit is missing precisely where
42
+ * nobody thought about access control, which is the population it exists for.
43
+ *
44
+ * WARNING ONLY. It never changes a role, and deliberately so: rewriting an override's metadata would
45
+ * silently relax a route the project pinned on purpose, which is the trap that rules out adopting the
46
+ * TUS module's approach here. The audit reports; the project decides.
47
+ *
48
+ * Registered as a `CoreModule` provider; consumers never interact with it.
49
+ */
50
+ @Injectable()
51
+ export class CoreFileAccessAuditInitializer implements OnApplicationBootstrap {
52
+ protected readonly logger = new Logger(CoreFileAccessAuditInitializer.name);
53
+
54
+ constructor(@Optional() protected readonly discoveryService?: DiscoveryService) {}
55
+
56
+ onApplicationBootstrap(): void {
57
+ if (!this.discoveryService) {
58
+ return;
59
+ }
60
+
61
+ const handlers = this.collectHandlers();
62
+ // No file endpoint registered — there is no route to be wrong about, and the configuration-side
63
+ // warning in the service constructor already covers a service-only integration.
64
+ if (!handlers.length) {
65
+ return;
66
+ }
67
+
68
+ const config = ConfigService.configFastButReadOnly;
69
+ warnOnUndecidedEffectiveFileAccess({
70
+ fileConfig: config?.file,
71
+ handlers,
72
+ hasPerFileRule: this.hasPerFileRule(),
73
+ multiTenancyEnabled: !!config?.multiTenancy && config.multiTenancy.enabled !== false,
74
+ });
75
+ }
76
+
77
+ /**
78
+ * The effective roles of every known member on every registered file endpoint class.
79
+ *
80
+ * `proto[method]` resolves through the prototype chain, so it yields the OVERRIDE when there is one
81
+ * and the inherited base function otherwise — which is exactly the function Nest registered.
82
+ */
83
+ protected collectHandlers(): ObservedFileHandler[] {
84
+ const handlers: ObservedFileHandler[] = [];
85
+
86
+ for (const [className, target] of this.registeredEndpointClasses()) {
87
+ for (const member of FILE_ROLE_MEMBERS) {
88
+ if (member.className !== className) {
89
+ continue;
90
+ }
91
+
92
+ const fn = (target.prototype as Record<string, unknown>)?.[member.method];
93
+ if (typeof fn !== 'function') {
94
+ continue;
95
+ }
96
+
97
+ handlers.push({
98
+ key: member.key,
99
+ member: `${target.name}.${member.method}`,
100
+ roles: this.effectiveRoles(fn, target),
101
+ });
102
+ }
103
+ }
104
+
105
+ return handlers;
106
+ }
107
+
108
+ /**
109
+ * The union the guards will compute for a member.
110
+ *
111
+ * Mirrors `mergeRolesMetadata([handlerRoles, classRoles])`: both halves count, so a class-level
112
+ * `@Roles()` on the subclass is seen too. `Reflect.getMetadata` walks the prototype chain for the
113
+ * class, which is what makes an undecorated subclass inherit the core `@Roles(ADMIN)` — the same
114
+ * resolution Nest's `Reflector` performs.
115
+ */
116
+ protected effectiveRoles(fn: unknown, target: Function): string[] {
117
+ const handlerRoles: string[] = Reflect.getMetadata('roles', fn as object) ?? [];
118
+ const classRoles: string[] = Reflect.getMetadata('roles', target) ?? [];
119
+ return [...handlerRoles, ...classRoles];
120
+ }
121
+
122
+ /** Whether the registered `CoreFileService` overrides `checkRights()` — the project wrote a rule. */
123
+ protected hasPerFileRule(): boolean {
124
+ const services = (this.discoveryService?.getProviders() ?? [])
125
+ .map((wrapper) => wrapper.instance)
126
+ .filter((instance): instance is CoreFileService => instance instanceof CoreFileService);
127
+
128
+ return services.some((service) => (service as any).checkRights !== (CoreFileService.prototype as any).checkRights);
129
+ }
130
+
131
+ /**
132
+ * Every registered class that IS one of the two file endpoint classes, deduplicated.
133
+ *
134
+ * Read off the instance rather than the wrapper's `metatype`, because that is the object whose
135
+ * prototype chain answers the `instanceof` question reliably for both controllers and providers.
136
+ */
137
+ protected registeredEndpointClasses(): [FileEndpointClassName, Function][] {
138
+ const found = new Map<Function, FileEndpointClassName>();
139
+
140
+ const consider = (instance: unknown): void => {
141
+ if (!instance || typeof instance !== 'object') {
142
+ return;
143
+ }
144
+ const target = (instance as object).constructor;
145
+ if (typeof target !== 'function' || found.has(target)) {
146
+ return;
147
+ }
148
+ if (instance instanceof CoreFileController) {
149
+ found.set(target, 'CoreFileController');
150
+ } else if (instance instanceof CoreFileResolver) {
151
+ found.set(target, 'CoreFileResolver');
152
+ }
153
+ };
154
+
155
+ for (const wrapper of this.discoveryService?.getControllers() ?? []) {
156
+ consider(wrapper.instance);
157
+ }
158
+ for (const wrapper of this.discoveryService?.getProviders() ?? []) {
159
+ consider(wrapper.instance);
160
+ }
161
+
162
+ return [...found].map(([target, className]) => [className, target]);
163
+ }
164
+ }
@@ -36,6 +36,31 @@ export const FILE_ROLE_DEFAULTS: Record<FileRoleKey, string[]> = {
36
36
  uploadRoles: [RoleEnum.ADMIN],
37
37
  };
38
38
 
39
+ /**
40
+ * Which endpoint member each knob governs, by NAME only.
41
+ *
42
+ * Deliberately strings rather than function references: this file must stay a leaf that imports
43
+ * nothing but enums and interfaces (see the header), and both consumers need the same list —
44
+ * `applyFileRoles()` writes metadata onto these members, and the boot audit reads it back off
45
+ * whichever class the project actually registered. Two hand-maintained copies of that list is
46
+ * exactly how one of them ends up governing a member the other forgot.
47
+ *
48
+ * `getFileInfo` rides with `downloadRoles` rather than getting its own knob: it answers filename,
49
+ * size and content type for a blob, which is the metadata half of a download. Splitting it would let
50
+ * a project accidentally publish the bucket's contents list while believing downloads were closed.
51
+ */
52
+ export const FILE_ROLE_MEMBERS: { className: FileEndpointClassName; key: FileRoleKey; method: string }[] = [
53
+ { className: 'CoreFileController', key: 'downloadRoles', method: 'getFileById' },
54
+ { className: 'CoreFileController', key: 'downloadRoles', method: 'getFile' },
55
+ { className: 'CoreFileResolver', key: 'downloadRoles', method: 'getFileInfo' },
56
+ { className: 'CoreFileResolver', key: 'uploadRoles', method: 'uploadFile' },
57
+ { className: 'CoreFileResolver', key: 'uploadRoles', method: 'uploadFiles' },
58
+ { className: 'CoreFileResolver', key: 'deleteRoles', method: 'deleteFile' },
59
+ ];
60
+
61
+ /** The two core endpoint classes the role knobs govern. */
62
+ export type FileEndpointClassName = 'CoreFileController' | 'CoreFileResolver';
63
+
39
64
  /**
40
65
  * Resolve one knob to the role list that will actually be applied.
41
66
  *
@@ -62,6 +87,21 @@ export function resolveRoles(key: FileRoleKey, config?: IFileConfig): string[] {
62
87
  return configured;
63
88
  }
64
89
 
90
+ /**
91
+ * Did somebody DECLARE the per-file policy?
92
+ *
93
+ * Shared by both file-access warnings on purpose. They must silence on exactly the same conditions,
94
+ * and two copies of that rule is how a third silencer gets added to one and forgotten in the other —
95
+ * at which point the boot audit starts firing on a project that did decide, gets muted, and protects
96
+ * nobody. Keeping it in one place makes that particular drift impossible rather than merely unlikely.
97
+ *
98
+ * `'custom'` does not count: it is the escape hatch that says "I will answer this in code", so the
99
+ * only evidence that somebody actually did is an overridden `checkRights()`.
100
+ */
101
+ export function hasDeclaredFilePolicy(options: { fileConfig?: IFileConfig; hasPerFileRule: boolean }): boolean {
102
+ return options.hasPerFileRule || !!(options.fileConfig?.access && options.fileConfig.access !== 'custom');
103
+ }
104
+
65
105
  /**
66
106
  * Warn when presigned S3 downloads are combined with a restricted `downloadRoles`.
67
107
  *
@@ -152,6 +192,15 @@ export function warnOnPresignedDownloadsWithRestrictedRoles(
152
192
  * public logos, and refusing to start on a configuration that is correct for the second would be
153
193
  * wrong. What it can do is refuse to be silent.
154
194
  *
195
+ * SCOPE — this function reads CONFIGURATION, which is the right source for a member the project
196
+ * INHERITS: `applyFileRoles()` writes the configured roles onto the base-class function, the subclass
197
+ * picks them up through the prototype chain, and config and reality agree. It says nothing about a
198
+ * member the project RE-DECLARES, because an override is a different function carrying its own
199
+ * `@Roles()` and the configuration never reaches that route. That half is covered by
200
+ * {@link warnOnUndecidedEffectiveFileAccess}, driven from `CoreFileAccessAuditInitializer` at
201
+ * bootstrap — the earliest point at which the registered class exists. The two do not overlap: the
202
+ * audit reports only roles this function's source cannot account for.
203
+ *
155
204
  * @param hasPerFileRule whether `CoreFileService.checkRights()` is overridden — the caller knows,
156
205
  * because it has the instance; this helper stays a pure function so it can be unit-tested.
157
206
  * @returns the message, or `undefined` when there is nothing to warn about. Returned as well as
@@ -166,7 +215,7 @@ export function warnOnUndecidedFileAccess(options: {
166
215
  const { fileConfig, hasPerFileRule, multiTenancyEnabled } = options;
167
216
 
168
217
  // (1) and (2): somebody decided.
169
- if (hasPerFileRule || (fileConfig?.access && fileConfig.access !== 'custom')) {
218
+ if (hasDeclaredFilePolicy({ fileConfig, hasPerFileRule })) {
170
219
  return undefined;
171
220
  }
172
221
 
@@ -200,3 +249,109 @@ export function warnOnUndecidedFileAccess(options: {
200
249
  logger.warn(message);
201
250
  return message;
202
251
  }
252
+
253
+ /**
254
+ * One registered endpoint member, as the GUARDS will see it.
255
+ *
256
+ * `roles` is the union of handler-level and class-level metadata, because that is what
257
+ * `mergeRolesMetadata` computes — not the handler alone. A subclass that carries a class-level
258
+ * `@Roles(S_EVERYONE)` widens every member it declares, and reading only the handler would miss it.
259
+ */
260
+ export interface ObservedFileHandler {
261
+ /** which knob governs this member */
262
+ key: FileRoleKey;
263
+ /** `'FileController.getFileById'` — named as REGISTERED, so the operator can go straight to it */
264
+ member: string;
265
+ /** the effective role union the guards will evaluate */
266
+ roles: string[];
267
+ }
268
+
269
+ /**
270
+ * Warn when a REGISTERED file endpoint is open beyond platform admins for a reason the configuration
271
+ * does not explain — i.e. a `@Roles()` written in the project's own subclass.
272
+ *
273
+ * WHY THIS EXISTS SEPARATELY FROM {@link warnOnUndecidedFileAccess}. That one reads CONFIGURATION,
274
+ * which is the right source for the inherited case: `applyFileRoles()` writes the configured roles
275
+ * onto the base-class member, an inheriting subclass picks them up through the prototype chain, and
276
+ * config and reality agree. They stop agreeing the moment a project RE-DECLARES a member. Decorator
277
+ * metadata lives on the function object, so an override is a different function carrying its own
278
+ * roles — and that is the function Nest registers. The configuration never reaches the route.
279
+ *
280
+ * The consequence was a silence exactly where the noise was wanted: a subclassed `getFileById()` with
281
+ * `@Roles(RoleEnum.S_EVERYONE)` serves anonymous downloads while `resolveRoles('downloadRoles', …)`
282
+ * still answers `[ADMIN]`. Two independent consumer projects shipped that, and in both the actually
283
+ * open routes were the ones nothing reported. A warning that is quiet in the dangerous case and loud
284
+ * in the safe one is worse than no warning, because it reads as a clean bill of health.
285
+ *
286
+ * WHAT IT REPORTS — only roles the configuration does not account for:
287
+ *
288
+ * effective = union(handler roles, class roles) // what mergeRolesMetadata gives the guard
289
+ * unexplained = effective − {ADMIN} − configured(key)
290
+ *
291
+ * That subtraction is what keeps this from double-warning. When the widening came from
292
+ * `file.downloadRoles`, {@link warnOnUndecidedFileAccess} has already said so and `unexplained` is
293
+ * empty. When an override widens FURTHER than the configuration does, only the extra roles are
294
+ * named — the part that is genuinely invisible elsewhere.
295
+ *
296
+ * ADMIN is subtracted because both endpoint classes carry a class-level `@Roles(ADMIN)` that the
297
+ * guards union in unconditionally. It is present on every member and never widens anything.
298
+ *
299
+ * The silencers are deliberately identical to {@link warnOnUndecidedFileAccess} — an overridden
300
+ * `checkRights()` or a declared `file.access` means somebody decided, and how they decided is beyond
301
+ * what a boot check can grade. A warning that fires on a correct setup gets muted, and a muted
302
+ * warning protects nobody.
303
+ *
304
+ * @returns the message, or `undefined` when there is nothing to report. Returned as well as logged
305
+ * for the same reason as the other two warnings: the message IS the contract, and a module-private
306
+ * Logger cannot be asserted against from a unit test.
307
+ */
308
+ export function warnOnUndecidedEffectiveFileAccess(options: {
309
+ fileConfig?: IFileConfig;
310
+ handlers: ObservedFileHandler[];
311
+ hasPerFileRule: boolean;
312
+ multiTenancyEnabled: boolean;
313
+ }): string | undefined {
314
+ const { fileConfig, handlers, hasPerFileRule, multiTenancyEnabled } = options;
315
+
316
+ // Somebody decided. The SAME predicate the configuration-side warning uses — see
317
+ // hasDeclaredFilePolicy() for why this must not be a second copy of the rule.
318
+ if (hasDeclaredFilePolicy({ fileConfig, hasPerFileRule })) {
319
+ return undefined;
320
+ }
321
+
322
+ const findings: string[] = [];
323
+ for (const handler of handlers) {
324
+ const configured = resolveRoles(handler.key, fileConfig);
325
+ const unexplained = handler.roles.filter((role) => role !== RoleEnum.ADMIN && !configured.includes(role));
326
+ if (unexplained.length) {
327
+ findings.push(`${handler.member} → ${JSON.stringify([...new Set(unexplained)])}`);
328
+ }
329
+ }
330
+
331
+ if (!findings.length) {
332
+ return undefined;
333
+ }
334
+
335
+ const tenantNote = multiTenancyEnabled
336
+ ? ' multiTenancy is active, and the leak crosses tenants too: the file stores are reached outside ' +
337
+ 'Mongoose, so mongooseTenantPlugin never scopes them and these role names resolve against ' +
338
+ 'user.roles — a GLOBAL attribute — never against membership.role.'
339
+ : '';
340
+
341
+ const message =
342
+ `A registered file endpoint is open beyond platform admins through roles declared in your own ` +
343
+ `class, not through configuration (${findings.join(', ')}), and no per-file policy is declared: ` +
344
+ `file.access is unset and CoreFileService.checkRights() is not overridden. Because the member is ` +
345
+ `RE-DECLARED, file.downloadRoles/uploadRoles/deleteRoles do NOT apply to it — decorator metadata ` +
346
+ `lives on the function object, so your override keeps its own @Roles() and the configuration ` +
347
+ `never reaches the route. Every holder of such a role can therefore read, overwrite or delete ` +
348
+ `EVERY file — and file ids are not secret, they are ENUMERABLE (an ObjectId shares a per-process ` +
349
+ `random part and an incrementing counter, so one own upload reveals the neighbourhood), with no ` +
350
+ `rate limit on the file routes.${tenantNote} Either inherit the member instead of re-declaring ` +
351
+ `it, so the knobs apply, or declare the per-file policy with file.access ` +
352
+ `('public' | 'authenticated' | 'owner' | 'tenant') or an overridden checkRights() — see ` +
353
+ `src/core/modules/file/README.md § Access control.`;
354
+
355
+ logger.warn(message);
356
+ return message;
357
+ }
@@ -3,7 +3,7 @@ import { Logger } from '@nestjs/common';
3
3
  import { IFileConfig } from '../../common/interfaces/server-options.interface';
4
4
  import { CoreFileController } from './core-file.controller';
5
5
  import { CoreFileResolver } from './core-file.resolver';
6
- import { FileRoleKey, resolveRoles } from './file-roles.config';
6
+ import { FILE_ROLE_MEMBERS, FileEndpointClassName, FileRoleKey, resolveRoles } from './file-roles.config';
7
7
 
8
8
  const logger = new Logger('CoreFileRoles');
9
9
 
@@ -12,32 +12,25 @@ const logger = new Logger('CoreFileRoles');
12
12
  // imports the endpoint classes, which inject CoreFileService — see file-roles.config.ts.
13
13
  export {
14
14
  FILE_ROLE_DEFAULTS,
15
+ FILE_ROLE_MEMBERS,
15
16
  resolveRoles,
16
17
  warnOnPresignedDownloadsWithRestrictedRoles,
18
+ warnOnUndecidedEffectiveFileAccess,
17
19
  warnOnUndecidedFileAccess,
18
20
  } from './file-roles.config';
19
- export type { FileRoleKey } from './file-roles.config';
21
+ export type { FileEndpointClassName, FileRoleKey, ObservedFileHandler } from './file-roles.config';
20
22
 
21
23
  /**
22
- * Which member is governed by which knob.
24
+ * The prototypes the member names in {@link FILE_ROLE_MEMBERS} resolve against.
23
25
  *
24
- * `getFileInfo` rides with `downloadRoles` rather than getting its own knob:
25
- * it answers filename, size and content type for a blob, which is the metadata
26
- * half of a download. Splitting it would let a project accidentally publish the
27
- * bucket's contents list while believing downloads were still closed.
26
+ * The NAMES live in `file-roles.config.ts` so the boot audit can share them without importing these
27
+ * classes (that import is what makes this file a non-leaf see the header of `file-roles.config.ts`
28
+ * for the temporal-dead-zone crash it caused). Only the class lookup lives here.
28
29
  */
29
- const ROLE_TARGETS: { key: FileRoleKey; member: string; owner: () => unknown }[] = [
30
- {
31
- key: 'downloadRoles',
32
- member: 'CoreFileController.getFileById',
33
- owner: () => CoreFileController.prototype.getFileById,
34
- },
35
- { key: 'downloadRoles', member: 'CoreFileController.getFile', owner: () => CoreFileController.prototype.getFile },
36
- { key: 'downloadRoles', member: 'CoreFileResolver.getFileInfo', owner: () => CoreFileResolver.prototype.getFileInfo },
37
- { key: 'uploadRoles', member: 'CoreFileResolver.uploadFile', owner: () => CoreFileResolver.prototype.uploadFile },
38
- { key: 'uploadRoles', member: 'CoreFileResolver.uploadFiles', owner: () => CoreFileResolver.prototype.uploadFiles },
39
- { key: 'deleteRoles', member: 'CoreFileResolver.deleteFile', owner: () => CoreFileResolver.prototype.deleteFile },
40
- ];
30
+ const ENDPOINT_PROTOTYPES: Record<FileEndpointClassName, unknown> = {
31
+ CoreFileController: CoreFileController.prototype,
32
+ CoreFileResolver: CoreFileResolver.prototype,
33
+ };
41
34
 
42
35
  /**
43
36
  * Apply the configured file roles to the core file endpoints.
@@ -65,14 +58,14 @@ const ROLE_TARGETS: { key: FileRoleKey; member: string; owner: () => unknown }[]
65
58
  export function applyFileRoles(config?: IFileConfig): void {
66
59
  const resolved = new Map<FileRoleKey, string[]>();
67
60
 
68
- for (const { key, member, owner } of ROLE_TARGETS) {
61
+ for (const { className, key, method } of FILE_ROLE_MEMBERS) {
69
62
  if (!resolved.has(key)) {
70
63
  resolved.set(key, resolveRoles(key, config));
71
64
  }
72
65
 
73
- const target = owner();
66
+ const target = (ENDPOINT_PROTOTYPES[className] as Record<string, unknown>)[method];
74
67
  if (typeof target !== 'function') {
75
- logger.warn(`Cannot apply file.${key}: ${member} is not a function — skipping.`);
68
+ logger.warn(`Cannot apply file.${key}: ${className}.${method} is not a function — skipping.`);
76
69
  continue;
77
70
  }
78
71
 
@@ -1,6 +1,6 @@
1
1
  import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
2
2
  import { DynamicModule, Global, MiddlewareConsumer, Module, NestModule, UnauthorizedException } from '@nestjs/common';
3
- import { APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
3
+ import { APP_INTERCEPTOR, APP_PIPE, DiscoveryModule } from '@nestjs/core';
4
4
  import { GraphQLModule } from '@nestjs/graphql';
5
5
  import { MongooseModule } from '@nestjs/mongoose';
6
6
  import type { Context } from 'graphql-ws';
@@ -51,6 +51,7 @@ import { CoreBetterAuthUserMapper } from './core/modules/better-auth/core-better
51
51
  import { CoreBetterAuthModule } from './core/modules/better-auth/core-better-auth.module';
52
52
  import { CoreBetterAuthService } from './core/modules/better-auth/core-better-auth.service';
53
53
  import { ErrorCodeModule } from './core/modules/error-code/error-code.module';
54
+ import { CoreFileAccessAuditInitializer } from './core/modules/file/core-file-access-audit.initializer';
54
55
  import { applyFileRoles, warnOnPresignedDownloadsWithRestrictedRoles } from './core/modules/file/file-roles.helper';
55
56
  import { CoreHealthCheckModule } from './core/modules/health-check/core-health-check.module';
56
57
  import { CoreHubModule } from './core/modules/hub/core-hub.module';
@@ -305,6 +306,11 @@ export class CoreModule implements NestModule {
305
306
 
306
307
  // Core Services
307
308
  CoreCronJobsInitializer,
309
+ // Audits the file endpoints the project actually REGISTERED. applyFileRoles() above can only
310
+ // reach the base-class members; a subclass that re-declares one keeps its own @Roles() and the
311
+ // configuration never reaches that route. Only a bootstrap-time look at the registered class
312
+ // can tell those apart — see the initializer.
313
+ CoreFileAccessAuditInitializer,
308
314
  CoreRedisService,
309
315
  CoreS3Service,
310
316
  CoreTrustProxyInitializer,
@@ -381,7 +387,9 @@ export class CoreModule implements NestModule {
381
387
  }
382
388
  }
383
389
 
384
- const imports: any[] = [MongooseModule.forRoot(config.mongoose.uri, config.mongoose.options)];
390
+ // DiscoveryModule: CoreFileAccessAuditInitializer needs the registered controller/resolver
391
+ // classes, which only exist once Nest has built its route table.
392
+ const imports: any[] = [DiscoveryModule, MongooseModule.forRoot(config.mongoose.uri, config.mongoose.options)];
385
393
 
386
394
  if (isGraphQlEnabled && config.graphQl) {
387
395
  imports.push(
package/src/index.ts CHANGED
@@ -36,9 +36,11 @@ export * from './core/common/helpers/cookies.helper';
36
36
  export * from './core/common/helpers/graceful-shutdown.helper';
37
37
  export * from './core/common/helpers/db.helper';
38
38
  export * from './core/common/helpers/decorator.helper';
39
+ export * from './core/common/helpers/execution-context-request.helper';
39
40
  export * from './core/common/helpers/file.helper';
40
41
  export * from './core/common/helpers/filter.helper';
41
42
  export * from './core/common/helpers/graphql.helper';
43
+ export * from './core/common/helpers/graphql-ws-context.helper';
42
44
  export * from './core/common/helpers/interceptor.helper';
43
45
  export * from './core/common/helpers/gridfs.helper';
44
46
  export * from './core/common/helpers/input.helper';
@@ -94,6 +96,7 @@ export * from './core/common/services/core-cron-jobs.service';
94
96
  export * from './core/common/services/core-redis.service';
95
97
  export * from './core/common/services/core-redis-pubsub';
96
98
  export * from './core/common/services/core-s3.service';
99
+ export * from './core/common/services/core-tenant-context.registry';
97
100
  export * from './core/common/services/rate-limit-store';
98
101
  export * from './core/common/services/crud.service';
99
102
  export * from './core/common/services/email.service';
@@ -173,6 +176,7 @@ export * from './core/modules/error-code';
173
176
  // Core - Modules - File
174
177
  // =====================================================================================================================
175
178
 
179
+ export * from './core/modules/file/core-file-access-audit.initializer';
176
180
  export * from './core/modules/file/core-file-info.model';
177
181
  export * from './core/modules/file/core-file.controller';
178
182
  export * from './core/modules/file/core-file.resolver';