@lenne.tech/nest-server 11.32.4 → 11.33.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.
- package/.claude/rules/architecture.md +16 -0
- package/.claude/rules/configurable-features.md +11 -3
- package/.claude/rules/testing.md +101 -1
- package/CLAUDE.md +5 -0
- package/FRAMEWORK-API.md +58 -3
- package/dist/core/common/helpers/content-disposition.helper.d.ts +1 -0
- package/dist/core/common/helpers/content-disposition.helper.js +14 -0
- package/dist/core/common/helpers/content-disposition.helper.js.map +1 -0
- package/dist/core/common/helpers/file.helper.d.ts +7 -0
- package/dist/core/common/helpers/file.helper.js +21 -4
- package/dist/core/common/helpers/file.helper.js.map +1 -1
- package/dist/core/common/helpers/graceful-shutdown.helper.d.ts +2 -0
- package/dist/core/common/helpers/graceful-shutdown.helper.js +55 -0
- package/dist/core/common/helpers/graceful-shutdown.helper.js.map +1 -0
- package/dist/core/common/helpers/project-name.helper.d.ts +2 -0
- package/dist/core/common/helpers/project-name.helper.js +38 -0
- package/dist/core/common/helpers/project-name.helper.js.map +1 -0
- package/dist/core/common/interfaces/cron-job-config.interface.d.ts +1 -0
- package/dist/core/common/interfaces/server-options.interface.d.ts +39 -0
- package/dist/core/common/services/core-cron-jobs.initializer.d.ts +10 -0
- package/dist/core/common/services/core-cron-jobs.initializer.js +46 -0
- package/dist/core/common/services/core-cron-jobs.initializer.js.map +1 -0
- package/dist/core/common/services/core-cron-jobs.registry.d.ts +8 -0
- package/dist/core/common/services/core-cron-jobs.registry.js +12 -0
- package/dist/core/common/services/core-cron-jobs.registry.js.map +1 -0
- package/dist/core/common/services/core-cron-jobs.service.d.ts +37 -7
- package/dist/core/common/services/core-cron-jobs.service.js +278 -27
- package/dist/core/common/services/core-cron-jobs.service.js.map +1 -1
- package/dist/core/common/services/core-redis-pubsub.d.ts +20 -0
- package/dist/core/common/services/core-redis-pubsub.js +112 -0
- package/dist/core/common/services/core-redis-pubsub.js.map +1 -0
- package/dist/core/common/services/core-redis.service.d.ts +25 -0
- package/dist/core/common/services/core-redis.service.js +154 -0
- package/dist/core/common/services/core-redis.service.js.map +1 -0
- package/dist/core/common/services/core-s3.service.d.ts +51 -0
- package/dist/core/common/services/core-s3.service.js +261 -0
- package/dist/core/common/services/core-s3.service.js.map +1 -0
- package/dist/core/common/services/core-trust-proxy.initializer.d.ts +16 -0
- package/dist/core/common/services/core-trust-proxy.initializer.js +77 -0
- package/dist/core/common/services/core-trust-proxy.initializer.js.map +1 -0
- package/dist/core/common/services/rate-limit-store.d.ts +61 -0
- package/dist/core/common/services/rate-limit-store.js +235 -0
- package/dist/core/common/services/rate-limit-store.js.map +1 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.d.ts +19 -3
- package/dist/core/modules/ai/core-ai-mcp.controller.js +148 -18
- package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai.service.d.ts +7 -2
- package/dist/core/modules/ai/services/core-ai.service.js +24 -20
- package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
- package/dist/core/modules/auth/core-auth.module.js +4 -1
- package/dist/core/modules/auth/core-auth.module.js.map +1 -1
- package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.d.ts +1 -1
- package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js +3 -14
- package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js.map +1 -1
- package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.d.ts +12 -10
- package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js +30 -59
- package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +8 -2
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +118 -45
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.d.ts +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js +2 -11
- package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +12 -10
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +30 -85
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +0 -1
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
- package/dist/core/modules/file/core-file.controller.d.ts +11 -2
- package/dist/core/modules/file/core-file.controller.js +52 -15
- package/dist/core/modules/file/core-file.controller.js.map +1 -1
- package/dist/core/modules/file/core-file.resolver.d.ts +4 -4
- package/dist/core/modules/file/core-file.resolver.js +23 -16
- package/dist/core/modules/file/core-file.resolver.js.map +1 -1
- package/dist/core/modules/file/core-file.service.d.ts +41 -7
- package/dist/core/modules/file/core-file.service.js +227 -7
- package/dist/core/modules/file/core-file.service.js.map +1 -1
- package/dist/core/modules/file/file-metadata.helper.d.ts +16 -0
- package/dist/core/modules/file/file-metadata.helper.js +33 -0
- package/dist/core/modules/file/file-metadata.helper.js.map +1 -0
- package/dist/core/modules/file/file-roles.helper.d.ts +4 -0
- package/dist/core/modules/file/file-roles.helper.js +53 -0
- package/dist/core/modules/file/file-roles.helper.js.map +1 -0
- package/dist/core/modules/file/file-storage.helper.d.ts +13 -0
- package/dist/core/modules/file/file-storage.helper.js +65 -0
- package/dist/core/modules/file/file-storage.helper.js.map +1 -0
- package/dist/core/modules/file/filesystem-file.helper.d.ts +22 -0
- package/dist/core/modules/file/filesystem-file.helper.js +77 -0
- package/dist/core/modules/file/filesystem-file.helper.js.map +1 -0
- package/dist/core/modules/file/interfaces/file-service-options.interface.d.ts +1 -0
- package/dist/core/modules/file/interfaces/file-upload.interface.d.ts +7 -1
- package/dist/core/modules/file/s3-file.helper.d.ts +37 -0
- package/dist/core/modules/file/s3-file.helper.js +81 -0
- package/dist/core/modules/file/s3-file.helper.js.map +1 -0
- package/dist/core/modules/hub/core-hub.controller.d.ts +8 -8
- package/dist/core/modules/hub/core-hub.controller.js +24 -22
- package/dist/core/modules/hub/core-hub.controller.js.map +1 -1
- package/dist/core/modules/hub/helpers/hub-mask.helper.js +2 -2
- package/dist/core/modules/hub/helpers/hub-mask.helper.js.map +1 -1
- package/dist/core/modules/hub/hub-buffer.d.ts +23 -0
- package/dist/core/modules/hub/hub-buffer.js +101 -0
- package/dist/core/modules/hub/hub-buffer.js.map +1 -0
- package/dist/core/modules/hub/services/core-hub-mailbox.service.d.ts +7 -5
- package/dist/core/modules/hub/services/core-hub-mailbox.service.js +14 -10
- package/dist/core/modules/hub/services/core-hub-mailbox.service.js.map +1 -1
- package/dist/core/modules/hub/services/hub-log-buffer.service.d.ts +5 -2
- package/dist/core/modules/hub/services/hub-log-buffer.service.js +17 -8
- package/dist/core/modules/hub/services/hub-log-buffer.service.js.map +1 -1
- package/dist/core/modules/hub/services/hub-query-profiler.service.d.ts +4 -2
- package/dist/core/modules/hub/services/hub-query-profiler.service.js +13 -7
- package/dist/core/modules/hub/services/hub-query-profiler.service.js.map +1 -1
- package/dist/core/modules/hub/services/hub-trace-buffer.service.d.ts +4 -2
- package/dist/core/modules/hub/services/hub-trace-buffer.service.js +13 -8
- package/dist/core/modules/hub/services/hub-trace-buffer.service.js.map +1 -1
- package/dist/core/modules/migrate/helpers/migration.helper.d.ts +2 -6
- package/dist/core/modules/migrate/helpers/migration.helper.js +4 -4
- package/dist/core/modules/migrate/helpers/migration.helper.js.map +1 -1
- package/dist/core/modules/migrate/migration-runner.d.ts +1 -0
- package/dist/core/modules/migrate/migration-runner.js +4 -0
- package/dist/core/modules/migrate/migration-runner.js.map +1 -1
- package/dist/core/modules/migrate/mongo-state-store.d.ts +1 -0
- package/dist/core/modules/migrate/mongo-state-store.js +98 -9
- package/dist/core/modules/migrate/mongo-state-store.js.map +1 -1
- package/dist/core/modules/system-setup/core-system-setup.service.d.ts +2 -0
- package/dist/core/modules/system-setup/core-system-setup.service.js +31 -1
- package/dist/core/modules/system-setup/core-system-setup.service.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.guard.d.ts +17 -3
- package/dist/core/modules/tenant/core-tenant.guard.js +92 -15
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.module.js +3 -4
- package/dist/core/modules/tenant/core-tenant.module.js.map +1 -1
- package/dist/core/modules/tus/core-tus.controller.d.ts +2 -0
- package/dist/core/modules/tus/core-tus.controller.js +29 -3
- package/dist/core/modules/tus/core-tus.controller.js.map +1 -1
- package/dist/core/modules/tus/core-tus.service.d.ts +24 -1
- package/dist/core/modules/tus/core-tus.service.js +182 -28
- package/dist/core/modules/tus/core-tus.service.js.map +1 -1
- package/dist/core/modules/tus/interfaces/tus-config.interface.js +3 -0
- package/dist/core/modules/tus/interfaces/tus-config.interface.js.map +1 -1
- package/dist/core/modules/tus/tus-redis-locker.d.ts +25 -0
- package/dist/core/modules/tus/tus-redis-locker.js +87 -0
- package/dist/core/modules/tus/tus-redis-locker.js.map +1 -0
- package/dist/core/modules/tus/tus.module.d.ts +1 -0
- package/dist/core/modules/tus/tus.module.js +30 -4
- package/dist/core/modules/tus/tus.module.js.map +1 -1
- package/dist/core.module.js +18 -1
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/main.js +2 -1
- package/dist/main.js.map +1 -1
- package/dist/server/modules/file/file.controller.js.map +1 -1
- package/dist/server/modules/file/file.resolver.js +1 -12
- package/dist/server/modules/file/file.resolver.js.map +1 -1
- package/dist/server/modules/file/file.service.d.ts +5 -1
- package/dist/server/modules/file/file.service.js +12 -3
- package/dist/server/modules/file/file.service.js.map +1 -1
- package/dist/server/modules/user/avatar.controller.d.ts +5 -1
- package/dist/server/modules/user/avatar.controller.js +26 -10
- package/dist/server/modules/user/avatar.controller.js.map +1 -1
- package/dist/server/modules/user/user.model.js +3 -2
- package/dist/server/modules/user/user.model.js.map +1 -1
- package/dist/server/modules/user/user.module.js +6 -2
- package/dist/server/modules/user/user.module.js.map +1 -1
- package/dist/server/modules/user/user.service.d.ts +1 -1
- package/dist/server/modules/user/user.service.js +6 -13
- package/dist/server/modules/user/user.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +57 -8
- package/docs/security-overrides.md +21 -3
- package/migration-guides/11.32.3-to-11.32.4.md +15 -3
- package/migration-guides/11.32.x-to-11.33.x.md +1551 -0
- package/package.json +74 -42
- package/src/core/common/helpers/content-disposition.helper.ts +90 -0
- package/src/core/common/helpers/file.helper.ts +53 -10
- package/src/core/common/helpers/graceful-shutdown.helper.ts +116 -0
- package/src/core/common/helpers/project-name.helper.ts +71 -0
- package/src/core/common/interfaces/cron-job-config.interface.ts +16 -0
- package/src/core/common/interfaces/server-options.interface.ts +439 -2
- package/src/core/common/services/core-cron-jobs.initializer.ts +39 -0
- package/src/core/common/services/core-cron-jobs.registry.ts +45 -0
- package/src/core/common/services/core-cron-jobs.service.ts +589 -47
- package/src/core/common/services/core-redis-pubsub.ts +183 -0
- package/src/core/common/services/core-redis.service.ts +249 -0
- package/src/core/common/services/core-s3.service.ts +465 -0
- package/src/core/common/services/core-trust-proxy.initializer.ts +112 -0
- package/src/core/common/services/rate-limit-store.ts +492 -0
- package/src/core/modules/ai/README.md +25 -0
- package/src/core/modules/ai/core-ai-mcp.controller.ts +253 -22
- package/src/core/modules/ai/services/core-ai.service.ts +33 -24
- package/src/core/modules/auth/core-auth.module.ts +7 -1
- package/src/core/modules/auth/guards/legacy-auth-rate-limit.guard.ts +12 -21
- package/src/core/modules/auth/services/legacy-auth-rate-limiter.service.ts +49 -93
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +202 -66
- package/src/core/modules/better-auth/core-better-auth-rate-limit.middleware.ts +11 -18
- package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +51 -132
- package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +7 -1
- package/src/core/modules/file/INTEGRATION-CHECKLIST.md +108 -0
- package/src/core/modules/file/README.md +277 -53
- package/src/core/modules/file/core-file.controller.ts +168 -12
- package/src/core/modules/file/core-file.resolver.ts +46 -12
- package/src/core/modules/file/core-file.service.ts +482 -16
- package/src/core/modules/file/file-metadata.helper.ts +142 -0
- package/src/core/modules/file/file-roles.helper.ts +110 -0
- package/src/core/modules/file/file-storage.helper.ts +163 -0
- package/src/core/modules/file/filesystem-file.helper.ts +184 -0
- package/src/core/modules/file/interfaces/file-service-options.interface.ts +14 -0
- package/src/core/modules/file/interfaces/file-upload.interface.ts +17 -1
- package/src/core/modules/file/s3-file.helper.ts +209 -0
- package/src/core/modules/hub/README.md +48 -0
- package/src/core/modules/hub/core-hub.controller.ts +18 -16
- package/src/core/modules/hub/helpers/hub-mask.helper.ts +21 -4
- package/src/core/modules/hub/hub-buffer.ts +198 -0
- package/src/core/modules/hub/services/core-hub-mailbox.service.ts +22 -12
- package/src/core/modules/hub/services/hub-log-buffer.service.ts +24 -8
- package/src/core/modules/hub/services/hub-query-profiler.service.ts +9 -6
- package/src/core/modules/hub/services/hub-trace-buffer.service.ts +14 -9
- package/src/core/modules/migrate/README.md +33 -6
- package/src/core/modules/migrate/helpers/migration.helper.ts +13 -6
- package/src/core/modules/migrate/migration-runner.ts +10 -1
- package/src/core/modules/migrate/mongo-state-store.ts +179 -11
- package/src/core/modules/system-setup/README.md +7 -2
- package/src/core/modules/system-setup/core-system-setup.service.ts +95 -1
- package/src/core/modules/tenant/README.md +21 -1
- package/src/core/modules/tenant/core-tenant.guard.ts +135 -14
- package/src/core/modules/tenant/core-tenant.module.ts +8 -4
- package/src/core/modules/tus/INTEGRATION-CHECKLIST.md +93 -21
- package/src/core/modules/tus/README.md +35 -2
- package/src/core/modules/tus/core-tus.controller.ts +64 -12
- package/src/core/modules/tus/core-tus.service.ts +358 -34
- package/src/core/modules/tus/interfaces/tus-config.interface.ts +9 -0
- package/src/core/modules/tus/tus-redis-locker.ts +139 -0
- package/src/core/modules/tus/tus.module.ts +78 -6
- package/src/core.module.ts +26 -1
- package/src/index.ts +10 -0
- package/src/main.ts +7 -1
- package/src/server/modules/file/file.controller.ts +7 -3
- package/src/server/modules/file/file.resolver.ts +5 -17
- package/src/server/modules/file/file.service.ts +42 -3
- package/src/server/modules/user/avatar.controller.ts +39 -9
- package/src/server/modules/user/user.model.ts +9 -3
- package/src/server/modules/user/user.module.ts +12 -3
- package/src/server/modules/user/user.service.ts +20 -17
- package/src/test/README.md +7 -2
- package/src/core/common/helpers/file.helper.spec.ts +0 -145
- package/src/core/common/helpers/logging.helper.spec.ts +0 -61
- package/src/core/common/helpers/process-diagnostics.helper.spec.ts +0 -310
- package/src/core/common/services/brevo.service.spec.ts +0 -266
- package/src/core/modules/file/core-file.controller.spec.ts +0 -164
- package/src/core/modules/hub/core-hub.module.spec.ts +0 -108
- package/src/core/modules/hub/helpers/hub-command-shape.helper.spec.ts +0 -48
- package/src/core/modules/hub/helpers/hub-mask.helper.spec.ts +0 -67
- package/src/core/modules/hub/helpers/hub-mermaid.helper.spec.ts +0 -61
- package/src/core/modules/hub/helpers/hub-shell.helper.spec.ts +0 -106
- package/src/core/modules/hub/hub-config.helper.spec.ts +0 -108
- package/src/core/modules/hub/hub-ring-buffer.spec.ts +0 -95
- package/src/core/modules/hub/services/core-hub-mailbox.service.spec.ts +0 -116
- package/src/core/modules/hub/services/hub-trace-buffer.service.spec.ts +0 -112
- package/src/core/modules/migrate/helpers/migration.helper.spec.ts +0 -85
|
@@ -8,6 +8,25 @@ import { CoreBetterAuthUserMapper } from '../better-auth/core-better-auth-user.m
|
|
|
8
8
|
import { CoreBetterAuthService } from '../better-auth/core-better-auth.service';
|
|
9
9
|
import { ErrorCode } from '../error-code/error-codes';
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Collection holding the bootstrap claim markers.
|
|
13
|
+
*
|
|
14
|
+
* Native collection access is intentional here: no Mongoose schema exists for it
|
|
15
|
+
* (see docs/native-driver-security.md).
|
|
16
|
+
*/
|
|
17
|
+
const SETUP_LOCK_COLLECTION = 'system-setup-locks';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* How long an initial-admin claim may stand before another replica takes it over.
|
|
21
|
+
*
|
|
22
|
+
* Long enough that a slow but live creation is never stolen, short enough that a crashed
|
|
23
|
+
* claimer does not lock the deployment out for good.
|
|
24
|
+
*/
|
|
25
|
+
const INITIAL_ADMIN_CLAIM_STALE_AFTER_MS = 5 * 60_000;
|
|
26
|
+
|
|
27
|
+
/** `_id` of the marker claiming the initial-admin creation */
|
|
28
|
+
const INITIAL_ADMIN_LOCK_ID = 'initial-admin';
|
|
29
|
+
|
|
11
30
|
/**
|
|
12
31
|
* Input for creating the initial admin user
|
|
13
32
|
*/
|
|
@@ -101,6 +120,10 @@ export class CoreSystemSetupService implements OnApplicationBootstrap {
|
|
|
101
120
|
return;
|
|
102
121
|
}
|
|
103
122
|
|
|
123
|
+
// The atomic claim lives in createInitialAdmin() so that BOTH entry points — this
|
|
124
|
+
// one and the anonymous POST /system-setup/init — serialize on the same marker.
|
|
125
|
+
// Claiming here as well would take the claim twice on this path and leave the
|
|
126
|
+
// public path unguarded.
|
|
104
127
|
try {
|
|
105
128
|
const result = await this.createInitialAdmin({
|
|
106
129
|
email: initialAdmin.email,
|
|
@@ -109,8 +132,11 @@ export class CoreSystemSetupService implements OnApplicationBootstrap {
|
|
|
109
132
|
});
|
|
110
133
|
this.logger.log(`Auto-created initial admin on startup: ${result.email}`);
|
|
111
134
|
} catch (error) {
|
|
135
|
+
// The claim is released by createInitialAdmin() itself, which is the only place
|
|
136
|
+
// that takes it — so a replica that crashed mid-creation cannot block setup on
|
|
137
|
+
// every future boot, and the marker has exactly one owner.
|
|
112
138
|
if (error instanceof ForbiddenException) {
|
|
113
|
-
this.logger.log('Initial admin auto-creation skipped (users already exist)');
|
|
139
|
+
this.logger.log('Initial admin auto-creation skipped (users already exist or claimed elsewhere)');
|
|
114
140
|
} else {
|
|
115
141
|
this.logger.warn(
|
|
116
142
|
`Initial admin auto-creation failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
@@ -119,6 +145,58 @@ export class CoreSystemSetupService implements OnApplicationBootstrap {
|
|
|
119
145
|
}
|
|
120
146
|
}
|
|
121
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Try to claim the initial-admin creation for this instance
|
|
150
|
+
*
|
|
151
|
+
* The upsert on a fixed `_id` is atomic, so exactly one of N concurrently booting
|
|
152
|
+
* replicas sees no previous document and wins the claim.
|
|
153
|
+
*
|
|
154
|
+
* @returns true when this instance may create the initial admin
|
|
155
|
+
*/
|
|
156
|
+
protected async claimInitialAdminSetup(): Promise<boolean> {
|
|
157
|
+
try {
|
|
158
|
+
const collection = this.connection.collection(SETUP_LOCK_COLLECTION);
|
|
159
|
+
|
|
160
|
+
// The marker is a CLAIM, not a permanent record: a replica SIGKILLed between claiming and
|
|
161
|
+
// creating (OOM, node drain, a failed first rollout — all ordinary) never reaches the
|
|
162
|
+
// release in the catch below. Without an expiry that leaves a deployment with zero users
|
|
163
|
+
// and no way in, recoverable only by deleting a document from an undocumented collection.
|
|
164
|
+
// A stale claim is therefore taken over rather than obeyed.
|
|
165
|
+
const staleBefore = new Date(Date.now() - INITIAL_ADMIN_CLAIM_STALE_AFTER_MS);
|
|
166
|
+
await collection.deleteOne({ _id: INITIAL_ADMIN_LOCK_ID as any, claimedAt: { $lt: staleBefore } });
|
|
167
|
+
|
|
168
|
+
const previous = await collection.findOneAndUpdate(
|
|
169
|
+
{ _id: INITIAL_ADMIN_LOCK_ID as any },
|
|
170
|
+
{ $setOnInsert: { claimedAt: new Date() } },
|
|
171
|
+
{ returnDocument: 'before', upsert: true },
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
// No previous document → this instance inserted the marker and owns the setup
|
|
175
|
+
return !previous;
|
|
176
|
+
} catch (error) {
|
|
177
|
+
// Two replicas upserting the same `_id` at the very same moment: one insert wins,
|
|
178
|
+
// the other gets a duplicate key error — which means the claim is taken.
|
|
179
|
+
if (error instanceof Error && (error.message?.includes('duplicate key') || error.message?.includes('E11000'))) {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Remove the initial-admin claim marker so a later boot can retry the setup
|
|
188
|
+
*/
|
|
189
|
+
protected async releaseInitialAdminSetupClaim(): Promise<void> {
|
|
190
|
+
try {
|
|
191
|
+
await this.connection.collection(SETUP_LOCK_COLLECTION).deleteOne({ _id: INITIAL_ADMIN_LOCK_ID as any });
|
|
192
|
+
} catch (error) {
|
|
193
|
+
// Never mask the failure that triggered the release
|
|
194
|
+
this.logger.warn(
|
|
195
|
+
`Failed to release initial admin setup claim: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
122
200
|
/**
|
|
123
201
|
* Check if the system needs initial setup (zero users)
|
|
124
202
|
*/
|
|
@@ -153,6 +231,17 @@ export class CoreSystemSetupService implements OnApplicationBootstrap {
|
|
|
153
231
|
throw new ForbiddenException(ErrorCode.SYSTEM_SETUP_BETTERAUTH_REQUIRED);
|
|
154
232
|
}
|
|
155
233
|
|
|
234
|
+
// The count check above is check-then-act, and this method is reachable ANONYMOUSLY
|
|
235
|
+
// via POST /system-setup/init. Two concurrent callers with DIFFERENT emails both pass
|
|
236
|
+
// it and both get an admin — the E11000 handler below only catches the same-email
|
|
237
|
+
// case. The claim is the same marker the boot path uses, so the HTTP path and the
|
|
238
|
+
// auto-bootstrap also serialize against each other instead of racing: an attacker
|
|
239
|
+
// racing a fresh deployment can no longer obtain an admin account ALONGSIDE the
|
|
240
|
+
// configured one.
|
|
241
|
+
if (!(await this.claimInitialAdminSetup())) {
|
|
242
|
+
throw new ForbiddenException(ErrorCode.SYSTEM_SETUP_NOT_AVAILABLE);
|
|
243
|
+
}
|
|
244
|
+
|
|
156
245
|
try {
|
|
157
246
|
// Access BetterAuth internal context (same pattern as core-better-auth-api.middleware.ts)
|
|
158
247
|
const context = await authInstance.$context;
|
|
@@ -208,6 +297,11 @@ export class CoreSystemSetupService implements OnApplicationBootstrap {
|
|
|
208
297
|
success: true,
|
|
209
298
|
};
|
|
210
299
|
} catch (error) {
|
|
300
|
+
// Release the claim we took above: a caller that failed mid-creation must not
|
|
301
|
+
// leave a deployment with zero users and no way in. The marker is additionally
|
|
302
|
+
// stale-expiring, so this is belt AND braces for the crash-before-release case.
|
|
303
|
+
await this.releaseInitialAdminSetupClaim();
|
|
304
|
+
|
|
211
305
|
// Handle duplicate email (race condition via MongoDB unique index)
|
|
212
306
|
if (error instanceof Error && (error.message?.includes('duplicate key') || error.message?.includes('E11000'))) {
|
|
213
307
|
throw new ForbiddenException(ErrorCode.SYSTEM_SETUP_NOT_AVAILABLE);
|
|
@@ -238,7 +238,7 @@ multiTenancy: {
|
|
|
238
238
|
- **Config-change detection:** Cache is flushed when `multiTenancy` config changes (e.g., `roleHierarchy` update).
|
|
239
239
|
- **Bounded:** Max 500 entries with FIFO eviction. Memory overhead: ~100-250 KB.
|
|
240
240
|
|
|
241
|
-
**Important:** The cache is process-local.
|
|
241
|
+
**Important:** The cache storage itself is always process-local — every replica keeps its own map. What changed in 11.33.0 is how _invalidations_ travel: with `redis` configured they are broadcast, without it they are not (see below). A membership change that happens **outside** `invalidateUser()` / `invalidateAll()` — a direct DB write, an admin tool, a migration — is still invisible to every replica until `cacheTtlMs` expires. Set `cacheTtlMs: 0` for security-sensitive deployments.
|
|
242
242
|
|
|
243
243
|
### Manual Cache Invalidation
|
|
244
244
|
|
|
@@ -256,6 +256,26 @@ export class TenantService extends CoreTenantService {
|
|
|
256
256
|
|
|
257
257
|
Use `invalidateAll()` to flush the entire cache (e.g., after bulk operations).
|
|
258
258
|
|
|
259
|
+
Both are **instance** methods on the singleton `CoreTenantGuard` — inject it and call
|
|
260
|
+
`this.tenantGuard?.invalidateUser(userId)`. There is no static equivalent.
|
|
261
|
+
|
|
262
|
+
#### Cross-replica invalidation (since 11.33.0)
|
|
263
|
+
|
|
264
|
+
`invalidateUser()` / `invalidateAll()` are no longer a purely local clear:
|
|
265
|
+
|
|
266
|
+
| Setup | What happens |
|
|
267
|
+
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
268
|
+
| **`redis` configured** | The local caches are cleared **and** the invalidation is published to `<keyPrefix>:tenant-cache:invalidate`. Every replica subscribed to that channel clears its own caches, so a membership change takes effect fleet-wide within one round trip. |
|
|
269
|
+
| **No `redis`** | Only the calling process clears. Other replicas keep serving the stale membership until `cacheTtlMs` expires — the pre-11.33.0 behaviour. |
|
|
270
|
+
|
|
271
|
+
Mechanism, for anyone extending the guard (`core-tenant.guard.ts`):
|
|
272
|
+
|
|
273
|
+
- The channel is namespaced with the framework's Redis key prefix, so two applications sharing one Redis instance do not clear each other's caches. Two **stages** of the same application do, unless you set `redis.keyPrefix` or `redis.db`.
|
|
274
|
+
- The message is `{ scope: 'user', userId }` or `{ scope: 'all' }`, JSON-encoded.
|
|
275
|
+
- Subscription uses the shared subscriber connection from `CoreRedisService.getSubscriber()`; the listener is held in a field and detached on destroy, because that connection is shared.
|
|
276
|
+
- **A received broadcast clears locally and does NOT re-publish** (`applyInvalidation()` → `clearUser()` / `clearAll()`, the no-broadcast variants). Re-publishing would bounce the message around the cluster forever.
|
|
277
|
+
- **Publishing is fire-and-forget and failures are debug-level only.** A missed broadcast is not an error: it merely leaves the other replicas' caches stale until their TTL — exactly the no-Redis behaviour. Nothing about a request path depends on the broadcast succeeding.
|
|
278
|
+
|
|
259
279
|
### SkipTenantCheck
|
|
260
280
|
|
|
261
281
|
For high-frequency endpoints that don't access tenant-scoped data, use `@SkipTenantCheck()` to avoid the membership lookup entirely.
|
|
@@ -4,7 +4,9 @@ import {
|
|
|
4
4
|
ForbiddenException,
|
|
5
5
|
Injectable,
|
|
6
6
|
Logger,
|
|
7
|
+
OnApplicationBootstrap,
|
|
7
8
|
OnModuleDestroy,
|
|
9
|
+
Optional,
|
|
8
10
|
UnauthorizedException,
|
|
9
11
|
} from '@nestjs/common';
|
|
10
12
|
import { Reflector } from '@nestjs/core';
|
|
@@ -14,6 +16,7 @@ import { Model } from 'mongoose';
|
|
|
14
16
|
|
|
15
17
|
import { RoleEnum } from '../../common/enums/role.enum';
|
|
16
18
|
import { ConfigService } from '../../common/services/config.service';
|
|
19
|
+
import { CoreRedisService } from '../../common/services/core-redis.service';
|
|
17
20
|
import { ErrorCode } from '../error-code/error-codes';
|
|
18
21
|
import { CoreTenantMemberModel } from './core-tenant-member.model';
|
|
19
22
|
import { SKIP_TENANT_CHECK_KEY } from './core-tenant.decorators';
|
|
@@ -42,6 +45,14 @@ interface CachedTenantIds {
|
|
|
42
45
|
ids: string[];
|
|
43
46
|
}
|
|
44
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Cache invalidation message broadcast between replicas via Redis pub/sub
|
|
50
|
+
*/
|
|
51
|
+
interface TenantCacheInvalidation {
|
|
52
|
+
scope: 'all' | 'user';
|
|
53
|
+
userId?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
45
56
|
/**
|
|
46
57
|
* Global guard for multi-tenancy with defense-in-depth security.
|
|
47
58
|
*
|
|
@@ -103,7 +114,7 @@ interface CachedTenantIds {
|
|
|
103
114
|
* - No user + checkable roles → 401 (authentication required)
|
|
104
115
|
*/
|
|
105
116
|
@Injectable()
|
|
106
|
-
export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
|
|
117
|
+
export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnModuleDestroy {
|
|
107
118
|
private readonly logger = new Logger(CoreTenantGuard.name);
|
|
108
119
|
|
|
109
120
|
/**
|
|
@@ -111,6 +122,12 @@ export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
|
|
|
111
122
|
* Key: `${userId}:${tenantId}`, Value: cached membership result with expiry.
|
|
112
123
|
* Eliminates repeated DB queries for the same user+tenant combination.
|
|
113
124
|
*/
|
|
125
|
+
/** Channel this guard subscribed to, so it can unsubscribe again */
|
|
126
|
+
private invalidationChannel?: string;
|
|
127
|
+
|
|
128
|
+
/** The shared-subscriber listener, kept so it can be detached on destroy */
|
|
129
|
+
private invalidationListener?: (channel: string, message: string) => void;
|
|
130
|
+
|
|
114
131
|
private readonly membershipCache = new Map<string, CachedMembership>();
|
|
115
132
|
|
|
116
133
|
/**
|
|
@@ -131,6 +148,7 @@ export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
|
|
|
131
148
|
constructor(
|
|
132
149
|
private readonly reflector: Reflector,
|
|
133
150
|
@InjectModel(TENANT_MEMBER_MODEL_TOKEN) private readonly memberModel: Model<CoreTenantMemberModel>,
|
|
151
|
+
@Optional() protected readonly redisService?: CoreRedisService,
|
|
134
152
|
) {
|
|
135
153
|
// Clean up expired cache entries every 60 seconds
|
|
136
154
|
this.cleanupInterval = setInterval(() => this.evictExpired(), 60_000);
|
|
@@ -139,7 +157,50 @@ export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
|
|
|
139
157
|
}
|
|
140
158
|
}
|
|
141
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Subscribe to the cross-replica cache invalidation channel.
|
|
162
|
+
*
|
|
163
|
+
* Runs after ALL onModuleInit hooks so that CoreRedisService is connected.
|
|
164
|
+
* Without Redis this is a no-op and the caches stay process-local.
|
|
165
|
+
*/
|
|
166
|
+
async onApplicationBootstrap(): Promise<void> {
|
|
167
|
+
if (!this.redisService?.enabled) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const channel = this.redisService.key('tenant-cache', 'invalidate');
|
|
171
|
+
try {
|
|
172
|
+
const subscriber = this.redisService.getSubscriber();
|
|
173
|
+
// Held in a field, not an anonymous closure: the subscriber connection is SHARED between
|
|
174
|
+
// features, so a listener that is never detached keeps dispatching into a destroyed
|
|
175
|
+
// guard's caches for the life of the process — and a second app in the same process
|
|
176
|
+
// inherits the previous one's listener alongside its own.
|
|
177
|
+
this.invalidationChannel = channel;
|
|
178
|
+
this.invalidationListener = (incomingChannel: string, message: string) => {
|
|
179
|
+
if (incomingChannel === channel) {
|
|
180
|
+
this.applyInvalidation(message);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
subscriber.on('message', this.invalidationListener);
|
|
184
|
+
await subscriber.subscribe(channel);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
this.logger.debug(`Tenant cache invalidation subscribe failed: ${(error as Error).message}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
142
190
|
onModuleDestroy(): void {
|
|
191
|
+
if (this.invalidationListener) {
|
|
192
|
+
try {
|
|
193
|
+
const subscriber = this.redisService?.getSubscriber();
|
|
194
|
+
subscriber?.off('message', this.invalidationListener);
|
|
195
|
+
if (this.invalidationChannel) {
|
|
196
|
+
void subscriber?.unsubscribe(this.invalidationChannel).catch(() => undefined);
|
|
197
|
+
}
|
|
198
|
+
} catch {
|
|
199
|
+
// Redis already gone — nothing to detach from
|
|
200
|
+
}
|
|
201
|
+
this.invalidationListener = undefined;
|
|
202
|
+
}
|
|
203
|
+
|
|
143
204
|
if (this.cleanupInterval) {
|
|
144
205
|
clearInterval(this.cleanupInterval);
|
|
145
206
|
this.cleanupInterval = null;
|
|
@@ -152,31 +213,28 @@ export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
|
|
|
152
213
|
* Invalidate all cache entries for a specific user.
|
|
153
214
|
* Call this when memberships change (add/remove/update).
|
|
154
215
|
*
|
|
216
|
+
* When Redis is enabled, the invalidation is additionally broadcast to all other
|
|
217
|
+
* replicas — otherwise their process-local caches would stay stale for up to cacheTtlMs.
|
|
218
|
+
*
|
|
155
219
|
* Note: userId must not contain ':' characters (used as cache key delimiter).
|
|
156
220
|
* MongoDB ObjectIds and standard UUID formats are safe.
|
|
157
221
|
*
|
|
158
222
|
* @param userId - The user ID whose cache entries should be invalidated
|
|
159
223
|
*/
|
|
160
224
|
invalidateUser(userId: string): void {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
this.membershipCache.delete(key);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
for (const key of this.tenantIdsCache.keys()) {
|
|
167
|
-
if (key === userId || key.startsWith(`${userId}:`)) {
|
|
168
|
-
this.tenantIdsCache.delete(key);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
225
|
+
this.clearUser(userId);
|
|
226
|
+
this.publishInvalidation({ scope: 'user', userId });
|
|
171
227
|
}
|
|
172
228
|
|
|
173
229
|
/**
|
|
174
230
|
* Clear all cache entries.
|
|
175
231
|
* Useful when configuration changes (e.g., roleHierarchy) or for testing.
|
|
232
|
+
*
|
|
233
|
+
* When Redis is enabled, the invalidation is additionally broadcast to all other replicas.
|
|
176
234
|
*/
|
|
177
235
|
invalidateAll(): void {
|
|
178
|
-
this.
|
|
179
|
-
this.
|
|
236
|
+
this.clearAll();
|
|
237
|
+
this.publishInvalidation({ scope: 'all' });
|
|
180
238
|
}
|
|
181
239
|
|
|
182
240
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
@@ -192,7 +250,8 @@ export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
|
|
|
192
250
|
const isTestEnv =
|
|
193
251
|
process.env.VITEST === 'true' || process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'e2e';
|
|
194
252
|
this.cacheTtlMs = config.cacheTtlMs ?? (isTestEnv ? 0 : 30_000);
|
|
195
|
-
|
|
253
|
+
// Local-only flush: every replica detects the config change itself, no broadcast needed
|
|
254
|
+
this.clearAll();
|
|
196
255
|
}
|
|
197
256
|
|
|
198
257
|
const request = this.getRequest(context);
|
|
@@ -494,6 +553,68 @@ export class CoreTenantGuard implements CanActivate, OnModuleDestroy {
|
|
|
494
553
|
// Cache helpers
|
|
495
554
|
// ===================================================================================================================
|
|
496
555
|
|
|
556
|
+
/**
|
|
557
|
+
* Apply an invalidation message received from another replica.
|
|
558
|
+
* Clears locally only — re-publishing would bounce the message around the cluster.
|
|
559
|
+
*/
|
|
560
|
+
protected applyInvalidation(message: string): void {
|
|
561
|
+
let parsed: TenantCacheInvalidation;
|
|
562
|
+
try {
|
|
563
|
+
parsed = JSON.parse(message);
|
|
564
|
+
} catch {
|
|
565
|
+
this.logger.debug(`Ignoring malformed tenant cache invalidation message: ${message}`);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (parsed?.scope === 'all') {
|
|
569
|
+
this.clearAll();
|
|
570
|
+
} else if (parsed?.scope === 'user' && parsed.userId) {
|
|
571
|
+
this.clearUser(parsed.userId);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Clear both caches locally (no broadcast)
|
|
577
|
+
*/
|
|
578
|
+
protected clearAll(): void {
|
|
579
|
+
this.membershipCache.clear();
|
|
580
|
+
this.tenantIdsCache.clear();
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Remove all entries of a user from both caches locally (no broadcast)
|
|
585
|
+
*/
|
|
586
|
+
protected clearUser(userId: string): void {
|
|
587
|
+
for (const key of this.membershipCache.keys()) {
|
|
588
|
+
if (key.startsWith(`${userId}:`)) {
|
|
589
|
+
this.membershipCache.delete(key);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
for (const key of this.tenantIdsCache.keys()) {
|
|
593
|
+
if (key === userId || key.startsWith(`${userId}:`)) {
|
|
594
|
+
this.tenantIdsCache.delete(key);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Broadcast an invalidation to the other replicas (fire-and-forget).
|
|
601
|
+
* No-op without Redis; failures are logged at debug level only — a missed
|
|
602
|
+
* broadcast merely leaves the other replicas' caches stale until their TTL.
|
|
603
|
+
*/
|
|
604
|
+
protected publishInvalidation(message: TenantCacheInvalidation): void {
|
|
605
|
+
if (!this.redisService?.enabled) {
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
try {
|
|
609
|
+
void this.redisService
|
|
610
|
+
.getClient()
|
|
611
|
+
.publish(this.redisService.key('tenant-cache', 'invalidate'), JSON.stringify(message))
|
|
612
|
+
.catch((error: Error) => this.logger.debug(`Tenant cache invalidation publish failed: ${error.message}`));
|
|
613
|
+
} catch (error) {
|
|
614
|
+
this.logger.debug(`Tenant cache invalidation publish failed: ${(error as Error).message}`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
497
618
|
/**
|
|
498
619
|
* Look up a membership with process-level TTL cache.
|
|
499
620
|
* Avoids repeated DB queries when the same user accesses the same tenant repeatedly.
|
|
@@ -75,10 +75,14 @@ export class CoreTenantModule {
|
|
|
75
75
|
provide: CoreTenantService,
|
|
76
76
|
useClass: Service,
|
|
77
77
|
},
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
78
|
+
// Registered under its OWN token first, then aliased to APP_GUARD — the pattern
|
|
79
|
+
// CoreAuthModule uses for RolesGuard. A bare `{ provide: APP_GUARD, useClass: Guard }`
|
|
80
|
+
// makes Nest mint an internal token for the instance, so `@Optional() CoreTenantGuard`
|
|
81
|
+
// in CoreTenantService resolves to undefined and every invalidateUser()/invalidateAll()
|
|
82
|
+
// — including the cross-replica Redis broadcast — silently did nothing.
|
|
83
|
+
Guard,
|
|
84
|
+
...(Guard === CoreTenantGuard ? [] : [{ provide: CoreTenantGuard, useExisting: Guard }]),
|
|
85
|
+
{ provide: APP_GUARD, useExisting: Guard },
|
|
82
86
|
];
|
|
83
87
|
|
|
84
88
|
// When a custom model name is used, alias the default injection token to the custom model.
|
|
@@ -2,18 +2,61 @@
|
|
|
2
2
|
|
|
3
3
|
**For customizing TUS uploads in projects using `@lenne.tech/nest-server`.**
|
|
4
4
|
|
|
5
|
-
> **Note:** TUS is **enabled by default** with no configuration needed. This checklist is only for projects that need to customize behavior (e.g.,
|
|
5
|
+
> **Note:** TUS is **enabled by default** with no configuration needed. This checklist is only for projects that need to customize behavior (e.g., widen or narrow who may upload).
|
|
6
|
+
|
|
7
|
+
> **Since 11.33.0 uploads require a signed-in user.** `tus.roles` defaults to `[RoleEnum.S_USER]`; it used to be `S_EVERYONE`. If your project accepts attachments on a public form, you must opt back in explicitly — see Step 0.
|
|
6
8
|
|
|
7
9
|
---
|
|
8
10
|
|
|
9
11
|
## Do You Need This Checklist?
|
|
10
12
|
|
|
11
|
-
| Scenario
|
|
12
|
-
|
|
|
13
|
-
| Use TUS with defaults (
|
|
14
|
-
|
|
|
15
|
-
|
|
|
16
|
-
|
|
|
13
|
+
| Scenario | Checklist Needed? |
|
|
14
|
+
| -------------------------------------------------- | ---------------------------------------------------- |
|
|
15
|
+
| Use TUS with defaults (signed-in users may upload) | No - works automatically |
|
|
16
|
+
| **Allow anonymous uploads** (public form) | **Yes - Step 0** (the default no longer allows this) |
|
|
17
|
+
| Restrict uploads to specific roles | Yes - Step 0 |
|
|
18
|
+
| Custom upload handling (notifications, etc.) | Yes - Step 2 |
|
|
19
|
+
| Hard-code a policy that config must NOT change | Yes - Step 1 |
|
|
20
|
+
| Disable TUS completely | No - just use `TusModule.forRoot({ config: false })` |
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Step 0: Configure `tus.roles` (Who May Upload)
|
|
25
|
+
|
|
26
|
+
This is the normal way to set TUS permissions — no controller subclass needed.
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
// src/config.env.ts
|
|
30
|
+
tus: {
|
|
31
|
+
roles: [RoleEnum.S_USER], // default since 11.33.0
|
|
32
|
+
// roles: [RoleEnum.S_EVERYONE], // opt back in to anonymous uploads (public form)
|
|
33
|
+
// roles: ['editor', 'contributor'], // project-specific roles work too
|
|
34
|
+
},
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Plain role **strings**, exactly like a hand-written `@Roles()` call, so project roles work alongside
|
|
38
|
+
the `RoleEnum` system roles.
|
|
39
|
+
|
|
40
|
+
**Three properties worth knowing:**
|
|
41
|
+
|
|
42
|
+
1. **`roles: []` is rejected**, with a warning, and the default applies. An all-empty role set reads
|
|
43
|
+
to the guards as "no roles required" and returns `true` — honouring it literally would _open_ the
|
|
44
|
+
endpoints, the exact opposite of what someone writing `[]` intends. Same for a non-array or an
|
|
45
|
+
array holding a non-string.
|
|
46
|
+
|
|
47
|
+
2. **`OPTIONS` stays public regardless.** `handleTusOptions` / `handleTusOptionsWithId` keep their
|
|
48
|
+
own `@Roles(RoleEnum.S_EVERYONE)`, because that is the CORS preflight — browsers send it
|
|
49
|
+
**without credentials**, and it returns server capabilities only. Gating it would make every
|
|
50
|
+
browser upload fail before the first byte.
|
|
51
|
+
|
|
52
|
+
3. **Pair it with the file roles.** A TUS upload lands in the same store `file.downloadRoles`
|
|
53
|
+
guards (default `[RoleEnum.ADMIN]`), so with both at their defaults a signed-in user may upload
|
|
54
|
+
but cannot read the result back. Either widen `file.downloadRoles`, or write an owner into the
|
|
55
|
+
metadata at upload time and authorize per file in `CoreFileService.checkRights()`. TUS uploads
|
|
56
|
+
already carry `metadata.tusUploadId` plus the original TUS metadata.
|
|
57
|
+
|
|
58
|
+
**Verify:** `curl -X OPTIONS <baseUrl>/tus` still answers `204` with the TUS capability headers,
|
|
59
|
+
while `POST /tus` without a token answers `401`.
|
|
17
60
|
|
|
18
61
|
---
|
|
19
62
|
|
|
@@ -30,22 +73,42 @@ https://github.com/lenneTech/nest-server/tree/develop/src/server
|
|
|
30
73
|
|
|
31
74
|
---
|
|
32
75
|
|
|
33
|
-
## Step 1: Custom Controller (
|
|
76
|
+
## Step 1: Custom Controller (Custom Upload Logic)
|
|
77
|
+
|
|
78
|
+
> Only needed for custom handler behaviour. For "who may upload", use Step 0 — it needs no subclass.
|
|
34
79
|
|
|
35
80
|
**Create:** `src/server/modules/tus/tus.controller.ts`
|
|
36
81
|
|
|
37
82
|
```typescript
|
|
38
83
|
import { Controller } from '@nestjs/common';
|
|
39
|
-
import { CoreTusController
|
|
84
|
+
import { CoreTusController } from '@lenne.tech/nest-server';
|
|
40
85
|
|
|
41
86
|
@Controller('tus')
|
|
42
|
-
@Roles(RoleEnum.S_USER) // Require authenticated user
|
|
43
87
|
export class TusController extends CoreTusController {
|
|
44
|
-
//
|
|
45
|
-
// Override methods here for custom logic
|
|
88
|
+
// Inherits the handlers; `tus.roles` from config applies to them.
|
|
89
|
+
// Override methods here for custom logic.
|
|
46
90
|
}
|
|
47
91
|
```
|
|
48
92
|
|
|
93
|
+
**How `tus.roles` reaches a custom controller — and why you should not fight it with `@Roles()`:**
|
|
94
|
+
|
|
95
|
+
`TusModule.forRoot()` calls `applyRoles()` on the class it actually registers, writing the configured
|
|
96
|
+
roles onto that class **and** onto its `handleTus` / `handleTusWithId`, resolved through the
|
|
97
|
+
prototype chain. `forRoot()` runs after the controller's decorators have been evaluated, so it wins:
|
|
98
|
+
|
|
99
|
+
| You write on the subclass | Effective at runtime |
|
|
100
|
+
| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
101
|
+
| Nothing | `tus.roles` (config) |
|
|
102
|
+
| A class-level `@Roles(...)` | `tus.roles` — overwritten by `applyRoles()` |
|
|
103
|
+
| A re-declared `handleTus` with its own `@Roles(...)` | `tus.roles` — overwritten too, because `applyRoles()` writes onto whichever function `prototype.handleTus` resolves to |
|
|
104
|
+
|
|
105
|
+
So `tus.roles` is the single source of truth for TUS permissions. If you need a policy that
|
|
106
|
+
configuration cannot change, either give your handler a **different method name** (`applyRoles()`
|
|
107
|
+
only targets `handleTus` / `handleTusWithId`), or register the controller outside `TusModule`.
|
|
108
|
+
|
|
109
|
+
> This differs from the file module, where `applyFileRoles()` targets the **base** class methods by
|
|
110
|
+
> name — there an override genuinely does opt out.
|
|
111
|
+
|
|
49
112
|
**Update ServerModule:**
|
|
50
113
|
|
|
51
114
|
```typescript
|
|
@@ -104,6 +167,10 @@ export class TusService extends CoreTusService {
|
|
|
104
167
|
path: '/tus',
|
|
105
168
|
maxSize: 50 * 1024 * 1024 * 1024, // 50 GB
|
|
106
169
|
expiration: { expiresIn: '24h' },
|
|
170
|
+
roles: [RoleEnum.S_USER], // since 11.33.0 (was S_EVERYONE)
|
|
171
|
+
// s3Staging: true when `s3` is configured — stages in-progress uploads in
|
|
172
|
+
// s3.stagingBucket instead of on pod-local disk, so resumable uploads survive
|
|
173
|
+
// replica restarts and need no sticky sessions. Set false to force local disk.
|
|
107
174
|
}
|
|
108
175
|
```
|
|
109
176
|
|
|
@@ -132,20 +199,25 @@ TusModule.forRoot({ config: false });
|
|
|
132
199
|
|
|
133
200
|
- [ ] `npm run build` succeeds
|
|
134
201
|
- [ ] `npm test` passes
|
|
135
|
-
- [ ] `OPTIONS /tus` returns TUS capabilities
|
|
136
|
-
- [ ]
|
|
137
|
-
- [ ]
|
|
138
|
-
- [ ] (
|
|
202
|
+
- [ ] `OPTIONS /tus` returns TUS capabilities **without a token** (the CORS preflight must stay public)
|
|
203
|
+
- [ ] `POST /tus` **without** a token answers `401` (or `204` if you deliberately set `roles: [S_EVERYONE]`)
|
|
204
|
+
- [ ] Upload via tus-js-client works for a user holding a role from `tus.roles`
|
|
205
|
+
- [ ] File appears in the configured file store after upload completion (GridFS by default; S3 or filesystem per `file.storage`)
|
|
206
|
+
- [ ] The uploaded file can also be **downloaded** by its intended audience — check `file.downloadRoles` too
|
|
139
207
|
|
|
140
208
|
---
|
|
141
209
|
|
|
142
210
|
## Common Mistakes
|
|
143
211
|
|
|
144
|
-
| Mistake
|
|
145
|
-
|
|
|
146
|
-
|
|
|
147
|
-
|
|
|
148
|
-
|
|
|
212
|
+
| Mistake | Symptom | Fix |
|
|
213
|
+
| -------------------------------------------------- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
|
214
|
+
| Assuming uploads are still public (pre-11.33.0) | `401` on the very first `POST /tus` from a public form | `tus: { roles: [RoleEnum.S_EVERYONE] }` — Step 0 |
|
|
215
|
+
| `tus: { roles: [] }` meaning "nobody" | Warning in the log, default `[S_USER]` applied instead | An empty role set would OPEN the routes; name the roles explicitly — Step 0 |
|
|
216
|
+
| Gating `OPTIONS` in a custom controller | Every browser upload fails before the first byte | Leave `handleTusOptions*` on `@Roles(RoleEnum.S_EVERYONE)` — the preflight sends no credentials |
|
|
217
|
+
| Expecting a subclass `@Roles()` to win over config | Roles look ignored | `applyRoles()` overwrites them — Step 1 table |
|
|
218
|
+
| Forgot to register custom controller | Custom handler logic never runs (core controller is used) | Add `controller: TusController` to `forRoot()` |
|
|
219
|
+
| Upload succeeds but the file cannot be downloaded | `403` on `GET /files/id/:id` | `file.downloadRoles` defaults to `[ADMIN]` — widen it, or authorize per file in `checkRights()` |
|
|
220
|
+
| Using wrong endpoint path | 404 on upload | Ensure client uses same path as config |
|
|
149
221
|
|
|
150
222
|
---
|
|
151
223
|
|
|
@@ -262,7 +262,31 @@ upload.start(); // Resumes from where it left off
|
|
|
262
262
|
|
|
263
263
|
### Require Authentication
|
|
264
264
|
|
|
265
|
-
|
|
265
|
+
Since 11.33.0 TUS requires a signed-in caller by default (`tus.roles`, default `[S_USER]`). The
|
|
266
|
+
previous default was `S_EVERYONE`, which let anonymous callers write into — and, with the
|
|
267
|
+
termination extension, delete from — the same GridFS bucket the download routes guard.
|
|
268
|
+
|
|
269
|
+
Set it in `config.env.ts`:
|
|
270
|
+
|
|
271
|
+
```typescript
|
|
272
|
+
tus: {
|
|
273
|
+
roles: [RoleEnum.S_USER];
|
|
274
|
+
} // default
|
|
275
|
+
tus: {
|
|
276
|
+
roles: [RoleEnum.S_EVERYONE];
|
|
277
|
+
} // opt back in to anonymous uploads
|
|
278
|
+
tus: {
|
|
279
|
+
roles: ['editor', 'contributor'];
|
|
280
|
+
} // project-specific roles work too
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
`roles: []` is rejected with a warning rather than honoured — an all-empty role set reads to the
|
|
284
|
+
guards as "no roles required" and would open the endpoints instead of closing them.
|
|
285
|
+
|
|
286
|
+
`OPTIONS` stays public regardless: it is the CORS preflight, which browsers send without
|
|
287
|
+
credentials, and it discloses only server capabilities.
|
|
288
|
+
|
|
289
|
+
Alternatively, create a custom controller:
|
|
266
290
|
|
|
267
291
|
```typescript
|
|
268
292
|
// src/server/modules/tus/tus.controller.ts
|
|
@@ -332,7 +356,7 @@ GET /files/:filename
|
|
|
332
356
|
|
|
333
357
|
# Via GraphQL
|
|
334
358
|
query {
|
|
335
|
-
|
|
359
|
+
getFileInfo(filename: "...") {
|
|
336
360
|
id
|
|
337
361
|
filename
|
|
338
362
|
contentType
|
|
@@ -343,6 +367,15 @@ query {
|
|
|
343
367
|
|
|
344
368
|
**Recommendation:** Use the ID-based endpoint (`/files/id/:id`) for TUS uploads as filenames may not be unique.
|
|
345
369
|
|
|
370
|
+
> **These download routes are gated.** They require `file.downloadRoles`, which defaults to
|
|
371
|
+
> `[RoleEnum.ADMIN]` — while uploading here only requires `tus.roles` (default `S_USER`). So out of
|
|
372
|
+
> the box a signed-in user can upload but cannot read their own file back.
|
|
373
|
+
>
|
|
374
|
+
> Do not fix that by widening `downloadRoles` to `S_USER`: that would let every signed-in user read
|
|
375
|
+
> _every_ file in the shared bucket. Write an owner into the file metadata at upload time and
|
|
376
|
+
> authorize per file in `CoreFileService.checkRights()` — see the File module's README, section
|
|
377
|
+
> "Access control".
|
|
378
|
+
|
|
346
379
|
### File Metadata
|
|
347
380
|
|
|
348
381
|
The following metadata is stored with each GridFS file:
|