@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.
- package/.claude/rules/architecture.md +3 -1
- package/.claude/rules/configurable-features.md +3 -3
- package/.claude/rules/role-system.md +281 -0
- package/CLAUDE.md +11 -1
- package/FRAMEWORK-API.md +7 -3
- package/dist/core/common/decorators/nested-type.registry.d.ts +2 -0
- package/dist/core/common/decorators/nested-type.registry.js +17 -0
- package/dist/core/common/decorators/nested-type.registry.js.map +1 -0
- package/dist/core/common/decorators/restricted.decorator.d.ts +2 -1
- package/dist/core/common/decorators/restricted.decorator.js +52 -16
- package/dist/core/common/decorators/restricted.decorator.js.map +1 -1
- package/dist/core/common/decorators/restrictions-checked.marker.d.ts +4 -0
- package/dist/core/common/decorators/restrictions-checked.marker.js +30 -0
- package/dist/core/common/decorators/restrictions-checked.marker.js.map +1 -0
- package/dist/core/common/decorators/unified-field.decorator.d.ts +1 -1
- package/dist/core/common/decorators/unified-field.decorator.js +6 -3
- package/dist/core/common/decorators/unified-field.decorator.js.map +1 -1
- package/dist/core/common/enums/role.enum.d.ts +8 -0
- package/dist/core/common/enums/role.enum.js +28 -1
- package/dist/core/common/enums/role.enum.js.map +1 -1
- package/dist/core/common/helpers/execution-context-request.helper.d.ts +7 -0
- package/dist/core/common/helpers/execution-context-request.helper.js +31 -0
- package/dist/core/common/helpers/execution-context-request.helper.js.map +1 -0
- package/dist/core/common/helpers/file.helper.js +3 -2
- package/dist/core/common/helpers/file.helper.js.map +1 -1
- package/dist/core/common/helpers/graphql-ws-context.helper.d.ts +15 -0
- package/dist/core/common/helpers/graphql-ws-context.helper.js +96 -0
- package/dist/core/common/helpers/graphql-ws-context.helper.js.map +1 -0
- package/dist/core/common/helpers/gridfs.helper.js +1 -1
- package/dist/core/common/helpers/gridfs.helper.js.map +1 -1
- package/dist/core/common/interceptors/check-security.interceptor.js +2 -1
- package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
- package/dist/core/common/interceptors/response-model.interceptor.js +3 -2
- package/dist/core/common/interceptors/response-model.interceptor.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +5 -0
- package/dist/core/common/plugins/mongoose-system-role.plugin.d.ts +1 -0
- package/dist/core/common/plugins/mongoose-system-role.plugin.js +106 -0
- package/dist/core/common/plugins/mongoose-system-role.plugin.js.map +1 -0
- package/dist/core/common/plugins/mongoose-tenant.plugin.js +134 -9
- package/dist/core/common/plugins/mongoose-tenant.plugin.js.map +1 -1
- package/dist/core/common/services/core-s3.service.js +4 -1
- package/dist/core/common/services/core-s3.service.js.map +1 -1
- package/dist/core/common/services/core-tenant-context.registry.d.ts +10 -0
- package/dist/core/common/services/core-tenant-context.registry.js +12 -0
- package/dist/core/common/services/core-tenant-context.registry.js.map +1 -0
- package/dist/core/modules/auth/guards/roles.guard.d.ts +1 -1
- package/dist/core/modules/auth/guards/roles.guard.js +2 -2
- package/dist/core/modules/auth/guards/roles.guard.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth-roles.guard.js +2 -10
- package/dist/core/modules/better-auth/better-auth-roles.guard.js.map +1 -1
- package/dist/core/modules/file/core-file-access-audit.initializer.d.ts +13 -0
- package/dist/core/modules/file/core-file-access-audit.initializer.js +109 -0
- package/dist/core/modules/file/core-file-access-audit.initializer.js.map +1 -0
- package/dist/core/modules/file/core-file.service.d.ts +3 -1
- package/dist/core/modules/file/core-file.service.js +62 -7
- package/dist/core/modules/file/core-file.service.js.map +1 -1
- package/dist/core/modules/file/file-access.helper.d.ts +18 -0
- package/dist/core/modules/file/file-access.helper.js +46 -0
- package/dist/core/modules/file/file-access.helper.js.map +1 -0
- package/dist/core/modules/file/file-metadata.helper.js +1 -1
- package/dist/core/modules/file/file-metadata.helper.js.map +1 -1
- package/dist/core/modules/file/file-roles.config.d.ts +31 -0
- package/dist/core/modules/file/file-roles.config.js +127 -0
- package/dist/core/modules/file/file-roles.config.js.map +1 -0
- package/dist/core/modules/file/file-roles.helper.d.ts +2 -2
- package/dist/core/modules/file/file-roles.helper.js +16 -34
- package/dist/core/modules/file/file-roles.helper.js.map +1 -1
- package/dist/core/modules/tenant/core-role-scope.registry.d.ts +28 -0
- package/dist/core/modules/tenant/core-role-scope.registry.js +93 -0
- package/dist/core/modules/tenant/core-role-scope.registry.js.map +1 -0
- package/dist/core/modules/tenant/core-tenant.guard.d.ts +5 -0
- package/dist/core/modules/tenant/core-tenant.guard.js +37 -11
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.helpers.d.ts +9 -1
- package/dist/core/modules/tenant/core-tenant.helpers.js +65 -4
- package/dist/core/modules/tenant/core-tenant.helpers.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.module.js +5 -0
- package/dist/core/modules/tenant/core-tenant.module.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.service.d.ts +1 -0
- package/dist/core/modules/tenant/core-tenant.service.js +25 -0
- package/dist/core/modules/tenant/core-tenant.service.js.map +1 -1
- package/dist/core/modules/tus/core-tus.service.d.ts +2 -0
- package/dist/core/modules/tus/core-tus.service.js +34 -4
- package/dist/core/modules/tus/core-tus.service.js.map +1 -1
- package/dist/core/modules/tus/index.d.ts +1 -0
- package/dist/core/modules/tus/index.js +1 -0
- package/dist/core/modules/tus/index.js.map +1 -1
- package/dist/core/modules/tus/tus.constants.d.ts +1 -0
- package/dist/core/modules/tus/tus.constants.js +2 -1
- package/dist/core/modules/tus/tus.constants.js.map +1 -1
- package/dist/core/modules/user/core-user.service.js +5 -0
- package/dist/core/modules/user/core-user.service.js.map +1 -1
- package/dist/core/modules/user/inputs/core-user.input.js +8 -0
- package/dist/core/modules/user/inputs/core-user.input.js.map +1 -1
- package/dist/core.module.js +13 -1
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/server/modules/file/file.service.js +4 -1
- package/dist/server/modules/file/file.service.js.map +1 -1
- package/dist/test/test.helper.d.ts +1 -0
- package/dist/test/test.helper.js +26 -6
- package/dist/test/test.helper.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +60 -1
- package/migration-guides/11.34.x-to-11.35.x.md +846 -0
- package/migration-guides/11.35.0-to-11.35.1.md +152 -0
- package/package.json +1 -1
- package/src/core/common/decorators/nested-type.registry.ts +42 -0
- package/src/core/common/decorators/restricted.decorator.ts +149 -21
- package/src/core/common/decorators/restrictions-checked.marker.ts +87 -0
- package/src/core/common/decorators/unified-field.decorator.ts +4 -3
- package/src/core/common/enums/role.enum.ts +116 -0
- package/src/core/common/helpers/execution-context-request.helper.ts +102 -0
- package/src/core/common/helpers/file.helper.ts +11 -5
- package/src/core/common/helpers/graphql-ws-context.helper.ts +192 -0
- package/src/core/common/helpers/gridfs.helper.ts +18 -2
- package/src/core/common/interceptors/check-security.interceptor.ts +4 -1
- package/src/core/common/interceptors/response-model.interceptor.ts +3 -2
- package/src/core/common/interfaces/server-options.interface.ts +139 -7
- package/src/core/common/plugins/mongoose-system-role.plugin.ts +193 -0
- package/src/core/common/plugins/mongoose-tenant.plugin.ts +244 -12
- package/src/core/common/services/core-s3.service.ts +9 -1
- package/src/core/common/services/core-tenant-context.registry.ts +50 -0
- package/src/core/modules/auth/guards/roles.guard.ts +7 -5
- package/src/core/modules/better-auth/better-auth-roles.guard.ts +7 -13
- package/src/core/modules/file/INTEGRATION-CHECKLIST.md +95 -1
- package/src/core/modules/file/README.md +71 -0
- package/src/core/modules/file/core-file-access-audit.initializer.ts +164 -0
- package/src/core/modules/file/core-file.service.ts +166 -9
- package/src/core/modules/file/file-access.helper.ts +149 -0
- package/src/core/modules/file/file-metadata.helper.ts +13 -5
- package/src/core/modules/file/file-roles.config.ts +357 -0
- package/src/core/modules/file/file-roles.helper.ts +24 -60
- package/src/core/modules/tenant/README.md +20 -1
- package/src/core/modules/tenant/core-role-scope.registry.ts +195 -0
- package/src/core/modules/tenant/core-tenant.guard.ts +92 -12
- package/src/core/modules/tenant/core-tenant.helpers.ts +139 -7
- package/src/core/modules/tenant/core-tenant.module.ts +12 -0
- package/src/core/modules/tenant/core-tenant.service.ts +56 -0
- package/src/core/modules/tus/README.md +46 -7
- package/src/core/modules/tus/core-tus.service.ts +94 -5
- package/src/core/modules/tus/index.ts +1 -0
- package/src/core/modules/tus/tus.constants.ts +17 -1
- package/src/core/modules/user/core-user.service.ts +12 -0
- package/src/core/modules/user/inputs/core-user.input.ts +28 -2
- package/src/core.module.ts +72 -3
- package/src/index.ts +7 -0
- package/src/server/modules/file/file.service.ts +45 -2
- package/src/test/test.helper.ts +54 -11
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
FilesystemFileHelper,
|
|
19
19
|
} from '../file/filesystem-file.helper';
|
|
20
20
|
import { S3_FILES_COLLECTION, S3FileHelper } from '../file/s3-file.helper';
|
|
21
|
+
import { TUS_OWNER_METADATA_KEY } from './tus.constants';
|
|
21
22
|
import { TusRedisLocker } from './tus-redis-locker';
|
|
22
23
|
import {
|
|
23
24
|
DEFAULT_TUS_ALLOWED_HEADERS,
|
|
@@ -181,6 +182,11 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
|
|
|
181
182
|
const contentType = metadata.filetype || 'application/octet-stream';
|
|
182
183
|
const fileMetadata = {
|
|
183
184
|
originalMetadata: metadata,
|
|
185
|
+
// The key `CoreFileService.checkRights()` documents for a per-file ownership rule. Without it
|
|
186
|
+
// a tus-uploaded file could never satisfy that rule — it failed CLOSED for everyone but
|
|
187
|
+
// ADMIN, so a project following the documented pattern ended up with undownloadable files.
|
|
188
|
+
// Written from the SERVER-recorded owner, never from the client's own metadata.
|
|
189
|
+
...(metadata[TUS_OWNER_METADATA_KEY] ? { ownerId: metadata[TUS_OWNER_METADATA_KEY] } : {}),
|
|
184
190
|
tusUploadId: upload.id,
|
|
185
191
|
uploadedAt: new Date(),
|
|
186
192
|
};
|
|
@@ -460,6 +466,79 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
|
|
|
460
466
|
await this.deleteStagedUpload(upload.id);
|
|
461
467
|
}
|
|
462
468
|
|
|
469
|
+
/**
|
|
470
|
+
* The authenticated caller's id, as the upstream role guard left it on the request.
|
|
471
|
+
*
|
|
472
|
+
* `CoreTusController.handleTus(req, res)` receives the Express request, so `req.user` is whatever
|
|
473
|
+
* the guard put there. But `@tus/server` v2 does NOT hand that object to its hooks: `handle()`
|
|
474
|
+
* converts the Node request into a WHATWG `ServerRequest` (srvx) first, and anything the guard
|
|
475
|
+
* attached lives on the ORIGINAL request, reachable through `runtime.node.req`. Reading only
|
|
476
|
+
* `req.user` therefore finds nothing and every upload silently becomes owner-less — which fails in
|
|
477
|
+
* the permissive direction, so it would not have shown up as an error anywhere.
|
|
478
|
+
*
|
|
479
|
+
* Returns `undefined` for an unauthenticated request, which is a legitimate state: a project may
|
|
480
|
+
* open the gate with `tus.roles: [RoleEnum.S_EVERYONE]` for a public form.
|
|
481
|
+
*
|
|
482
|
+
* Override to read the owner from somewhere else (an API key, a signed form token).
|
|
483
|
+
*/
|
|
484
|
+
protected readRequestUserId(req: any): string | undefined {
|
|
485
|
+
const id = req?.user?.id ?? req?.runtime?.node?.req?.user?.id ?? req?.context?.user?.id;
|
|
486
|
+
return id === undefined || id === null || id === '' ? undefined : String(id);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Refuse a request that names an upload the caller does not own.
|
|
491
|
+
*
|
|
492
|
+
* `tus.roles` is the coarse gate — who may reach the endpoint at all. This is the fine one: HEAD
|
|
493
|
+
* (read the offset), PATCH (APPEND BYTES) and DELETE (terminate) all address an upload by its URL,
|
|
494
|
+
* and without this any authenticated caller who learns an id could resume, overwrite or destroy
|
|
495
|
+
* somebody else's upload. Appending is the sharpest: those bytes are then migrated into the file
|
|
496
|
+
* store under the ORIGINAL uploader's filename, so the attacker's content is served as the victim's.
|
|
497
|
+
*
|
|
498
|
+
* **Refuses with 404, never 403** — the same policy `CoreFileService.checkRights()` states: a
|
|
499
|
+
* refusal must be indistinguishable from an upload that does not exist, or the endpoint becomes an
|
|
500
|
+
* existence oracle for ids that are not secrets.
|
|
501
|
+
*
|
|
502
|
+
* **An OWNER-LESS upload stays reachable by anyone who may reach the endpoint.** That is deliberate
|
|
503
|
+
* and load-bearing for backward compatibility: uploads created before 11.35.0 carry no owner, and
|
|
504
|
+
* so does every upload of a project that deliberately runs the endpoint public. Denying those would
|
|
505
|
+
* turn an upgrade into a fleet-wide breakage of in-flight uploads and break a documented
|
|
506
|
+
* configuration. What is closed is the case that actually leaks: an upload that HAS an owner being
|
|
507
|
+
* touched by somebody else.
|
|
508
|
+
*
|
|
509
|
+
* Override to widen this — e.g. to let a support role resume any upload.
|
|
510
|
+
*/
|
|
511
|
+
protected async assertUploadOwnership(req: any, uploadId: string): Promise<void> {
|
|
512
|
+
// No id: this is a creation (POST) or a server-capability request (OPTIONS). Nothing to own yet.
|
|
513
|
+
if (!uploadId) {
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
let owner: string | undefined;
|
|
518
|
+
try {
|
|
519
|
+
const upload = await (this.tusServer as any)?.datastore?.getUpload?.(uploadId);
|
|
520
|
+
const recorded = upload?.metadata?.[TUS_OWNER_METADATA_KEY];
|
|
521
|
+
owner = recorded === undefined || recorded === null ? undefined : String(recorded);
|
|
522
|
+
} catch {
|
|
523
|
+
// Unknown id, or a store that cannot answer right now. Say nothing and let the tus server
|
|
524
|
+
// produce its own 404 — inventing a refusal here would also refuse legitimate retries during a
|
|
525
|
+
// transient store outage.
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
if (!owner) {
|
|
530
|
+
return; // owner-less upload — see the note above
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const caller = this.readRequestUserId(req);
|
|
534
|
+
if (caller && caller === owner) {
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
this.logger.warn(`Refused tus request for upload ${uploadId}: caller is not its owner`);
|
|
539
|
+
throw Object.assign(new Error('Upload not found'), { body: 'Upload not found', status_code: 404 });
|
|
540
|
+
}
|
|
541
|
+
|
|
463
542
|
/**
|
|
464
543
|
* Validate file type against allowedTypes configuration
|
|
465
544
|
*
|
|
@@ -519,11 +598,20 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
|
|
|
519
598
|
datastore,
|
|
520
599
|
...(locker ? { locker } : {}),
|
|
521
600
|
maxSize: this.config.maxSize,
|
|
522
|
-
|
|
601
|
+
// Every request that names an upload — HEAD (offset), PATCH (append bytes), DELETE
|
|
602
|
+
// (terminate) — is checked against the upload's recorded owner. `tus.roles` alone cannot do
|
|
603
|
+
// this: it decides who may reach the endpoint, not which upload they may touch, and the tus
|
|
604
|
+
// protocol is built around a per-upload URL. Appending is the sharp end — bytes PATCHed into
|
|
605
|
+
// somebody else's upload are migrated into the file store under THEIR filename.
|
|
606
|
+
onIncomingRequest: async (req, uploadId) => this.assertUploadOwnership(req, uploadId),
|
|
607
|
+
onUploadCreate: async (req, upload) => {
|
|
608
|
+
// Record the creator FIRST, so the ownership check below has something to compare against.
|
|
609
|
+
// Overwrites rather than merges: see TUS_OWNER_METADATA_KEY.
|
|
610
|
+
const metadata = { ...upload.metadata, [TUS_OWNER_METADATA_KEY]: this.readRequestUserId(req) ?? null };
|
|
611
|
+
|
|
523
612
|
// Validate file type if allowedTypes is configured
|
|
524
613
|
if (this.config.allowedTypes && this.config.allowedTypes.length > 0) {
|
|
525
|
-
const
|
|
526
|
-
const filetype = metadata.filetype;
|
|
614
|
+
const filetype = this.parseMetadata(upload.metadata).filetype;
|
|
527
615
|
|
|
528
616
|
if (!this.validateFileType(filetype)) {
|
|
529
617
|
const allowedList = this.config.allowedTypes.join(', ');
|
|
@@ -541,8 +629,9 @@ export class CoreTusService implements OnModuleDestroy, OnModuleInit {
|
|
|
541
629
|
}
|
|
542
630
|
}
|
|
543
631
|
|
|
544
|
-
//
|
|
545
|
-
|
|
632
|
+
// Returning the metadata is what PERSISTS the owner — the tus server writes the returned
|
|
633
|
+
// value onto the upload. Returning `{}` here would keep the client's own metadata verbatim.
|
|
634
|
+
return { metadata };
|
|
546
635
|
},
|
|
547
636
|
onUploadFinish: async (_req, upload) => {
|
|
548
637
|
try {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Dependency-injection tokens of the TUS module.
|
|
2
|
+
* Dependency-injection tokens AND cross-file constants of the TUS module.
|
|
3
3
|
*
|
|
4
4
|
* They live in a dedicated, import-free leaf file — never in `tus.module.ts` or a service — so that
|
|
5
5
|
* no file needing a token has to import the module (or vice versa) and close an import cycle.
|
|
@@ -23,3 +23,19 @@
|
|
|
23
23
|
* Injected type: `Required<ITusConfig>`.
|
|
24
24
|
*/
|
|
25
25
|
export const TUS_CONFIG = 'TUS_CONFIG';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Metadata key recording WHO created a tus upload, in the upload's own (staged) metadata.
|
|
29
|
+
*
|
|
30
|
+
* Framework-owned and ALWAYS overwritten in `onUploadCreate` — metadata otherwise arrives from the
|
|
31
|
+
* client in the `Upload-Metadata` header, so a merged value would let a caller declare somebody else
|
|
32
|
+
* as the owner and hand themselves access to the victim's upload URL.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately NOT `ownerId`: that is the key of the FINISHED FILE's metadata (the one
|
|
35
|
+
* `CoreFileService.checkRights()` documents), and keeping the two distinct means a project's own
|
|
36
|
+
* `ownerId` metadata on a file cannot be confused with the upload-time record.
|
|
37
|
+
*
|
|
38
|
+
* It lives here rather than in the service for the same reason the token above does — and because
|
|
39
|
+
* `tests/unit/import-cycle-invariants.spec.ts` enforces exactly that placement.
|
|
40
|
+
*/
|
|
41
|
+
export const TUS_OWNER_METADATA_KEY = 'ltOwnerId';
|
|
@@ -4,6 +4,7 @@ import crypto = require('crypto');
|
|
|
4
4
|
import { sha256 } from 'js-sha256';
|
|
5
5
|
import { Document, Model } from 'mongoose';
|
|
6
6
|
|
|
7
|
+
import { looksLikeSystemRole, SYSTEM_ROLE_PREFIX } from '../../common/enums/role.enum';
|
|
7
8
|
import { assignPlain, prepareServiceOptionsForCreate } from '../../common/helpers/input.helper';
|
|
8
9
|
import { ServiceOptions } from '../../common/interfaces/service-options.interface';
|
|
9
10
|
import { ConfigService } from '../../common/services/config.service';
|
|
@@ -263,6 +264,17 @@ export abstract class CoreUserService<
|
|
|
263
264
|
throw new BadRequestException('Roles contains invalid values');
|
|
264
265
|
}
|
|
265
266
|
|
|
267
|
+
// Reject system roles (s_*). This is the framework's canonical "assign roles" API and it writes
|
|
268
|
+
// straight through findByIdAndUpdate, so neither MapAndValidatePipe nor check() sees the values.
|
|
269
|
+
// mongooseSystemRolePlugin is the backstop below this; the explicit check here exists to fail
|
|
270
|
+
// before the DB round-trip and with a message naming the offending values.
|
|
271
|
+
const systemRoles = roles.filter((role) => looksLikeSystemRole(role));
|
|
272
|
+
if (systemRoles.length) {
|
|
273
|
+
throw new BadRequestException(
|
|
274
|
+
`System roles (${SYSTEM_ROLE_PREFIX}*) must never be stored in user.roles: ${systemRoles.join(', ')}`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
266
278
|
// Update and return user
|
|
267
279
|
return this.process(
|
|
268
280
|
async () => {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { InputType } from '@nestjs/graphql';
|
|
2
|
-
import { IsEmail } from 'class-validator';
|
|
2
|
+
import { IsEmail, IsString, Matches } from 'class-validator';
|
|
3
3
|
|
|
4
4
|
import { Restricted } from '../../../common/decorators/restricted.decorator';
|
|
5
5
|
import { UnifiedField } from '../../../common/decorators/unified-field.decorator';
|
|
6
6
|
import { ProcessType } from '../../../common/enums/process-type.enum';
|
|
7
|
-
import { RoleEnum } from '../../../common/enums/role.enum';
|
|
7
|
+
import { RoleEnum, SYSTEM_ROLE_PREFIX, SYSTEM_ROLE_REJECT_PATTERN } from '../../../common/enums/role.enum';
|
|
8
8
|
import { CoreInput } from '../../../common/inputs/core-input.input';
|
|
9
9
|
|
|
10
10
|
/**
|
|
@@ -50,12 +50,38 @@ export abstract class CoreUserInput extends CoreInput {
|
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
52
|
* Roles of the user
|
|
53
|
+
*
|
|
54
|
+
* System roles (`s_*` prefix, see RoleEnum) are runtime-context checks and must NEVER be stored:
|
|
55
|
+
* `hasRole` is a plain string intersection, so a stored `s_self` would satisfy every S_SELF
|
|
56
|
+
* check (e.g. update/delete of ARBITRARY users) without the account carrying a real role.
|
|
57
|
+
* The @Restricted above limits WHO may send the field; this validator limits WHAT it may hold.
|
|
58
|
+
*
|
|
59
|
+
* A rejected value answers HTTP 400 (`Validation failed for 1 field: roles (matches)`, with the
|
|
60
|
+
* per-field message in the response body). `CoreUserCreateInput` inherits this field, so sign-up
|
|
61
|
+
* and user creation are covered too.
|
|
62
|
+
*
|
|
63
|
+
* This validator is the FIRST of three layers, not the only one — it gives the best error
|
|
64
|
+
* message, but a subclass that redeclares `roles` replaces it (MapAndValidatePipe walks the
|
|
65
|
+
* prototype chain child-first and skips a property once a child class has validated it), and
|
|
66
|
+
* `force: true` / direct Mongoose writes never reach it at all. `CoreUserService.setRoles()` and
|
|
67
|
+
* the unconditional `mongooseSystemRolePlugin` close those paths.
|
|
53
68
|
*/
|
|
54
69
|
@Restricted({ processType: ProcessType.INPUT, roles: RoleEnum.ADMIN })
|
|
55
70
|
@UnifiedField({
|
|
71
|
+
description: 'Roles of the user (system roles with s_ prefix are rejected)',
|
|
56
72
|
isArray: true,
|
|
57
73
|
isOptional: true,
|
|
58
74
|
type: String,
|
|
75
|
+
// `opts` already carries `each: true` for array fields — do not hardcode it, or array-ness has
|
|
76
|
+
// two sources of truth. Note that supplying `validator` REPLACES the built-in `IsString`, so it
|
|
77
|
+
// is re-declared here explicitly rather than relying on `matches()` rejecting non-strings.
|
|
78
|
+
validator: (opts) => [
|
|
79
|
+
IsString(opts),
|
|
80
|
+
Matches(SYSTEM_ROLE_REJECT_PATTERN, {
|
|
81
|
+
...opts,
|
|
82
|
+
message: `System roles (${SYSTEM_ROLE_PREFIX}*) must never be stored in user.roles`,
|
|
83
|
+
}),
|
|
84
|
+
],
|
|
59
85
|
})
|
|
60
86
|
roles?: string[] = undefined;
|
|
61
87
|
|
package/src/core.module.ts
CHANGED
|
@@ -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';
|
|
@@ -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,8 @@ 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 {
|
|
54
|
+
import { CoreFileAccessAuditInitializer } from './core/modules/file/core-file-access-audit.initializer';
|
|
55
|
+
import { applyFileRoles, warnOnPresignedDownloadsWithRestrictedRoles } from './core/modules/file/file-roles.helper';
|
|
50
56
|
import { CoreHealthCheckModule } from './core/modules/health-check/core-health-check.module';
|
|
51
57
|
import { CoreHubModule } from './core/modules/hub/core-hub.module';
|
|
52
58
|
import { isHubEnabled, isHubQueriesEnabled } from './core/modules/hub/hub-config.helper';
|
|
@@ -253,6 +259,12 @@ export class CoreModule implements NestModule {
|
|
|
253
259
|
if (config.security?.mongooseRoleGuardPlugin !== false) {
|
|
254
260
|
connection.plugin(mongooseRoleGuardPlugin);
|
|
255
261
|
}
|
|
262
|
+
// Refuse to store system roles (s_*) in a roles array. Deliberately NOT configurable and
|
|
263
|
+
// registered independently of the role guard above: that one decides WHO may change roles
|
|
264
|
+
// (and can be switched off), this one decides WHICH values may exist at all. A stored
|
|
265
|
+
// 's_self' satisfies every S_SELF check on arbitrary users, so there is no configuration
|
|
266
|
+
// under which writing one is legitimate.
|
|
267
|
+
connection.plugin(mongooseSystemRolePlugin);
|
|
256
268
|
// Add audit fields plugin (enabled by default, opt-out via config)
|
|
257
269
|
if (config.security?.mongooseAuditFieldsPlugin !== false) {
|
|
258
270
|
connection.plugin(mongooseAuditFieldsPlugin);
|
|
@@ -273,6 +285,11 @@ export class CoreModule implements NestModule {
|
|
|
273
285
|
// function and opt out of this; see applyFileRoles() for why.
|
|
274
286
|
applyFileRoles(config.file);
|
|
275
287
|
|
|
288
|
+
// Presigned downloads hand out a bearer URL that ignores roles after it is issued, so pairing
|
|
289
|
+
// them with a restricted downloadRoles is usually an oversight. Warn rather than fail — the
|
|
290
|
+
// combination has legitimate uses, it just should not be reached by accident.
|
|
291
|
+
warnOnPresignedDownloadsWithRestrictedRoles(config.s3, config.file);
|
|
292
|
+
|
|
276
293
|
// Check secrets
|
|
277
294
|
const jwtConfig = config.jwt;
|
|
278
295
|
if (jwtConfig?.secret && jwtConfig.secret && jwtConfig.refresh && jwtConfig.refresh.secret === jwtConfig.secret) {
|
|
@@ -289,6 +306,11 @@ export class CoreModule implements NestModule {
|
|
|
289
306
|
|
|
290
307
|
// Core Services
|
|
291
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,
|
|
292
314
|
CoreRedisService,
|
|
293
315
|
CoreS3Service,
|
|
294
316
|
CoreTrustProxyInitializer,
|
|
@@ -365,7 +387,9 @@ export class CoreModule implements NestModule {
|
|
|
365
387
|
}
|
|
366
388
|
}
|
|
367
389
|
|
|
368
|
-
|
|
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)];
|
|
369
393
|
|
|
370
394
|
if (isGraphQlEnabled && config.graphQl) {
|
|
371
395
|
imports.push(
|
|
@@ -593,6 +617,21 @@ export class CoreModule implements NestModule {
|
|
|
593
617
|
cors,
|
|
594
618
|
installSubscriptionHandlers: true,
|
|
595
619
|
subscriptions: {
|
|
620
|
+
// WEBSOCKET REQUEST CONTEXT. `GqlSubscriptionService` destructures `execute` /
|
|
621
|
+
// `subscribe` from its own options and hands them to BOTH WS transports, so these two
|
|
622
|
+
// are the only place where a whole WS operation is reachable as one call. They must sit
|
|
623
|
+
// INSIDE `subscriptions`: `ApolloDriver.start()` forwards only
|
|
624
|
+
// `{ schema, path, context, ...options.subscriptions }`, so a top-level pair is
|
|
625
|
+
// silently dropped.
|
|
626
|
+
//
|
|
627
|
+
// Without them a WS operation runs with NO `RequestContext` at all — no Express
|
|
628
|
+
// middleware runs on an upgrade, and `CoreTenantGuard.getRequest()` finds no `req` on a
|
|
629
|
+
// subscription context — and `mongooseTenantPlugin` reads "no context" as "system
|
|
630
|
+
// operation, no filter". A tenant-scoped read while delivering a subscription message
|
|
631
|
+
// therefore returned EVERY tenant's rows, with the plugin's safety net unable to
|
|
632
|
+
// notice. HTTP does not pass through these (Apollo runs its own pipeline).
|
|
633
|
+
execute: buildRequestContextAwareExecute(),
|
|
634
|
+
subscribe: buildRequestContextAwareSubscribe(),
|
|
596
635
|
'graphql-ws': {
|
|
597
636
|
context: ({ extra }) => extra,
|
|
598
637
|
onConnect: async (context: Context<any, any>) => {
|
|
@@ -688,6 +727,21 @@ export class CoreModule implements NestModule {
|
|
|
688
727
|
cors,
|
|
689
728
|
installSubscriptionHandlers: true,
|
|
690
729
|
subscriptions: {
|
|
730
|
+
// WEBSOCKET REQUEST CONTEXT. `GqlSubscriptionService` destructures `execute` /
|
|
731
|
+
// `subscribe` from its own options and hands them to BOTH WS transports, so these two
|
|
732
|
+
// are the only place where a whole WS operation is reachable as one call. They must sit
|
|
733
|
+
// INSIDE `subscriptions`: `ApolloDriver.start()` forwards only
|
|
734
|
+
// `{ schema, path, context, ...options.subscriptions }`, so a top-level pair is
|
|
735
|
+
// silently dropped.
|
|
736
|
+
//
|
|
737
|
+
// Without them a WS operation runs with NO `RequestContext` at all — no Express
|
|
738
|
+
// middleware runs on an upgrade, and `CoreTenantGuard.getRequest()` finds no `req` on a
|
|
739
|
+
// subscription context — and `mongooseTenantPlugin` reads "no context" as "system
|
|
740
|
+
// operation, no filter". A tenant-scoped read while delivering a subscription message
|
|
741
|
+
// therefore returned EVERY tenant's rows, with the plugin's safety net unable to
|
|
742
|
+
// notice. HTTP does not pass through these (Apollo runs its own pipeline).
|
|
743
|
+
execute: buildRequestContextAwareExecute(),
|
|
744
|
+
subscribe: buildRequestContextAwareSubscribe(),
|
|
691
745
|
'graphql-ws': {
|
|
692
746
|
context: ({ extra }) => extra,
|
|
693
747
|
onConnect: async (context: Context<any, any>) => {
|
|
@@ -800,6 +854,21 @@ export class CoreModule implements NestModule {
|
|
|
800
854
|
cors,
|
|
801
855
|
installSubscriptionHandlers: true,
|
|
802
856
|
subscriptions: {
|
|
857
|
+
// WEBSOCKET REQUEST CONTEXT. `GqlSubscriptionService` destructures `execute` /
|
|
858
|
+
// `subscribe` from its own options and hands them to BOTH WS transports, so these two
|
|
859
|
+
// are the only place where a whole WS operation is reachable as one call. They must sit
|
|
860
|
+
// INSIDE `subscriptions`: `ApolloDriver.start()` forwards only
|
|
861
|
+
// `{ schema, path, context, ...options.subscriptions }`, so a top-level pair is
|
|
862
|
+
// silently dropped.
|
|
863
|
+
//
|
|
864
|
+
// Without them a WS operation runs with NO `RequestContext` at all — no Express
|
|
865
|
+
// middleware runs on an upgrade, and `CoreTenantGuard.getRequest()` finds no `req` on a
|
|
866
|
+
// subscription context — and `mongooseTenantPlugin` reads "no context" as "system
|
|
867
|
+
// operation, no filter". A tenant-scoped read while delivering a subscription message
|
|
868
|
+
// therefore returned EVERY tenant's rows, with the plugin's safety net unable to
|
|
869
|
+
// notice. HTTP does not pass through these (Apollo runs its own pipeline).
|
|
870
|
+
execute: buildRequestContextAwareExecute(),
|
|
871
|
+
subscribe: buildRequestContextAwareSubscribe(),
|
|
803
872
|
'graphql-ws': {
|
|
804
873
|
context: ({ extra }) => extra,
|
|
805
874
|
onConnect: async (context: Context<any, any>) => {
|
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';
|
|
@@ -81,6 +83,7 @@ export * from './core/common/plugins/mongoose-id.plugin';
|
|
|
81
83
|
export * from './core/common/plugins/mongoose-audit-fields.plugin';
|
|
82
84
|
export * from './core/common/plugins/mongoose-password.plugin';
|
|
83
85
|
export * from './core/common/plugins/mongoose-role-guard.plugin';
|
|
86
|
+
export * from './core/common/plugins/mongoose-system-role.plugin';
|
|
84
87
|
export * from './core/common/plugins/mongoose-tenant.plugin';
|
|
85
88
|
export * from './core/common/scalars/any.scalar';
|
|
86
89
|
export * from './core/common/scalars/date-timestamp.scalar';
|
|
@@ -93,6 +96,7 @@ export * from './core/common/services/core-cron-jobs.service';
|
|
|
93
96
|
export * from './core/common/services/core-redis.service';
|
|
94
97
|
export * from './core/common/services/core-redis-pubsub';
|
|
95
98
|
export * from './core/common/services/core-s3.service';
|
|
99
|
+
export * from './core/common/services/core-tenant-context.registry';
|
|
96
100
|
export * from './core/common/services/rate-limit-store';
|
|
97
101
|
export * from './core/common/services/crud.service';
|
|
98
102
|
export * from './core/common/services/email.service';
|
|
@@ -172,10 +176,12 @@ export * from './core/modules/error-code';
|
|
|
172
176
|
// Core - Modules - File
|
|
173
177
|
// =====================================================================================================================
|
|
174
178
|
|
|
179
|
+
export * from './core/modules/file/core-file-access-audit.initializer';
|
|
175
180
|
export * from './core/modules/file/core-file-info.model';
|
|
176
181
|
export * from './core/modules/file/core-file.controller';
|
|
177
182
|
export * from './core/modules/file/core-file.resolver';
|
|
178
183
|
export * from './core/modules/file/core-file.service';
|
|
184
|
+
export * from './core/modules/file/file-access.helper';
|
|
179
185
|
export * from './core/modules/file/file-roles.helper';
|
|
180
186
|
export * from './core/modules/file/file-storage.helper';
|
|
181
187
|
export * from './core/modules/file/filesystem-file.helper';
|
|
@@ -236,6 +242,7 @@ export * from './core/modules/tenant/core-tenant-member.model';
|
|
|
236
242
|
export * from './core/modules/tenant/core-tenant.decorators';
|
|
237
243
|
export * from './core/modules/tenant/core-tenant.enums';
|
|
238
244
|
export * from './core/modules/tenant/core-tenant.guard';
|
|
245
|
+
export * from './core/modules/tenant/core-role-scope.registry';
|
|
239
246
|
export * from './core/modules/tenant/core-tenant.helpers';
|
|
240
247
|
export * from './core/modules/tenant/core-tenant.module';
|
|
241
248
|
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
|
-
//
|
|
106
|
-
|
|
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 =
|
package/src/test/test.helper.ts
CHANGED
|
@@ -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
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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;
|