@lenne.tech/nest-server 11.34.0 → 11.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/.claude/rules/architecture.md +3 -1
  2. package/.claude/rules/configurable-features.md +3 -3
  3. package/.claude/rules/role-system.md +281 -0
  4. package/CLAUDE.md +11 -1
  5. package/FRAMEWORK-API.md +7 -3
  6. package/dist/core/common/decorators/nested-type.registry.d.ts +2 -0
  7. package/dist/core/common/decorators/nested-type.registry.js +17 -0
  8. package/dist/core/common/decorators/nested-type.registry.js.map +1 -0
  9. package/dist/core/common/decorators/restricted.decorator.d.ts +2 -1
  10. package/dist/core/common/decorators/restricted.decorator.js +52 -16
  11. package/dist/core/common/decorators/restricted.decorator.js.map +1 -1
  12. package/dist/core/common/decorators/restrictions-checked.marker.d.ts +4 -0
  13. package/dist/core/common/decorators/restrictions-checked.marker.js +30 -0
  14. package/dist/core/common/decorators/restrictions-checked.marker.js.map +1 -0
  15. package/dist/core/common/decorators/unified-field.decorator.d.ts +1 -1
  16. package/dist/core/common/decorators/unified-field.decorator.js +6 -3
  17. package/dist/core/common/decorators/unified-field.decorator.js.map +1 -1
  18. package/dist/core/common/enums/role.enum.d.ts +8 -0
  19. package/dist/core/common/enums/role.enum.js +28 -1
  20. package/dist/core/common/enums/role.enum.js.map +1 -1
  21. package/dist/core/common/helpers/execution-context-request.helper.d.ts +7 -0
  22. package/dist/core/common/helpers/execution-context-request.helper.js +31 -0
  23. package/dist/core/common/helpers/execution-context-request.helper.js.map +1 -0
  24. package/dist/core/common/helpers/graphql-ws-context.helper.d.ts +15 -0
  25. package/dist/core/common/helpers/graphql-ws-context.helper.js +96 -0
  26. package/dist/core/common/helpers/graphql-ws-context.helper.js.map +1 -0
  27. package/dist/core/common/helpers/gridfs.helper.js +1 -1
  28. package/dist/core/common/helpers/gridfs.helper.js.map +1 -1
  29. package/dist/core/common/helpers/validation-message.helper.d.ts +3 -0
  30. package/dist/core/common/helpers/validation-message.helper.js +41 -0
  31. package/dist/core/common/helpers/validation-message.helper.js.map +1 -0
  32. package/dist/core/common/interceptors/check-security.interceptor.js +2 -1
  33. package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
  34. package/dist/core/common/interceptors/response-model.interceptor.js +3 -2
  35. package/dist/core/common/interceptors/response-model.interceptor.js.map +1 -1
  36. package/dist/core/common/interfaces/server-options.interface.d.ts +5 -0
  37. package/dist/core/common/pipes/map-and-validate.pipe.js +16 -6
  38. package/dist/core/common/pipes/map-and-validate.pipe.js.map +1 -1
  39. package/dist/core/common/plugins/mongoose-system-role.plugin.d.ts +1 -0
  40. package/dist/core/common/plugins/mongoose-system-role.plugin.js +106 -0
  41. package/dist/core/common/plugins/mongoose-system-role.plugin.js.map +1 -0
  42. package/dist/core/common/plugins/mongoose-tenant.plugin.js +134 -9
  43. package/dist/core/common/plugins/mongoose-tenant.plugin.js.map +1 -1
  44. package/dist/core/common/services/core-s3.service.js +4 -1
  45. package/dist/core/common/services/core-s3.service.js.map +1 -1
  46. package/dist/core/common/services/core-tenant-context.registry.d.ts +10 -0
  47. package/dist/core/common/services/core-tenant-context.registry.js +12 -0
  48. package/dist/core/common/services/core-tenant-context.registry.js.map +1 -0
  49. package/dist/core/modules/auth/guards/roles.guard.d.ts +1 -1
  50. package/dist/core/modules/auth/guards/roles.guard.js +2 -2
  51. package/dist/core/modules/auth/guards/roles.guard.js.map +1 -1
  52. package/dist/core/modules/better-auth/better-auth-roles.guard.js +2 -10
  53. package/dist/core/modules/better-auth/better-auth-roles.guard.js.map +1 -1
  54. package/dist/core/modules/file/core-file.service.d.ts +3 -1
  55. package/dist/core/modules/file/core-file.service.js +62 -7
  56. package/dist/core/modules/file/core-file.service.js.map +1 -1
  57. package/dist/core/modules/file/file-access.helper.d.ts +18 -0
  58. package/dist/core/modules/file/file-access.helper.js +46 -0
  59. package/dist/core/modules/file/file-access.helper.js.map +1 -0
  60. package/dist/core/modules/file/file-metadata.helper.js +1 -1
  61. package/dist/core/modules/file/file-metadata.helper.js.map +1 -1
  62. package/dist/core/modules/file/file-roles.config.d.ts +10 -0
  63. package/dist/core/modules/file/file-roles.config.js +78 -0
  64. package/dist/core/modules/file/file-roles.config.js.map +1 -0
  65. package/dist/core/modules/file/file-roles.helper.d.ts +2 -2
  66. package/dist/core/modules/file/file-roles.helper.js +8 -20
  67. package/dist/core/modules/file/file-roles.helper.js.map +1 -1
  68. package/dist/core/modules/tenant/core-role-scope.registry.d.ts +28 -0
  69. package/dist/core/modules/tenant/core-role-scope.registry.js +93 -0
  70. package/dist/core/modules/tenant/core-role-scope.registry.js.map +1 -0
  71. package/dist/core/modules/tenant/core-tenant.guard.d.ts +5 -0
  72. package/dist/core/modules/tenant/core-tenant.guard.js +37 -11
  73. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  74. package/dist/core/modules/tenant/core-tenant.helpers.d.ts +9 -1
  75. package/dist/core/modules/tenant/core-tenant.helpers.js +65 -4
  76. package/dist/core/modules/tenant/core-tenant.helpers.js.map +1 -1
  77. package/dist/core/modules/tenant/core-tenant.module.js +5 -0
  78. package/dist/core/modules/tenant/core-tenant.module.js.map +1 -1
  79. package/dist/core/modules/tenant/core-tenant.service.d.ts +1 -0
  80. package/dist/core/modules/tenant/core-tenant.service.js +25 -0
  81. package/dist/core/modules/tenant/core-tenant.service.js.map +1 -1
  82. package/dist/core/modules/tus/core-tus.service.d.ts +2 -0
  83. package/dist/core/modules/tus/core-tus.service.js +34 -4
  84. package/dist/core/modules/tus/core-tus.service.js.map +1 -1
  85. package/dist/core/modules/tus/index.d.ts +1 -0
  86. package/dist/core/modules/tus/index.js +1 -0
  87. package/dist/core/modules/tus/index.js.map +1 -1
  88. package/dist/core/modules/tus/tus.constants.d.ts +1 -0
  89. package/dist/core/modules/tus/tus.constants.js +2 -1
  90. package/dist/core/modules/tus/tus.constants.js.map +1 -1
  91. package/dist/core/modules/user/core-user.service.js +5 -0
  92. package/dist/core/modules/user/core-user.service.js.map +1 -1
  93. package/dist/core/modules/user/inputs/core-user.input.js +8 -0
  94. package/dist/core/modules/user/inputs/core-user.input.js.map +1 -1
  95. package/dist/core.module.js +10 -0
  96. package/dist/core.module.js.map +1 -1
  97. package/dist/index.d.ts +3 -0
  98. package/dist/index.js +3 -0
  99. package/dist/index.js.map +1 -1
  100. package/dist/server/modules/file/file.service.js +4 -1
  101. package/dist/server/modules/file/file.service.js.map +1 -1
  102. package/dist/test/test.helper.d.ts +1 -0
  103. package/dist/test/test.helper.js +26 -6
  104. package/dist/test/test.helper.js.map +1 -1
  105. package/dist/tsconfig.build.tsbuildinfo +1 -1
  106. package/docs/REQUEST-LIFECYCLE.md +60 -1
  107. package/migration-guides/11.34.0-to-11.34.1.md +132 -0
  108. package/migration-guides/11.34.x-to-11.35.x.md +832 -0
  109. package/package.json +1 -1
  110. package/src/core/common/decorators/nested-type.registry.ts +42 -0
  111. package/src/core/common/decorators/restricted.decorator.ts +149 -21
  112. package/src/core/common/decorators/restrictions-checked.marker.ts +87 -0
  113. package/src/core/common/decorators/unified-field.decorator.ts +4 -3
  114. package/src/core/common/enums/role.enum.ts +116 -0
  115. package/src/core/common/helpers/execution-context-request.helper.ts +102 -0
  116. package/src/core/common/helpers/graphql-ws-context.helper.ts +192 -0
  117. package/src/core/common/helpers/gridfs.helper.ts +18 -2
  118. package/src/core/common/helpers/validation-message.helper.ts +83 -0
  119. package/src/core/common/interceptors/check-security.interceptor.ts +4 -1
  120. package/src/core/common/interceptors/response-model.interceptor.ts +3 -2
  121. package/src/core/common/interfaces/server-options.interface.ts +139 -7
  122. package/src/core/common/pipes/map-and-validate.pipe.ts +25 -7
  123. package/src/core/common/plugins/mongoose-system-role.plugin.ts +193 -0
  124. package/src/core/common/plugins/mongoose-tenant.plugin.ts +244 -12
  125. package/src/core/common/services/core-s3.service.ts +9 -1
  126. package/src/core/common/services/core-tenant-context.registry.ts +50 -0
  127. package/src/core/modules/auth/guards/roles.guard.ts +7 -5
  128. package/src/core/modules/better-auth/better-auth-roles.guard.ts +7 -13
  129. package/src/core/modules/file/INTEGRATION-CHECKLIST.md +95 -1
  130. package/src/core/modules/file/README.md +61 -0
  131. package/src/core/modules/file/core-file.service.ts +166 -9
  132. package/src/core/modules/file/file-access.helper.ts +149 -0
  133. package/src/core/modules/file/file-metadata.helper.ts +13 -5
  134. package/src/core/modules/file/file-roles.config.ts +202 -0
  135. package/src/core/modules/file/file-roles.helper.ts +11 -40
  136. package/src/core/modules/tenant/README.md +20 -1
  137. package/src/core/modules/tenant/core-role-scope.registry.ts +195 -0
  138. package/src/core/modules/tenant/core-tenant.guard.ts +92 -12
  139. package/src/core/modules/tenant/core-tenant.helpers.ts +139 -7
  140. package/src/core/modules/tenant/core-tenant.module.ts +12 -0
  141. package/src/core/modules/tenant/core-tenant.service.ts +56 -0
  142. package/src/core/modules/tus/README.md +46 -7
  143. package/src/core/modules/tus/core-tus.service.ts +94 -5
  144. package/src/core/modules/tus/index.ts +1 -0
  145. package/src/core/modules/tus/tus.constants.ts +17 -1
  146. package/src/core/modules/user/core-user.service.ts +12 -0
  147. package/src/core/modules/user/inputs/core-user.input.ts +28 -2
  148. package/src/core.module.ts +62 -1
  149. package/src/index.ts +3 -0
  150. package/src/server/modules/file/file.service.ts +45 -2
  151. package/src/test/test.helper.ts +54 -11
@@ -8,6 +8,10 @@ import graphqlUploadExpress = require('graphql-upload/graphqlUploadExpress.js');
8
8
  import mongoose from 'mongoose';
9
9
 
10
10
  import { merge } from './core/common/helpers/config.helper';
11
+ import {
12
+ buildRequestContextAwareExecute,
13
+ buildRequestContextAwareSubscribe,
14
+ } from './core/common/helpers/graphql-ws-context.helper';
11
15
  import { CheckResponseInterceptor } from './core/common/interceptors/check-response.interceptor';
12
16
  import { CheckSecurityInterceptor } from './core/common/interceptors/check-security.interceptor';
13
17
  import { ResponseModelInterceptor } from './core/common/interceptors/response-model.interceptor';
@@ -31,6 +35,7 @@ import { mongooseIdPlugin } from './core/common/plugins/mongoose-id.plugin';
31
35
  import { mongooseAuditFieldsPlugin } from './core/common/plugins/mongoose-audit-fields.plugin';
32
36
  import { mongoosePasswordPlugin } from './core/common/plugins/mongoose-password.plugin';
33
37
  import { mongooseRoleGuardPlugin } from './core/common/plugins/mongoose-role-guard.plugin';
38
+ import { mongooseSystemRolePlugin } from './core/common/plugins/mongoose-system-role.plugin';
34
39
  import { mongooseTenantPlugin } from './core/common/plugins/mongoose-tenant.plugin';
35
40
  import { ConfigService } from './core/common/services/config.service';
36
41
  import { CoreCronJobsInitializer } from './core/common/services/core-cron-jobs.initializer';
@@ -46,7 +51,7 @@ import { CoreBetterAuthUserMapper } from './core/modules/better-auth/core-better
46
51
  import { CoreBetterAuthModule } from './core/modules/better-auth/core-better-auth.module';
47
52
  import { CoreBetterAuthService } from './core/modules/better-auth/core-better-auth.service';
48
53
  import { ErrorCodeModule } from './core/modules/error-code/error-code.module';
49
- import { applyFileRoles } from './core/modules/file/file-roles.helper';
54
+ import { applyFileRoles, warnOnPresignedDownloadsWithRestrictedRoles } from './core/modules/file/file-roles.helper';
50
55
  import { CoreHealthCheckModule } from './core/modules/health-check/core-health-check.module';
51
56
  import { CoreHubModule } from './core/modules/hub/core-hub.module';
52
57
  import { isHubEnabled, isHubQueriesEnabled } from './core/modules/hub/hub-config.helper';
@@ -253,6 +258,12 @@ export class CoreModule implements NestModule {
253
258
  if (config.security?.mongooseRoleGuardPlugin !== false) {
254
259
  connection.plugin(mongooseRoleGuardPlugin);
255
260
  }
261
+ // Refuse to store system roles (s_*) in a roles array. Deliberately NOT configurable and
262
+ // registered independently of the role guard above: that one decides WHO may change roles
263
+ // (and can be switched off), this one decides WHICH values may exist at all. A stored
264
+ // 's_self' satisfies every S_SELF check on arbitrary users, so there is no configuration
265
+ // under which writing one is legitimate.
266
+ connection.plugin(mongooseSystemRolePlugin);
256
267
  // Add audit fields plugin (enabled by default, opt-out via config)
257
268
  if (config.security?.mongooseAuditFieldsPlugin !== false) {
258
269
  connection.plugin(mongooseAuditFieldsPlugin);
@@ -273,6 +284,11 @@ export class CoreModule implements NestModule {
273
284
  // function and opt out of this; see applyFileRoles() for why.
274
285
  applyFileRoles(config.file);
275
286
 
287
+ // Presigned downloads hand out a bearer URL that ignores roles after it is issued, so pairing
288
+ // them with a restricted downloadRoles is usually an oversight. Warn rather than fail — the
289
+ // combination has legitimate uses, it just should not be reached by accident.
290
+ warnOnPresignedDownloadsWithRestrictedRoles(config.s3, config.file);
291
+
276
292
  // Check secrets
277
293
  const jwtConfig = config.jwt;
278
294
  if (jwtConfig?.secret && jwtConfig.secret && jwtConfig.refresh && jwtConfig.refresh.secret === jwtConfig.secret) {
@@ -593,6 +609,21 @@ export class CoreModule implements NestModule {
593
609
  cors,
594
610
  installSubscriptionHandlers: true,
595
611
  subscriptions: {
612
+ // WEBSOCKET REQUEST CONTEXT. `GqlSubscriptionService` destructures `execute` /
613
+ // `subscribe` from its own options and hands them to BOTH WS transports, so these two
614
+ // are the only place where a whole WS operation is reachable as one call. They must sit
615
+ // INSIDE `subscriptions`: `ApolloDriver.start()` forwards only
616
+ // `{ schema, path, context, ...options.subscriptions }`, so a top-level pair is
617
+ // silently dropped.
618
+ //
619
+ // Without them a WS operation runs with NO `RequestContext` at all — no Express
620
+ // middleware runs on an upgrade, and `CoreTenantGuard.getRequest()` finds no `req` on a
621
+ // subscription context — and `mongooseTenantPlugin` reads "no context" as "system
622
+ // operation, no filter". A tenant-scoped read while delivering a subscription message
623
+ // therefore returned EVERY tenant's rows, with the plugin's safety net unable to
624
+ // notice. HTTP does not pass through these (Apollo runs its own pipeline).
625
+ execute: buildRequestContextAwareExecute(),
626
+ subscribe: buildRequestContextAwareSubscribe(),
596
627
  'graphql-ws': {
597
628
  context: ({ extra }) => extra,
598
629
  onConnect: async (context: Context<any, any>) => {
@@ -688,6 +719,21 @@ export class CoreModule implements NestModule {
688
719
  cors,
689
720
  installSubscriptionHandlers: true,
690
721
  subscriptions: {
722
+ // WEBSOCKET REQUEST CONTEXT. `GqlSubscriptionService` destructures `execute` /
723
+ // `subscribe` from its own options and hands them to BOTH WS transports, so these two
724
+ // are the only place where a whole WS operation is reachable as one call. They must sit
725
+ // INSIDE `subscriptions`: `ApolloDriver.start()` forwards only
726
+ // `{ schema, path, context, ...options.subscriptions }`, so a top-level pair is
727
+ // silently dropped.
728
+ //
729
+ // Without them a WS operation runs with NO `RequestContext` at all — no Express
730
+ // middleware runs on an upgrade, and `CoreTenantGuard.getRequest()` finds no `req` on a
731
+ // subscription context — and `mongooseTenantPlugin` reads "no context" as "system
732
+ // operation, no filter". A tenant-scoped read while delivering a subscription message
733
+ // therefore returned EVERY tenant's rows, with the plugin's safety net unable to
734
+ // notice. HTTP does not pass through these (Apollo runs its own pipeline).
735
+ execute: buildRequestContextAwareExecute(),
736
+ subscribe: buildRequestContextAwareSubscribe(),
691
737
  'graphql-ws': {
692
738
  context: ({ extra }) => extra,
693
739
  onConnect: async (context: Context<any, any>) => {
@@ -800,6 +846,21 @@ export class CoreModule implements NestModule {
800
846
  cors,
801
847
  installSubscriptionHandlers: true,
802
848
  subscriptions: {
849
+ // WEBSOCKET REQUEST CONTEXT. `GqlSubscriptionService` destructures `execute` /
850
+ // `subscribe` from its own options and hands them to BOTH WS transports, so these two
851
+ // are the only place where a whole WS operation is reachable as one call. They must sit
852
+ // INSIDE `subscriptions`: `ApolloDriver.start()` forwards only
853
+ // `{ schema, path, context, ...options.subscriptions }`, so a top-level pair is
854
+ // silently dropped.
855
+ //
856
+ // Without them a WS operation runs with NO `RequestContext` at all — no Express
857
+ // middleware runs on an upgrade, and `CoreTenantGuard.getRequest()` finds no `req` on a
858
+ // subscription context — and `mongooseTenantPlugin` reads "no context" as "system
859
+ // operation, no filter". A tenant-scoped read while delivering a subscription message
860
+ // therefore returned EVERY tenant's rows, with the plugin's safety net unable to
861
+ // notice. HTTP does not pass through these (Apollo runs its own pipeline).
862
+ execute: buildRequestContextAwareExecute(),
863
+ subscribe: buildRequestContextAwareSubscribe(),
803
864
  'graphql-ws': {
804
865
  context: ({ extra }) => extra,
805
866
  onConnect: async (context: Context<any, any>) => {
package/src/index.ts CHANGED
@@ -81,6 +81,7 @@ export * from './core/common/plugins/mongoose-id.plugin';
81
81
  export * from './core/common/plugins/mongoose-audit-fields.plugin';
82
82
  export * from './core/common/plugins/mongoose-password.plugin';
83
83
  export * from './core/common/plugins/mongoose-role-guard.plugin';
84
+ export * from './core/common/plugins/mongoose-system-role.plugin';
84
85
  export * from './core/common/plugins/mongoose-tenant.plugin';
85
86
  export * from './core/common/scalars/any.scalar';
86
87
  export * from './core/common/scalars/date-timestamp.scalar';
@@ -176,6 +177,7 @@ export * from './core/modules/file/core-file-info.model';
176
177
  export * from './core/modules/file/core-file.controller';
177
178
  export * from './core/modules/file/core-file.resolver';
178
179
  export * from './core/modules/file/core-file.service';
180
+ export * from './core/modules/file/file-access.helper';
179
181
  export * from './core/modules/file/file-roles.helper';
180
182
  export * from './core/modules/file/file-storage.helper';
181
183
  export * from './core/modules/file/filesystem-file.helper';
@@ -236,6 +238,7 @@ export * from './core/modules/tenant/core-tenant-member.model';
236
238
  export * from './core/modules/tenant/core-tenant.decorators';
237
239
  export * from './core/modules/tenant/core-tenant.enums';
238
240
  export * from './core/modules/tenant/core-tenant.guard';
241
+ export * from './core/modules/tenant/core-role-scope.registry';
239
242
  export * from './core/modules/tenant/core-tenant.helpers';
240
243
  export * from './core/modules/tenant/core-tenant.module';
241
244
  export * from './core/modules/tenant/core-tenant.service';
@@ -98,12 +98,28 @@ export class FileService extends CoreFileService {
98
98
  * See `src/core/modules/file/README.md` § Access control, and
99
99
  * `tests/file-ownership.e2e-spec.ts` for the end-to-end contract test.
100
100
  */
101
+ /**
102
+ * NOTE FOR CONSUMERS: since 11.35.0 you may not need any of this.
103
+ *
104
+ * `file.access: 'owner'` is exactly the rule below, shipped by the framework — including the parts
105
+ * that are easy to get wrong (fail closed without a user, require the owner field to be PRESENT,
106
+ * cover the by-name branch, refuse a listing) and including the metadata stamping, which this project
107
+ * does by hand in `AvatarController`. `'tenant'` is the same rule against
108
+ * `metadata.tenantId` and the validated `RequestContext` tenant.
109
+ *
110
+ * This override stays because the reference server has to EXERCISE the seam — a rule that lives only
111
+ * in a preset proves the preset works, never that the inheritance point a consuming project extends
112
+ * still does. Keep it here; in your own project, prefer the preset unless your rights are something
113
+ * the framework cannot guess (an explicit read right, a case assignment, a published flag).
114
+ */
101
115
  protected override async checkRights(
102
116
  input: any,
103
117
  options?: FileServiceOptions & { checkInputType: FileInputCheckType },
104
118
  ): Promise<boolean> {
105
- // Writes, list queries and forced (system) calls stay on the coarse role gate.
106
- if (options?.force || (options?.checkInputType !== 'filename' && options?.checkInputType !== 'id')) {
119
+ // Forced (system) calls and WRITES stay on the coarse role gate. Writes deliberately so: an
120
+ // upload has no owner to compare against yet `AvatarController` records `metadata.ownerId` as
121
+ // it writes — so the only meaningful gate there is `file.uploadRoles`.
122
+ if (options?.force || options?.checkInputType === 'file' || options?.checkInputType === 'files') {
107
123
  return true;
108
124
  }
109
125
 
@@ -111,6 +127,33 @@ export class FileService extends CoreFileService {
111
127
  return true;
112
128
  }
113
129
 
130
+ // A LISTING cannot be narrowed by this hook, so it is refused rather than waved through.
131
+ //
132
+ // The hook is asked ONCE for the whole query, not once per row, so there is no answer here that
133
+ // means "…but only their own files". Returning `true` — which this rule used to do, and which is
134
+ // therefore the shape projects copied — hands a non-admin a full inventory of every upload the
135
+ // moment a project surfaces `findFileInfo()`: `CoreFileInfo` carries `filename`, `length`,
136
+ // `uploadDate` and the `id`, and for medical data the filename frequently IS the content.
137
+ //
138
+ // A project that wants "my files" FORCES the constraint server-side and passes `force: true`:
139
+ //
140
+ // this.fileService.findFileInfo(
141
+ // { filterQuery: { 'metadata.ownerId': String(currentUser.id) } },
142
+ // { force: true },
143
+ // );
144
+ //
145
+ // Note what that is NOT: it does not inspect the caller's `filterArgs` to check whether they are
146
+ // already narrowed. `filterArgs` is CLIENT-CONTROLLED, so approving a filter shape means
147
+ // validating attacker input — and any such check is one filter shape away from being wrong.
148
+ // Override the filter; never approve it. See tests/file-ownership.e2e-spec.ts.
149
+ // Explicit, even though falling through would ALSO deny: with `checkInputType: 'filterArgs'` the
150
+ // `input` is a FilterArgs object, so the by-name raw lookup below answers null and the comparison
151
+ // fails. That is an accident of the fall-through, not a decision — it would flip the moment a
152
+ // project's own raw lookup behaved differently for a non-string input. Say it outright instead.
153
+ if (options.checkInputType === 'filterArgs') {
154
+ return false;
155
+ }
156
+
114
157
  // The RAW document on purpose: the public getFileInfo() runs prepareOutput(), which
115
158
  // strips `metadata` — the very field this decision rests on.
116
159
  const raw =
@@ -92,6 +92,16 @@ export interface TestGraphQLOptions {
92
92
  */
93
93
  cookies?: Record<string, string> | string;
94
94
 
95
+ /**
96
+ * Additional `connectionParams` for a SUBSCRIPTION handshake.
97
+ *
98
+ * Merged on top of the `Authorization` entry derived from `token`. A WebSocket carries no HTTP
99
+ * headers per operation, so anything the server reads from a header on the HTTP path — the tenant
100
+ * header above all — has to travel here instead. Without this a tenant-scoped subscription could
101
+ * not be tested at all.
102
+ */
103
+ connectionParams?: Record<string, string>;
104
+
95
105
  /**
96
106
  * Count of subscription messages, specifies how many messages are to be received on subscription
97
107
  */
@@ -788,10 +798,13 @@ export class TestHelper {
788
798
  }
789
799
 
790
800
  // Prepare subscription
791
- let connectionParams;
801
+ let connectionParams: Record<string, string> | undefined;
792
802
  if (options?.token) {
793
803
  connectionParams = { Authorization: `Bearer ${options?.token}` };
794
804
  }
805
+ if (options?.connectionParams) {
806
+ connectionParams = { ...connectionParams, ...options.connectionParams };
807
+ }
795
808
 
796
809
  // Init client
797
810
  if (options.log) {
@@ -800,10 +813,27 @@ export class TestHelper {
800
813
  const client = createClient({ connectionParams, url: this.subscriptionUrl, webSocketImpl: ws });
801
814
  const messages: any[] = [];
802
815
  let unsubscribe: () => void;
816
+ let rejectSubscription: (reason: unknown) => void;
817
+
803
818
  const onNext = (message) => {
804
819
  if (options.log) {
805
820
  console.info('Subscription message', JSON.stringify(message, null, 2));
806
821
  }
822
+
823
+ // A REFUSED subscription arrives as a `next` message carrying `errors`, not through the
824
+ // transport's `error` callback: when a guard throws, graphql's `subscribe()` returns an
825
+ // ExecutionResult instead of an async iterable, and graphql-ws emits that as one payload
826
+ // followed by `complete`. Projecting only `data[name]` therefore turned "you may not subscribe
827
+ // to this" into an indistinguishable `undefined`/`null` message — so a test asserting that an
828
+ // unauthorized subscriber gets nothing passed just as well when the gate was wide open.
829
+ // Rejecting makes the refusal observable, which is what the caller actually experiences.
830
+ if (message?.errors?.length) {
831
+ const detail = message.errors.map((error: any) => error?.message ?? String(error)).join('; ');
832
+ rejectSubscription?.(new Error(`Subscription "${graphql.name}" failed: ${detail}`));
833
+ unsubscribe?.();
834
+ return;
835
+ }
836
+
807
837
  messages.push(message?.data?.[graphql.name]);
808
838
  if (messages.length <= options.countOfSubscriptionMessages) {
809
839
  unsubscribe();
@@ -811,16 +841,29 @@ export class TestHelper {
811
841
  };
812
842
 
813
843
  // Subscribe
814
- await new Promise((resolve, reject) => {
815
- unsubscribe = client.subscribe(
816
- { query },
817
- {
818
- complete: resolve as any,
819
- error: reject,
820
- next: onNext,
821
- },
822
- );
823
- });
844
+ //
845
+ // Disposed in `finally`, including on the refusal path: without it the client keeps its socket
846
+ // (and graphql-ws its reconnect timer) open past the end of the test, which surfaces later as a
847
+ // hanging process or as an error attributed to whichever test happened to run next.
848
+ try {
849
+ await new Promise((resolve, reject) => {
850
+ rejectSubscription = reject;
851
+ unsubscribe = client.subscribe(
852
+ { query },
853
+ {
854
+ complete: resolve as any,
855
+ error: reject,
856
+ next: onNext,
857
+ },
858
+ );
859
+ });
860
+ } finally {
861
+ try {
862
+ await client.dispose?.();
863
+ } catch {
864
+ // Already closing — nothing to clean up, and a teardown error must not mask the result.
865
+ }
866
+ }
824
867
 
825
868
  // Return subscribed messages
826
869
  return messages;