@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
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
import { Logger } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
import type { CoreRedisService } from './core-redis.service';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Escape Redis glob metacharacters so a LITERAL key can be embedded in a `SCAN MATCH` pattern.
|
|
7
|
+
*
|
|
8
|
+
* Keys embed values the caller controls (an IP, an endpoint, an email), and `resetByPrefix` turns
|
|
9
|
+
* such a value into a pattern — where an unescaped `*` or `?` would silently widen the match to
|
|
10
|
+
* other callers' counters.
|
|
11
|
+
*
|
|
12
|
+
* Applied to the PATTERN only, never to the key being written. Escaping both is how the two stop
|
|
13
|
+
* meeting: the stored key then carries the backslashes literally while the pattern's `\x` matches
|
|
14
|
+
* a single unescaped `x`, so a reset matched nothing at all for any key containing `\` — which
|
|
15
|
+
* {@link rateLimitKey} produces for every IPv6 address — or a glob metacharacter.
|
|
16
|
+
*/
|
|
17
|
+
function escapeGlob(value: string): string {
|
|
18
|
+
return value.replace(/[?[\]*\\^]/g, (char) => `\\${char}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Join caller-controlled parts into ONE rate-limit key.
|
|
23
|
+
*
|
|
24
|
+
* `:` separates the segments of every key the framework builds, so a part that CONTAINS a colon
|
|
25
|
+
* straddles the boundary and two different callers collapse onto one counter: ip `1.2.3.4:5` with
|
|
26
|
+
* endpoint `/a` produces the same key as ip `1.2.3.4` with endpoint `5:/a`. Both parts are
|
|
27
|
+
* caller-influenced (a forwarded-for value, a request path), so that is a bucket a client can aim
|
|
28
|
+
* at — sharing, and therefore exhausting, someone else's limit.
|
|
29
|
+
*
|
|
30
|
+
* Escaping happens at CONSTRUCTION, not in the Redis key builder, so the in-memory store gets the
|
|
31
|
+
* same separation as the Redis one instead of only the SCAN path being protected. `\` is escaped
|
|
32
|
+
* first, otherwise a literal `\` in a part could forge the escape of a following colon.
|
|
33
|
+
*/
|
|
34
|
+
export function rateLimitKey(...parts: string[]): string {
|
|
35
|
+
return parts.map((part) => part.replace(/\\/g, '\\\\').replace(/:/g, '\\:')).join(':');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Prefix covering every {@link rateLimitKey} that starts with `parts` — for
|
|
40
|
+
* {@link RateLimitStore.resetByPrefix}. The trailing separator is the real one, so a reset for
|
|
41
|
+
* ip `1.2.3.4` cannot also match ip `1.2.3.40`.
|
|
42
|
+
*/
|
|
43
|
+
export function rateLimitKeyPrefix(...parts: string[]): string {
|
|
44
|
+
return `${rateLimitKey(...parts)}:`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Human-readable limiter name for a log line
|
|
49
|
+
*/
|
|
50
|
+
function namespaceLabel(namespace: string): string {
|
|
51
|
+
return `namespace ${namespace}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Result of a rate limit hit
|
|
56
|
+
*/
|
|
57
|
+
export interface RateLimitStoreHit {
|
|
58
|
+
/** Current request count in the window (including this hit) */
|
|
59
|
+
count: number;
|
|
60
|
+
/** Seconds until the window resets */
|
|
61
|
+
resetIn: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Fixed-window rate limit counter storage.
|
|
66
|
+
*
|
|
67
|
+
* Implementations: InMemoryRateLimitStore (process-local, default) and
|
|
68
|
+
* RedisRateLimitStore (distributed — the effective limit stays exact across
|
|
69
|
+
* replicas instead of multiplying by the replica count).
|
|
70
|
+
*/
|
|
71
|
+
export interface RateLimitStore {
|
|
72
|
+
/** Remove all entries (testing/admin) */
|
|
73
|
+
clear(): Promise<void>;
|
|
74
|
+
|
|
75
|
+
/** Count one hit for the key within a fixed window */
|
|
76
|
+
hit(key: string, windowSeconds: number): Promise<RateLimitStoreHit>;
|
|
77
|
+
|
|
78
|
+
/** Remove all entries whose key starts with the prefix (admin reset per IP) */
|
|
79
|
+
resetByPrefix(prefix: string): Promise<void>;
|
|
80
|
+
|
|
81
|
+
/** Number of active entries, or -1 when not cheaply known (Redis) */
|
|
82
|
+
size(): number;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Release timers and connections the store owns (graceful shutdown).
|
|
86
|
+
*
|
|
87
|
+
* On the interface, not just on the in-memory class: a RedisRateLimitStore owns an in-memory
|
|
88
|
+
* FALLBACK store, so an owner that released only `instanceof InMemoryRateLimitStore` left that
|
|
89
|
+
* fallback's cleanup interval running for the life of the process.
|
|
90
|
+
*/
|
|
91
|
+
destroy?(): void;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** How many shared counters a saturated {@link InMemoryRateLimitStore} folds unknown keys into */
|
|
95
|
+
const OVERFLOW_BUCKETS = 64;
|
|
96
|
+
|
|
97
|
+
/** Cheap, stable string hash — picks the overflow bucket for a key. Not security-sensitive. */
|
|
98
|
+
function overflowSlot(key: string): number {
|
|
99
|
+
let hash = 0;
|
|
100
|
+
for (let i = 0; i < key.length; i++) {
|
|
101
|
+
hash = (hash * 31 + key.charCodeAt(i)) | 0;
|
|
102
|
+
}
|
|
103
|
+
return Math.abs(hash) % OVERFLOW_BUCKETS;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Process-local fixed-window store (previous behavior of all framework rate
|
|
108
|
+
* limiters). Entries are capped and cleaned up periodically.
|
|
109
|
+
*/
|
|
110
|
+
export class InMemoryRateLimitStore implements RateLimitStore {
|
|
111
|
+
protected readonly store = new Map<string, { count: number; resetTime: number }>();
|
|
112
|
+
protected cleanupInterval: NodeJS.Timeout | null = null;
|
|
113
|
+
|
|
114
|
+
/** Set by evictOldest() when nothing could be freed without deleting a live counter */
|
|
115
|
+
protected atCapacity = false;
|
|
116
|
+
|
|
117
|
+
/** So the capacity warning is logged once, not per request */
|
|
118
|
+
protected capacityWarned = false;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Fixed set of shared counters that keys falling outside {@link maxEntries} are folded into.
|
|
122
|
+
*
|
|
123
|
+
* A full store must not become an open door. Returning an uncounted `1` for every unknown key —
|
|
124
|
+
* which is what "refuse new entries at capacity" naively means — hands an attacker the bypass
|
|
125
|
+
* for free: fill the store with distinct keys, and from then on every fresh key is a first
|
|
126
|
+
* request and no limit ever applies. Since both key parts are caller-influenced (a spoofable
|
|
127
|
+
* `X-Forwarded-For` and the request path), filling it is cheap.
|
|
128
|
+
*
|
|
129
|
+
* So overflow keys are still counted, just coarsely: each maps to one of a FIXED number of
|
|
130
|
+
* buckets, so the overflow map cannot grow either. The cost is that unrelated clients share a
|
|
131
|
+
* limit while the store is saturated — they are throttled earlier than they deserve. That is
|
|
132
|
+
* the right direction to fail: a degraded limit for new clients, never no limit at all.
|
|
133
|
+
*/
|
|
134
|
+
protected readonly overflow = new Map<number, { count: number; resetTime: number }>();
|
|
135
|
+
|
|
136
|
+
constructor(protected readonly maxEntries = 10000) {
|
|
137
|
+
// Clean up expired entries every 5 minutes; never keep the process alive
|
|
138
|
+
this.cleanupInterval = setInterval(() => this.removeExpired(), 5 * 60 * 1000);
|
|
139
|
+
this.cleanupInterval.unref?.();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async clear(): Promise<void> {
|
|
143
|
+
this.store.clear();
|
|
144
|
+
// The overflow buckets are bounded, so leaving them behind cannot leak — but they still hold
|
|
145
|
+
// live counts. A test (or an admin) that clears the store and then sees a limit trip early is
|
|
146
|
+
// looking at counters it believes it deleted.
|
|
147
|
+
this.overflow.clear();
|
|
148
|
+
this.atCapacity = false;
|
|
149
|
+
this.capacityWarned = false;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async hit(key: string, windowSeconds: number): Promise<RateLimitStoreHit> {
|
|
153
|
+
const now = Date.now();
|
|
154
|
+
let entry = this.store.get(key);
|
|
155
|
+
if (!entry || now >= entry.resetTime) {
|
|
156
|
+
if (!entry && this.store.size >= this.maxEntries) {
|
|
157
|
+
this.atCapacity = false;
|
|
158
|
+
this.evictOldest();
|
|
159
|
+
if (this.atCapacity) {
|
|
160
|
+
if (!this.capacityWarned) {
|
|
161
|
+
this.capacityWarned = true;
|
|
162
|
+
console.warn(
|
|
163
|
+
`Rate limit store is at capacity (${this.maxEntries} live counters). New clients now share ` +
|
|
164
|
+
`${OVERFLOW_BUCKETS} coarse counters until entries expire, so they may be limited earlier than ` +
|
|
165
|
+
'their own traffic warrants. Existing limits are unaffected.',
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return this.hitOverflow(key, windowSeconds, now);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
entry = { count: 1, resetTime: now + windowSeconds * 1000 };
|
|
172
|
+
this.store.set(key, entry);
|
|
173
|
+
return { count: 1, resetIn: windowSeconds };
|
|
174
|
+
}
|
|
175
|
+
entry.count++;
|
|
176
|
+
return { count: entry.count, resetIn: Math.ceil((entry.resetTime - now) / 1000) };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async resetByPrefix(prefix: string): Promise<void> {
|
|
180
|
+
for (const key of this.store.keys()) {
|
|
181
|
+
if (key.startsWith(prefix)) {
|
|
182
|
+
this.store.delete(key);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Count a key that did not fit in the store against its shared overflow bucket.
|
|
189
|
+
*
|
|
190
|
+
* The bucket count is what the caller sees, so a saturated store still enforces a limit.
|
|
191
|
+
*/
|
|
192
|
+
protected hitOverflow(key: string, windowSeconds: number, now: number): RateLimitStoreHit {
|
|
193
|
+
const slot = overflowSlot(key);
|
|
194
|
+
const entry = this.overflow.get(slot);
|
|
195
|
+
|
|
196
|
+
if (!entry || now >= entry.resetTime) {
|
|
197
|
+
this.overflow.set(slot, { count: 1, resetTime: now + windowSeconds * 1000 });
|
|
198
|
+
return { count: 1, resetIn: windowSeconds };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
entry.count++;
|
|
202
|
+
return { count: entry.count, resetIn: Math.ceil((entry.resetTime - now) / 1000) };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
size(): number {
|
|
206
|
+
return this.store.size;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Stop the cleanup interval (graceful shutdown / tests) */
|
|
210
|
+
destroy(): void {
|
|
211
|
+
if (this.cleanupInterval) {
|
|
212
|
+
clearInterval(this.cleanupInterval);
|
|
213
|
+
this.cleanupInterval = null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
protected removeExpired(): number {
|
|
218
|
+
const now = Date.now();
|
|
219
|
+
let removed = 0;
|
|
220
|
+
for (const [key, entry] of this.store.entries()) {
|
|
221
|
+
if (now >= entry.resetTime) {
|
|
222
|
+
this.store.delete(key);
|
|
223
|
+
removed++;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return removed;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Evict when at capacity: first everything expired, then the entries closest
|
|
231
|
+
* to expiry until the store is at 90% capacity.
|
|
232
|
+
*/
|
|
233
|
+
protected evictOldest(): void {
|
|
234
|
+
const removed = this.removeExpired();
|
|
235
|
+
if (removed > 0 || this.store.size < this.maxEntries) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
// Every entry is still LIVE. Dropping the ones closest to expiry would delete active
|
|
239
|
+
// counters — and for a rate limiter that means resetting the window of whoever is being
|
|
240
|
+
// limited, which is exactly the caller flooding the store in the first place. Refusing the
|
|
241
|
+
// new entry instead lets an attacker deny NEW clients a counter, but it cannot clear an
|
|
242
|
+
// existing one; and the cap only binds until the next natural expiry a window later.
|
|
243
|
+
this.atCapacity = true;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Atomic hit: cardinality check, then INCR + EXPIRE, in one round trip.
|
|
249
|
+
*
|
|
250
|
+
* ```
|
|
251
|
+
* KEYS[1] counter key for this caller
|
|
252
|
+
* KEYS[2] per-namespace cardinality counter
|
|
253
|
+
* KEYS[3] shared overflow counter this key folds into once the cap is reached
|
|
254
|
+
* ARGV[1] window in seconds
|
|
255
|
+
* ARGV[2] maximum distinct counter keys per window
|
|
256
|
+
* ```
|
|
257
|
+
*
|
|
258
|
+
* Returns `[count, ttlSeconds, overflow]`, where `overflow` is `1` when the coarse bucket was
|
|
259
|
+
* used. The cap decision lives INSIDE the script on purpose: a read-then-decide in JavaScript is
|
|
260
|
+
* a check-then-act across a network hop, and concurrent requests — precisely the traffic the cap
|
|
261
|
+
* exists for — would each read the pre-increment value and all conclude there is room.
|
|
262
|
+
*/
|
|
263
|
+
const HIT_SCRIPT = `
|
|
264
|
+
local window = tonumber(ARGV[1])
|
|
265
|
+
local target = KEYS[1]
|
|
266
|
+
local overflow = 0
|
|
267
|
+
if redis.call('EXISTS', KEYS[1]) == 0 then
|
|
268
|
+
local seen = redis.call('INCR', KEYS[2])
|
|
269
|
+
if seen == 1 then
|
|
270
|
+
redis.call('EXPIRE', KEYS[2], window)
|
|
271
|
+
end
|
|
272
|
+
if seen > tonumber(ARGV[2]) then
|
|
273
|
+
target = KEYS[3]
|
|
274
|
+
overflow = 1
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
local c = redis.call('INCR', target)
|
|
278
|
+
if c == 1 then
|
|
279
|
+
redis.call('EXPIRE', target, window)
|
|
280
|
+
end
|
|
281
|
+
local ttl = redis.call('TTL', target)
|
|
282
|
+
if ttl < 0 then
|
|
283
|
+
redis.call('EXPIRE', target, window)
|
|
284
|
+
ttl = window
|
|
285
|
+
end
|
|
286
|
+
return {c, ttl, overflow}
|
|
287
|
+
`;
|
|
288
|
+
|
|
289
|
+
/** Name the hit script is registered under via ioredis `defineCommand` */
|
|
290
|
+
const HIT_COMMAND = 'ltRateLimitHit';
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Redis-backed fixed-window store. All replicas share the counters, so the
|
|
294
|
+
* configured limit is enforced exactly regardless of replica count.
|
|
295
|
+
*
|
|
296
|
+
* Keys: `<keyPrefix>:rate-limit:<namespace>:<key>` with the window TTL, plus two framework-owned
|
|
297
|
+
* keys under the same prefix that bound the keyspace — `#meta:cardinality` and
|
|
298
|
+
* `#overflow:<slot>`, see {@link RedisRateLimitStore.hit}. Everything expires with the window,
|
|
299
|
+
* so there is no cleanup pass.
|
|
300
|
+
*/
|
|
301
|
+
export class RedisRateLimitStore implements RateLimitStore {
|
|
302
|
+
/**
|
|
303
|
+
* Process-local store used while Redis is unreachable.
|
|
304
|
+
*
|
|
305
|
+
* Neither of the obvious failure modes is acceptable for a rate limiter: letting the
|
|
306
|
+
* error escape turns every sign-in, sign-up and password-reset into a 500 for the
|
|
307
|
+
* duration of a Redis blip, and swallowing it into "allowed" silently removes the
|
|
308
|
+
* brute-force protection these endpoints exist to have. Degrading to the in-memory
|
|
309
|
+
* counter keeps a real bound — the exact one that applied before Redis was introduced —
|
|
310
|
+
* at the known cost that the effective limit is then per replica again.
|
|
311
|
+
*/
|
|
312
|
+
protected readonly fallback = new InMemoryRateLimitStore();
|
|
313
|
+
|
|
314
|
+
/** Whether the current state is degraded, so the transitions are logged once each */
|
|
315
|
+
protected degraded = false;
|
|
316
|
+
|
|
317
|
+
protected readonly logger = new Logger(RedisRateLimitStore.name);
|
|
318
|
+
|
|
319
|
+
/** So the saturation warning is logged once, not per request */
|
|
320
|
+
protected capacityWarned = false;
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Clients the hit script has already been registered on.
|
|
324
|
+
*
|
|
325
|
+
* `defineCommand` is per connection, and `CoreRedisService` may hand out a different client
|
|
326
|
+
* after a reconnect or in a second app instance, so the set is keyed by the client object.
|
|
327
|
+
* Weak, because a discarded connection must not be kept alive by this bookkeeping.
|
|
328
|
+
*/
|
|
329
|
+
protected readonly prepared = new WeakSet<object>();
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* @param maxKeys distinct counter keys the namespace may create per window before new keys are
|
|
333
|
+
* folded into the coarse overflow buckets (see {@link RedisRateLimitStore.hit})
|
|
334
|
+
*/
|
|
335
|
+
constructor(
|
|
336
|
+
protected readonly redisService: CoreRedisService,
|
|
337
|
+
protected readonly namespace: string,
|
|
338
|
+
protected readonly maxKeys = 10000,
|
|
339
|
+
) {}
|
|
340
|
+
|
|
341
|
+
async clear(): Promise<void> {
|
|
342
|
+
await this.fallback.clear();
|
|
343
|
+
this.capacityWarned = false;
|
|
344
|
+
// The empty prefix covers the whole namespace. Built through the same pattern builder as
|
|
345
|
+
// resetByPrefix on purpose: a second, hand-rolled pattern here is how the write path and the
|
|
346
|
+
// scan path drifted apart in the first place.
|
|
347
|
+
await this.guard(() => this.deleteByPattern(this.redisKeyPattern('')), undefined);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Count one hit, bounding the keyspace the namespace may occupy.
|
|
352
|
+
*
|
|
353
|
+
* Redis keys expire, but nothing stops a caller from CREATING them: both parts of a key are
|
|
354
|
+
* caller-influenced (a forwarded-for value, a request path), so an unbounded INCR is an
|
|
355
|
+
* unbounded write primitive against the instance that now also holds cron leases, MCP session
|
|
356
|
+
* ownership, tenant-cache invalidation and the Hub buffers. {@link InMemoryRateLimitStore} has
|
|
357
|
+
* always defended against exactly this; a store that drops the defense the moment `redis` is
|
|
358
|
+
* configured would turn an opt-in improvement into a silent regression.
|
|
359
|
+
*
|
|
360
|
+
* So the same shape applies: past `maxKeys` distinct keys per window, further NEW keys are
|
|
361
|
+
* folded into a fixed set of {@link OVERFLOW_BUCKETS} shared counters. They are still counted,
|
|
362
|
+
* just coarsely — unrelated clients then share a limit and are throttled earlier than their own
|
|
363
|
+
* traffic warrants. That is the right direction to fail; an uncounted `1` per fresh key would be
|
|
364
|
+
* a complete bypass for the price of filling the keyspace.
|
|
365
|
+
*/
|
|
366
|
+
async hit(key: string, windowSeconds: number): Promise<RateLimitStoreHit> {
|
|
367
|
+
return this.guard(
|
|
368
|
+
async () => {
|
|
369
|
+
const [count, ttl, overflow] = await this.runHit(key, windowSeconds);
|
|
370
|
+
if (this.degraded) {
|
|
371
|
+
this.degraded = false;
|
|
372
|
+
this.logger.log('Redis rate limiting recovered — limits are shared across replicas again');
|
|
373
|
+
}
|
|
374
|
+
if (overflow && !this.capacityWarned) {
|
|
375
|
+
this.capacityWarned = true;
|
|
376
|
+
this.logger.warn(
|
|
377
|
+
`Rate limit keyspace for ${namespaceLabel(this.namespace)} reached ${this.maxKeys} distinct counters ` +
|
|
378
|
+
`within one window. New clients now share ${OVERFLOW_BUCKETS} coarse counters until the window rolls ` +
|
|
379
|
+
'over, so they may be limited earlier than their own traffic warrants. Existing limits are unaffected.',
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
return { count, resetIn: ttl };
|
|
383
|
+
},
|
|
384
|
+
() => this.fallback.hit(key, windowSeconds),
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Execute {@link HIT_SCRIPT}, preferring the registered command over shipping the script body.
|
|
390
|
+
*
|
|
391
|
+
* `defineCommand` sends EVALSHA and retries with the full body only on a `NOSCRIPT` (an empty
|
|
392
|
+
* script cache after a Redis restart), instead of pushing ~600 bytes of Lua on every single
|
|
393
|
+
* request. The EVAL branch remains for a client that does not implement `defineCommand` —
|
|
394
|
+
* a wrapper or a test double — where failing would be worse than one extra payload.
|
|
395
|
+
*/
|
|
396
|
+
protected async runHit(key: string, windowSeconds: number): Promise<[number, number, number]> {
|
|
397
|
+
const client = this.redisService.getClient() as any;
|
|
398
|
+
const args = [this.redisKey(key), this.cardinalityKey(), this.overflowKey(key), windowSeconds, this.maxKeys];
|
|
399
|
+
|
|
400
|
+
if (typeof client.defineCommand === 'function') {
|
|
401
|
+
if (!this.prepared.has(client)) {
|
|
402
|
+
client.defineCommand(HIT_COMMAND, { lua: HIT_SCRIPT, numberOfKeys: 3 });
|
|
403
|
+
this.prepared.add(client);
|
|
404
|
+
}
|
|
405
|
+
return (await client[HIT_COMMAND](...args)) as [number, number, number];
|
|
406
|
+
}
|
|
407
|
+
return (await client.eval(HIT_SCRIPT, 3, ...args)) as [number, number, number];
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async resetByPrefix(prefix: string): Promise<void> {
|
|
411
|
+
await this.fallback.resetByPrefix(prefix);
|
|
412
|
+
await this.guard(() => this.deleteByPattern(this.redisKeyPattern(prefix)), undefined);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
size(): number {
|
|
416
|
+
return -1;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Release the fallback store's cleanup interval */
|
|
420
|
+
destroy(): void {
|
|
421
|
+
this.fallback.destroy();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Run a Redis operation, degrading to the local fallback instead of throwing
|
|
426
|
+
*/
|
|
427
|
+
protected async guard<T>(operation: () => Promise<T>, onFailure: (() => Promise<T> | T) | undefined): Promise<T> {
|
|
428
|
+
try {
|
|
429
|
+
return await operation();
|
|
430
|
+
} catch (error) {
|
|
431
|
+
if (!this.degraded) {
|
|
432
|
+
this.degraded = true;
|
|
433
|
+
this.logger.error(
|
|
434
|
+
`Redis rate limiting unavailable (${namespaceLabel(this.namespace)}): ${
|
|
435
|
+
error instanceof Error ? error.message : 'Unknown error'
|
|
436
|
+
}. Falling back to per-replica in-memory limits until Redis recovers.`,
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
return onFailure ? await onFailure() : (undefined as T);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** The literal key a counter is stored under — never glob-escaped, that belongs to the pattern */
|
|
444
|
+
protected redisKey(key: string): string {
|
|
445
|
+
return this.redisService.key('rate-limit', this.namespace, key);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* `SCAN MATCH` pattern covering every counter under `prefix`.
|
|
450
|
+
*
|
|
451
|
+
* The whole literal is escaped and only then extended by OUR wildcard, so the pattern matches
|
|
452
|
+
* exactly the keys {@link RedisRateLimitStore.redisKey} writes — including the `keyPrefix` and
|
|
453
|
+
* namespace segments, which a project's `redis.keyPrefix` could equally carry a `*` into.
|
|
454
|
+
*/
|
|
455
|
+
protected redisKeyPattern(prefix: string): string {
|
|
456
|
+
return `${escapeGlob(this.redisKey(prefix))}*`;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Counter of distinct keys created in the current window.
|
|
461
|
+
*
|
|
462
|
+
* It carries the window as its own TTL and is never refreshed, so the cap means "at most
|
|
463
|
+
* `maxKeys` NEW counters per window" and heals by itself: since every counter also lives one
|
|
464
|
+
* window, that bounds the live keyspace at roughly `maxKeys` rather than capping it forever.
|
|
465
|
+
*
|
|
466
|
+
* The `#meta` segment sits under the namespace prefix so `clear()` sweeps it along with the
|
|
467
|
+
* counters. It is NOT unforgeable: {@link rateLimitKey} escapes a colon inside a part but joins
|
|
468
|
+
* the parts with a real one, so `rateLimitKey('#meta', 'cardinality')` reproduces this key
|
|
469
|
+
* exactly. That costs nothing — the counter it would land on is the one every fresh key
|
|
470
|
+
* increments anyway, and the cap it feeds fails towards COARSER limits, never towards none.
|
|
471
|
+
*/
|
|
472
|
+
protected cardinalityKey(): string {
|
|
473
|
+
return this.redisService.key('rate-limit', this.namespace, '#meta', 'cardinality');
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Shared coarse counter a key falls into once the namespace is saturated */
|
|
477
|
+
protected overflowKey(key: string): string {
|
|
478
|
+
return this.redisService.key('rate-limit', this.namespace, '#overflow', String(overflowSlot(key)));
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
protected async deleteByPattern(pattern: string): Promise<void> {
|
|
482
|
+
const client = this.redisService.getClient();
|
|
483
|
+
let cursor = '0';
|
|
484
|
+
do {
|
|
485
|
+
const [nextCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', 200);
|
|
486
|
+
if (keys.length) {
|
|
487
|
+
await client.del(...keys);
|
|
488
|
+
}
|
|
489
|
+
cursor = nextCursor;
|
|
490
|
+
} while (cursor !== '0');
|
|
491
|
+
}
|
|
492
|
+
}
|
|
@@ -568,6 +568,31 @@ the server log; the response carries only a stable `#LTNS_0901` code.
|
|
|
568
568
|
`tools/list` / `tools/call` are filtered to and executed with their permissions.
|
|
569
569
|
- Unauthenticated requests get `401` with a `WWW-Authenticate` header.
|
|
570
570
|
|
|
571
|
+
### Multi-replica / sticky sessions
|
|
572
|
+
|
|
573
|
+
An MCP session owns a **live Streamable-HTTP transport** — an open response stream
|
|
574
|
+
held in process memory. It cannot be serialized, so it cannot be moved to another
|
|
575
|
+
replica. **`/ai/mcp` therefore requires sticky sessions** behind a load balancer:
|
|
576
|
+
every request carrying the same `mcp-session-id` must reach the replica that
|
|
577
|
+
created it.
|
|
578
|
+
|
|
579
|
+
When [Redis](../../common/services/core-redis.service.ts) is configured
|
|
580
|
+
(`ServerOptions.redis`), the controller additionally registers each session id in a
|
|
581
|
+
shared registry (`<keyPrefix>:ai-mcp-session:<id>` → `<hostname>:<pid>`, 1h TTL,
|
|
582
|
+
refreshed on every request, deleted on close/eviction). That does **not** make
|
|
583
|
+
sessions portable; it makes the failure legible: a request that lands on the wrong
|
|
584
|
+
replica gets
|
|
585
|
+
|
|
586
|
+
```
|
|
587
|
+
409 Conflict — MCP session belongs to another server instance (api-7f4d:31);
|
|
588
|
+
this instance is api-9b2c:29. … /ai/mcp requires sticky sessions …
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
instead of a `404 Unknown or expired MCP session`, which reads like an expiry bug
|
|
592
|
+
and sends operators looking in the wrong place.
|
|
593
|
+
|
|
594
|
+
Without Redis nothing changes: unknown ids answer `404` exactly as before.
|
|
595
|
+
|
|
571
596
|
### OAuth 2.1 (`ai.mcp.oauth: true`)
|
|
572
597
|
|
|
573
598
|
For generic MCP clients that auto-discover + register:
|