@lenne.tech/nest-server 11.32.3 → 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/bin/migrate.js +13 -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 +21 -2
- package/dist/core/common/helpers/file.helper.js +69 -13
- 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 +15 -3
- package/dist/core/modules/file/core-file.controller.js +90 -20
- 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/cli/migrate-cli.d.ts +3 -1
- package/dist/core/modules/migrate/cli/migrate-cli.js +29 -4
- package/dist/core/modules/migrate/cli/migrate-cli.js.map +1 -1
- package/dist/core/modules/migrate/helpers/migration.helper.d.ts +3 -6
- package/dist/core/modules/migrate/helpers/migration.helper.js +55 -8
- 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 +30 -5
- package/migration-guides/11.32.3-to-11.32.4.md +335 -0
- 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 +201 -20
- 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 +324 -41
- package/src/core/modules/file/core-file.controller.ts +267 -19
- 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 +68 -6
- package/src/core/modules/migrate/cli/migrate-cli.ts +69 -6
- package/src/core/modules/migrate/helpers/migration.helper.ts +144 -10
- 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/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/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
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
1
2
|
import { Db, MongoClient } from 'mongodb';
|
|
3
|
+
import { hostname } from 'os';
|
|
2
4
|
import { promisify } from 'util';
|
|
3
5
|
|
|
4
6
|
/**
|
|
@@ -181,14 +183,20 @@ export async function synchronizedMigration(
|
|
|
181
183
|
throw new Error('`lockCollectionName` in MongoStateStore is not set');
|
|
182
184
|
}
|
|
183
185
|
|
|
186
|
+
let heartbeat: undefined | { stop: () => void };
|
|
187
|
+
let lockToken: string | undefined;
|
|
184
188
|
try {
|
|
185
|
-
await acquireLock(stateStore.mongodbHost, lockCollectionName);
|
|
189
|
+
lockToken = await acquireLock(stateStore.mongodbHost, lockCollectionName);
|
|
190
|
+
heartbeat = startLockHeartbeat(stateStore.mongodbHost, lockCollectionName, lockToken);
|
|
186
191
|
|
|
187
192
|
// Load migration set using async method
|
|
188
193
|
const set = await stateStore.loadAsync();
|
|
189
194
|
await callback(set);
|
|
190
195
|
} finally {
|
|
191
|
-
|
|
196
|
+
heartbeat?.stop();
|
|
197
|
+
if (lockToken) {
|
|
198
|
+
await releaseLock(stateStore.mongodbHost, lockCollectionName, lockToken);
|
|
199
|
+
}
|
|
192
200
|
}
|
|
193
201
|
}
|
|
194
202
|
|
|
@@ -217,14 +225,119 @@ export async function synchronizedUp(opts: MigrationOptions): Promise<void> {
|
|
|
217
225
|
});
|
|
218
226
|
}
|
|
219
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Runs `fn` while holding the migration lock of the given state store
|
|
230
|
+
*
|
|
231
|
+
* When the store has no `lockCollectionName`, `fn` runs unsynchronized — a single
|
|
232
|
+
* replica keeps behaving exactly as before. Stores built by `createMigrationStore()`
|
|
233
|
+
* carry the default lock collection, so concurrent `migrate up` runs from several
|
|
234
|
+
* replicas serialize here: the second replica waits, then re-reads the migration
|
|
235
|
+
* state and finds nothing pending.
|
|
236
|
+
*
|
|
237
|
+
* @param stateStore - State store holding the connection URI and lock collection name
|
|
238
|
+
* @param fn - Work to execute under the lock
|
|
239
|
+
* @returns Promise with the result of `fn`
|
|
240
|
+
*/
|
|
241
|
+
export async function withMigrationLock<T>(stateStore: MongoStateStore, fn: () => Promise<T>): Promise<T> {
|
|
242
|
+
const lockCollectionName = stateStore.lockCollectionName;
|
|
243
|
+
|
|
244
|
+
if (!lockCollectionName) {
|
|
245
|
+
return fn();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const token = await acquireLock(stateStore.mongodbHost, lockCollectionName);
|
|
249
|
+
const heartbeat = startLockHeartbeat(stateStore.mongodbHost, lockCollectionName, token);
|
|
250
|
+
try {
|
|
251
|
+
return await fn();
|
|
252
|
+
} finally {
|
|
253
|
+
heartbeat.stop();
|
|
254
|
+
await releaseLock(stateStore.mongodbHost, lockCollectionName, token);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* How long a lock may go without a heartbeat before another replica may break it.
|
|
260
|
+
*
|
|
261
|
+
* Only a lock whose holder stopped refreshing it is ever broken, so this is not a cap
|
|
262
|
+
* on migration runtime — a migration running for hours keeps its lock as long as the
|
|
263
|
+
* process lives.
|
|
264
|
+
*/
|
|
265
|
+
const LOCK_STALE_AFTER_MS = 60_000;
|
|
266
|
+
|
|
267
|
+
/** Heartbeat interval — comfortably below {@link LOCK_STALE_AFTER_MS} */
|
|
268
|
+
const LOCK_HEARTBEAT_INTERVAL_MS = 15_000;
|
|
269
|
+
|
|
270
|
+
/** How long a replica waits for a held lock before giving up with a diagnosable error */
|
|
271
|
+
const LOCK_WAIT_TIMEOUT_MS = 15 * 60_000;
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Keep refreshing `acquiredAt` while the migration runs.
|
|
275
|
+
*
|
|
276
|
+
* This is what makes lock-breaking safe: a lock is only ever taken over when its holder
|
|
277
|
+
* stopped refreshing it, which for a live process cannot happen. Without the heartbeat,
|
|
278
|
+
* breaking a stale lock and a long-running migration would be indistinguishable.
|
|
279
|
+
*/
|
|
280
|
+
function startLockHeartbeat(url: string, lockCollectionName: string, token: string): { stop: () => void } {
|
|
281
|
+
// ONE connection for the whole heartbeat, rather than `dbRequest()`'s connect-and-close per
|
|
282
|
+
// call: that opened and tore down a MongoClient every 15 seconds for the entire migration run —
|
|
283
|
+
// a full handshake each time, against the database at its busiest moment. The client is created
|
|
284
|
+
// on the first tick (so a heartbeat that never fires costs nothing) and closed by stop().
|
|
285
|
+
let client: MongoClient | undefined;
|
|
286
|
+
let stopped = false;
|
|
287
|
+
|
|
288
|
+
const beat = async (): Promise<void> => {
|
|
289
|
+
if (stopped) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (!client) {
|
|
293
|
+
const connected = await MongoClient.connect(url);
|
|
294
|
+
if (stopped) {
|
|
295
|
+
// stop() ran while we were connecting — nothing would ever close this one.
|
|
296
|
+
await connected.close();
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
client = connected;
|
|
300
|
+
}
|
|
301
|
+
// Scoped: once our lock has been broken and re-taken, our heartbeat must not keep the NEW
|
|
302
|
+
// holder's lock alive on our behalf.
|
|
303
|
+
await client
|
|
304
|
+
.db()
|
|
305
|
+
.collection(lockCollectionName)
|
|
306
|
+
.updateOne({ lock: 'lock', owner: token }, { $set: { acquiredAt: new Date() } });
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const timer = setInterval(() => {
|
|
310
|
+
beat().catch((error) => {
|
|
311
|
+
// A missed heartbeat is not fatal on its own — the next one may succeed, and only
|
|
312
|
+
// a sustained gap makes the lock breakable. Drop the connection though: it may itself be
|
|
313
|
+
// what failed, and a held-open broken client would fail every remaining tick.
|
|
314
|
+
console.warn(`Migration lock heartbeat failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
315
|
+
const broken = client;
|
|
316
|
+
client = undefined;
|
|
317
|
+
void broken?.close().catch(() => undefined);
|
|
318
|
+
});
|
|
319
|
+
}, LOCK_HEARTBEAT_INTERVAL_MS);
|
|
320
|
+
timer.unref?.();
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
stop: () => {
|
|
324
|
+
stopped = true;
|
|
325
|
+
clearInterval(timer);
|
|
326
|
+
const open = client;
|
|
327
|
+
client = undefined;
|
|
328
|
+
void open?.close().catch(() => undefined);
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
220
333
|
/**
|
|
221
334
|
* Acquires a lock in MongoDB to ensure only one migration runs at a time
|
|
222
335
|
*
|
|
223
336
|
* @param url - MongoDB connection URI
|
|
224
337
|
* @param lockCollectionName - Name of the collection to use for locking
|
|
225
338
|
*/
|
|
226
|
-
async function acquireLock(url: string, lockCollectionName: string): Promise<
|
|
227
|
-
|
|
339
|
+
async function acquireLock(url: string, lockCollectionName: string): Promise<string> {
|
|
340
|
+
return dbRequest(url, async (db) => {
|
|
228
341
|
const collection = db.collection(lockCollectionName);
|
|
229
342
|
|
|
230
343
|
// Create unique index for atomicity
|
|
@@ -233,15 +346,58 @@ async function acquireLock(url: string, lockCollectionName: string): Promise<voi
|
|
|
233
346
|
// https://stackoverflow.com/questions/33346175/mongodb-upsert-operation-seems-not-atomic-which-throws-duplicatekeyexception/34784533
|
|
234
347
|
await collection.createIndex({ lock: 1 }, { unique: true });
|
|
235
348
|
|
|
349
|
+
const token = lockOwnerId();
|
|
350
|
+
const deadline = Date.now() + LOCK_WAIT_TIMEOUT_MS;
|
|
236
351
|
let showMessage = true;
|
|
237
352
|
|
|
238
353
|
for (;;) {
|
|
239
|
-
// Use updateOne with upsert for atomic lock acquisition (same as original package)
|
|
240
|
-
|
|
241
|
-
|
|
354
|
+
// Use updateOne with upsert for atomic lock acquisition (same as original package).
|
|
355
|
+
// `$setOnInsert` stamps the acquisition time only for the winner, so a loser's
|
|
356
|
+
// update never refreshes the holder's staleness clock.
|
|
357
|
+
const result = await collection.updateOne(
|
|
358
|
+
{ lock: 'lock' },
|
|
359
|
+
{ $set: { lock: 'lock' }, $setOnInsert: { acquiredAt: new Date(), owner: token } },
|
|
360
|
+
{ upsert: true },
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
if (result.upsertedCount > 0) {
|
|
364
|
+
return token;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// The holder may be gone: migrations run on every container boot, so a replica
|
|
368
|
+
// SIGKILLed mid-migration (OOM, node drain, failed deploy) would otherwise leave a
|
|
369
|
+
// lock nobody holds — and every future boot of every replica would wait on it
|
|
370
|
+
// forever. Only a lock that stopped heart-beating is broken; see LOCK_STALE_AFTER_MS.
|
|
371
|
+
const holder = await collection.findOne({ lock: 'lock' });
|
|
372
|
+
if (holder) {
|
|
373
|
+
const acquiredAt = holder.acquiredAt instanceof Date ? holder.acquiredAt : undefined;
|
|
374
|
+
if (!acquiredAt) {
|
|
375
|
+
// Written by a version that did not stamp the lock. Start its clock now rather
|
|
376
|
+
// than breaking it immediately — the holder may well be alive.
|
|
377
|
+
await collection.updateOne(
|
|
378
|
+
{ _id: holder._id, acquiredAt: { $exists: false } },
|
|
379
|
+
{ $set: { acquiredAt: new Date() } },
|
|
380
|
+
);
|
|
381
|
+
} else if (Date.now() - acquiredAt.getTime() > LOCK_STALE_AFTER_MS) {
|
|
382
|
+
console.warn(
|
|
383
|
+
`Breaking stale migration lock in "${lockCollectionName}" (last heartbeat ${acquiredAt.toISOString()}, ` +
|
|
384
|
+
`owner ${holder.owner ?? 'unknown'}) — its holder is gone.`,
|
|
385
|
+
);
|
|
386
|
+
// Matching on acquiredAt makes the break safe under concurrency: if another
|
|
387
|
+
// waiter already broke and re-acquired the lock, the timestamp differs and this
|
|
388
|
+
// deletes nothing.
|
|
389
|
+
await collection.deleteOne({ _id: holder._id, acquiredAt });
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
242
393
|
|
|
243
|
-
if (
|
|
244
|
-
|
|
394
|
+
if (Date.now() > deadline) {
|
|
395
|
+
throw new Error(
|
|
396
|
+
`Timed out after ${Math.round(LOCK_WAIT_TIMEOUT_MS / 60_000)} minutes waiting for the migration lock in ` +
|
|
397
|
+
`collection "${lockCollectionName}". Another replica is still migrating, or the lock is held by a process ` +
|
|
398
|
+
`that is alive but stuck. Inspect it with: db.getCollection("${lockCollectionName}").find({}) — and remove ` +
|
|
399
|
+
`the document only once you are sure no migration is running.`,
|
|
400
|
+
);
|
|
245
401
|
}
|
|
246
402
|
|
|
247
403
|
if (showMessage) {
|
|
@@ -254,6 +410,15 @@ async function acquireLock(url: string, lockCollectionName: string): Promise<voi
|
|
|
254
410
|
});
|
|
255
411
|
}
|
|
256
412
|
|
|
413
|
+
/**
|
|
414
|
+
* Identifies the process holding the lock, so a stale-lock warning names something actionable
|
|
415
|
+
*/
|
|
416
|
+
function lockOwnerId(): string {
|
|
417
|
+
// Random suffix, not just host+pid: a restarted process can reuse a pid, and the token is what
|
|
418
|
+
// decides whose lock a release may delete.
|
|
419
|
+
return `${hostname()}:${process.pid}:${randomUUID()}`;
|
|
420
|
+
}
|
|
421
|
+
|
|
257
422
|
/**
|
|
258
423
|
* Executes database operations with automatic connection management
|
|
259
424
|
*
|
|
@@ -278,6 +443,9 @@ async function dbRequest<T>(url: string, callback: (db: Db) => Promise<T> | T):
|
|
|
278
443
|
* @param url - MongoDB connection URI
|
|
279
444
|
* @param lockCollectionName - Name of the collection used for locking
|
|
280
445
|
*/
|
|
281
|
-
async function releaseLock(url: string, lockCollectionName: string): Promise<void> {
|
|
282
|
-
|
|
446
|
+
async function releaseLock(url: string, lockCollectionName: string, token: string): Promise<void> {
|
|
447
|
+
// Scoped to OUR token. An unscoped delete removes whatever lock exists — so after a missed
|
|
448
|
+
// heartbeat let another replica break and re-take the lock, the original holder's `finally`
|
|
449
|
+
// would delete the NEW holder's lock and let a third replica start a concurrent migration.
|
|
450
|
+
await dbRequest(url, (db) => db.collection(lockCollectionName).deleteOne({ lock: 'lock', owner: token }));
|
|
283
451
|
}
|
|
@@ -164,7 +164,11 @@ NEST_SERVER_CONFIG='{ "systemSetup": { "initialAdmin": { "email": "admin@example
|
|
|
164
164
|
- The admin is created automatically during application bootstrap (`OnApplicationBootstrap`)
|
|
165
165
|
- Same zero-user guard applies: only works when no users exist
|
|
166
166
|
- If users already exist, auto-creation is silently skipped (no error)
|
|
167
|
-
- Race conditions between multiple instances are handled gracefully
|
|
167
|
+
- Race conditions between multiple instances are handled gracefully: the zero-user check alone is
|
|
168
|
+
check-then-act, so N replicas booting at the same time would each pass it. Before creating, an
|
|
169
|
+
instance claims the setup by upserting the marker `{ _id: 'initial-admin' }` into the
|
|
170
|
+
`system-setup-locks` collection — an atomic operation only one instance wins. The others log a
|
|
171
|
+
debug message and skip. If creation fails, the marker is removed again so a later boot can retry.
|
|
168
172
|
|
|
169
173
|
### Security Best Practices
|
|
170
174
|
|
|
@@ -179,7 +183,8 @@ NEST_SERVER_CONFIG='{ "systemSetup": { "initialAdmin": { "email": "admin@example
|
|
|
179
183
|
|
|
180
184
|
1. **Zero-user guard** - Init only works when `countDocuments({}) === 0`
|
|
181
185
|
2. **Enabled by default** - Safe because endpoints are permanently locked once any user exists
|
|
182
|
-
3. **Race condition protection** - MongoDB unique email index prevents duplicates
|
|
186
|
+
3. **Race condition protection** - MongoDB unique email index prevents duplicates; auto-creation on
|
|
187
|
+
bootstrap is additionally claimed atomically via the `system-setup-locks` marker
|
|
183
188
|
4. **Permanent lock** - Once any user exists, init returns 403
|
|
184
189
|
5. **BetterAuth required** - Returns 403 if BetterAuth is not enabled
|
|
185
190
|
|
|
@@ -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.
|