@lenne.tech/nest-server 12.2.0 → 12.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/rules/better-auth.md +31 -4
- package/.claude/rules/configurable-features.md +8 -7
- package/.claude/rules/package-management.md +18 -2
- package/.claude/rules/persistence.md +2 -2
- package/.claude/rules/testing.md +5 -5
- package/.claude/rules/versioning.md +13 -2
- package/CLAUDE.md +5 -0
- package/FRAMEWORK-API.md +4 -3
- package/README.md +2 -2
- package/dist/config.env.js +2 -2
- package/dist/config.env.js.map +1 -1
- package/dist/core/common/helpers/graphql-ws-connection.helper.d.ts +4 -0
- package/dist/core/common/helpers/graphql-ws-connection.helper.js +17 -3
- package/dist/core/common/helpers/graphql-ws-connection.helper.js.map +1 -1
- package/dist/core/common/interceptors/check-security.interceptor.js +19 -2
- package/dist/core/common/interceptors/check-security.interceptor.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +8 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.d.ts +3 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.js +29 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
- package/dist/core/modules/ai/core-ai.constants.d.ts +3 -0
- package/dist/core/modules/ai/core-ai.constants.js +3 -0
- package/dist/core/modules/ai/core-ai.constants.js.map +1 -1
- package/dist/core/modules/ai/core-ai.controller.d.ts +9 -0
- package/dist/core/modules/ai/core-ai.controller.js +48 -1
- package/dist/core/modules/ai/core-ai.controller.js.map +1 -1
- package/dist/core/modules/ai/core-ai.module.d.ts +2 -0
- package/dist/core/modules/ai/core-ai.module.js +9 -1
- package/dist/core/modules/ai/core-ai.module.js.map +1 -1
- package/dist/core/modules/ai/core-ai.resolver.d.ts +6 -0
- package/dist/core/modules/ai/core-ai.resolver.js +32 -0
- package/dist/core/modules/ai/core-ai.resolver.js.map +1 -1
- package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.d.ts +65 -1
- package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js +6 -3
- package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js.map +1 -1
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js +48 -1
- package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js.map +1 -1
- package/dist/core/modules/ai/helpers/ai-tool-authorization.helper.d.ts +9 -0
- package/dist/core/modules/ai/helpers/ai-tool-authorization.helper.js +54 -0
- package/dist/core/modules/ai/helpers/ai-tool-authorization.helper.js.map +1 -0
- package/dist/core/modules/ai/index.d.ts +8 -0
- package/dist/core/modules/ai/index.js +7 -0
- package/dist/core/modules/ai/index.js.map +1 -1
- package/dist/core/modules/ai/inputs/core-ai-settings.input.d.ts +3 -0
- package/dist/core/modules/ai/inputs/core-ai-settings.input.js +31 -0
- package/dist/core/modules/ai/inputs/core-ai-settings.input.js.map +1 -0
- package/dist/core/modules/ai/interfaces/ai-tool.interface.d.ts +2 -0
- package/dist/core/modules/ai/models/core-ai-settings.model.d.ts +4 -0
- package/dist/core/modules/ai/models/core-ai-settings.model.js +30 -0
- package/dist/core/modules/ai/models/core-ai-settings.model.js.map +1 -0
- package/dist/core/modules/ai/models/core-ai-status.model.d.ts +3 -0
- package/dist/core/modules/ai/models/core-ai-status.model.js +25 -0
- package/dist/core/modules/ai/models/core-ai-status.model.js.map +1 -0
- package/dist/core/modules/ai/models/core-ai-user-setting.model.d.ts +8 -0
- package/dist/core/modules/ai/models/core-ai-user-setting.model.js +45 -0
- package/dist/core/modules/ai/models/core-ai-user-setting.model.js.map +1 -0
- package/dist/core/modules/ai/services/core-ai-conversation.service.d.ts +4 -1
- package/dist/core/modules/ai/services/core-ai-conversation.service.js +16 -4
- package/dist/core/modules/ai/services/core-ai-conversation.service.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js +6 -6
- package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-mcp.service.d.ts +27 -2
- package/dist/core/modules/ai/services/core-ai-mcp.service.js +118 -11
- package/dist/core/modules/ai/services/core-ai-mcp.service.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-prompt-builder.service.js +9 -6
- package/dist/core/modules/ai/services/core-ai-prompt-builder.service.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai-user-setting.service.d.ts +17 -0
- package/dist/core/modules/ai/services/core-ai-user-setting.service.js +72 -0
- package/dist/core/modules/ai/services/core-ai-user-setting.service.js.map +1 -0
- package/dist/core/modules/ai/services/core-ai.service.d.ts +8 -3
- package/dist/core/modules/ai/services/core-ai.service.js +68 -29
- package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.config.js +10 -4
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +2 -2
- 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-path-guard.middleware.d.ts +8 -0
- package/dist/core/modules/better-auth/core-better-auth-path-guard.middleware.js +64 -0
- package/dist/core/modules/better-auth/core-better-auth-path-guard.middleware.js.map +1 -0
- package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js +2 -1
- 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 +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +5 -3
- 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.module.js +2 -0
- package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
- package/dist/core/modules/better-auth/index.d.ts +1 -0
- package/dist/core/modules/better-auth/index.js +1 -0
- package/dist/core/modules/better-auth/index.js.map +1 -1
- package/dist/core/modules/error-code/error-codes.d.ts +18 -0
- package/dist/core/modules/error-code/error-codes.js +16 -0
- package/dist/core/modules/error-code/error-codes.js.map +1 -1
- package/dist/core/modules/sync/core-sync.service.d.ts +1 -0
- package/dist/core/modules/sync/core-sync.service.js +31 -2
- package/dist/core/modules/sync/core-sync.service.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.guard.js +10 -0
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.service.d.ts +21 -1
- package/dist/core/modules/tenant/core-tenant.service.js +153 -1
- package/dist/core/modules/tenant/core-tenant.service.js.map +1 -1
- package/dist/server/modules/error-code/error-codes.d.ts +2 -0
- package/dist/test/test.helper.d.ts +1 -0
- package/dist/test/test.helper.js +9 -2
- package/dist/test/test.helper.js.map +1 -1
- package/docs/REQUEST-LIFECYCLE.md +5 -4
- package/docs/error-codes.md +14 -0
- package/docs/offline-sync-protocol.md +17 -8
- package/migration-guides/12.0.x-to-12.1.x.md +0 -1
- package/migration-guides/12.2.x-to-12.3.x.md +440 -0
- package/migration-guides/12.3.0-to-12.3.1.md +136 -0
- package/package.json +26 -26
- package/src/config.env.ts +6 -2
- package/src/core/common/helpers/graphql-ws-connection.helper.ts +32 -4
- package/src/core/common/interceptors/check-security.interceptor.ts +26 -2
- package/src/core/common/interfaces/server-options.interface.ts +84 -8
- package/src/core/common/persistence/sync-model.ts +1 -1
- package/src/core/modules/ai/INTEGRATION-CHECKLIST.md +36 -15
- package/src/core/modules/ai/README.md +178 -25
- package/src/core/modules/ai/core-ai-mcp.controller.ts +48 -1
- package/src/core/modules/ai/core-ai.constants.ts +10 -0
- package/src/core/modules/ai/core-ai.controller.ts +55 -1
- package/src/core/modules/ai/core-ai.module.ts +14 -0
- package/src/core/modules/ai/core-ai.resolver.ts +36 -0
- package/src/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.ts +35 -4
- package/src/core/modules/ai/helpers/ai-mcp-oauth.helper.ts +68 -1
- package/src/core/modules/ai/helpers/ai-tool-authorization.helper.ts +99 -0
- package/src/core/modules/ai/index.ts +19 -0
- package/src/core/modules/ai/inputs/core-ai-settings.input.ts +24 -0
- package/src/core/modules/ai/interfaces/ai-hook.interface.ts +1 -1
- package/src/core/modules/ai/interfaces/ai-tool.interface.ts +38 -26
- package/src/core/modules/ai/models/core-ai-mode.model.ts +1 -1
- package/src/core/modules/ai/models/core-ai-settings.model.ts +23 -0
- package/src/core/modules/ai/models/core-ai-status.model.ts +16 -0
- package/src/core/modules/ai/models/core-ai-tool-policy.model.ts +1 -1
- package/src/core/modules/ai/models/core-ai-user-setting.model.ts +46 -0
- package/src/core/modules/ai/services/core-ai-conversation.service.ts +31 -6
- package/src/core/modules/ai/services/core-ai-mcp-oauth.service.ts +12 -9
- package/src/core/modules/ai/services/core-ai-mcp.service.ts +206 -13
- package/src/core/modules/ai/services/core-ai-prompt-builder.service.ts +13 -6
- package/src/core/modules/ai/services/core-ai-user-setting.service.ts +85 -0
- package/src/core/modules/ai/services/core-ai.service.ts +134 -35
- package/src/core/modules/api-token/README.md +4 -2
- package/src/core/modules/better-auth/CUSTOMIZATION.md +1 -1
- package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +12 -2
- package/src/core/modules/better-auth/README.md +32 -1
- package/src/core/modules/better-auth/better-auth.config.ts +21 -5
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +5 -2
- package/src/core/modules/better-auth/core-better-auth-path-guard.middleware.ts +85 -0
- package/src/core/modules/better-auth/core-better-auth-rate-limit.middleware.ts +5 -2
- package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +14 -7
- package/src/core/modules/better-auth/core-better-auth.module.ts +12 -2
- package/src/core/modules/better-auth/index.ts +1 -0
- package/src/core/modules/error-code/error-codes.ts +18 -0
- package/src/core/modules/sync/README.md +11 -1
- package/src/core/modules/sync/core-sync.service.ts +48 -3
- package/src/core/modules/tenant/INTEGRATION-CHECKLIST.md +12 -1
- package/src/core/modules/tenant/README.md +49 -3
- package/src/core/modules/tenant/core-tenant.guard.ts +18 -2
- package/src/core/modules/tenant/core-tenant.service.ts +287 -2
- package/src/test/test.helper.ts +17 -3
|
@@ -2,6 +2,7 @@ import { Injectable, Logger, Optional } from '@nestjs/common';
|
|
|
2
2
|
|
|
3
3
|
import { IBetterAuthRateLimit } from '../../common/interfaces/server-options.interface.js';
|
|
4
4
|
import { CoreRedisService } from '../../common/services/core-redis.service.js';
|
|
5
|
+
import { canonicalRequestPath, pathContainsSegments } from './core-better-auth-path-guard.middleware.js';
|
|
5
6
|
import {
|
|
6
7
|
InMemoryRateLimitStore,
|
|
7
8
|
rateLimitKey,
|
|
@@ -134,10 +135,10 @@ export class CoreBetterAuthRateLimiter {
|
|
|
134
135
|
* Check if a request is allowed under the rate limit
|
|
135
136
|
*
|
|
136
137
|
* @param ip - Client IP address
|
|
137
|
-
* @param
|
|
138
|
+
* @param requestPath - Request path (relative to basePath), as sent; dot segments are resolved here
|
|
138
139
|
* @returns Rate limit check result
|
|
139
140
|
*/
|
|
140
|
-
async check(ip: string,
|
|
141
|
+
async check(ip: string, requestPath: string): Promise<RateLimitResult> {
|
|
141
142
|
// If rate limiting is disabled, always allow
|
|
142
143
|
if (!this.config.enabled) {
|
|
143
144
|
return {
|
|
@@ -149,6 +150,13 @@ export class CoreBetterAuthRateLimiter {
|
|
|
149
150
|
};
|
|
150
151
|
}
|
|
151
152
|
|
|
153
|
+
// Decide on the path the request is served under, not the one as sent: `/callback/../sign-in/email` reaches the
|
|
154
|
+
// sign-in handler, so it must not be skipped as a callback (CoreBetterAuthPathGuardMiddleware refuses such paths
|
|
155
|
+
// before they get here; this keeps the limiter correct where that guard is not registered). Lower-cased because
|
|
156
|
+
// Express routes case-insensitively: `/sign-in/EMAIL` is the same sign-in and must share its counter and its
|
|
157
|
+
// strict limit
|
|
158
|
+
const path = canonicalRequestPath(requestPath).toLowerCase();
|
|
159
|
+
|
|
152
160
|
// Check if this endpoint should skip rate limiting
|
|
153
161
|
if (this.shouldSkip(path)) {
|
|
154
162
|
return {
|
|
@@ -249,10 +257,11 @@ export class CoreBetterAuthRateLimiter {
|
|
|
249
257
|
}
|
|
250
258
|
|
|
251
259
|
/**
|
|
252
|
-
* Determine if an endpoint should skip rate limiting
|
|
260
|
+
* Determine if an endpoint should skip rate limiting: an entry matches whole path segments, so `/session` skips
|
|
261
|
+
* `/session` but not `/sessions-export`
|
|
253
262
|
*/
|
|
254
263
|
private shouldSkip(path: string): boolean {
|
|
255
|
-
return this.config.skipEndpoints.some((skip) => path
|
|
264
|
+
return this.config.skipEndpoints.some((skip) => pathContainsSegments(path, skip.toLowerCase()));
|
|
256
265
|
}
|
|
257
266
|
|
|
258
267
|
/**
|
|
@@ -260,9 +269,7 @@ export class CoreBetterAuthRateLimiter {
|
|
|
260
269
|
* Strict endpoints get half the normal limit
|
|
261
270
|
*/
|
|
262
271
|
private getLimit(path: string): number {
|
|
263
|
-
const isStrict = this.config.strictEndpoints.some(
|
|
264
|
-
(strict) => path === strict || path.endsWith(strict) || path.includes(strict),
|
|
265
|
-
);
|
|
272
|
+
const isStrict = this.config.strictEndpoints.some((strict) => pathContainsSegments(path, strict.toLowerCase()));
|
|
266
273
|
|
|
267
274
|
return isStrict ? Math.ceil(this.config.max / 2) : this.config.max;
|
|
268
275
|
}
|
|
@@ -39,6 +39,7 @@ import { DefaultBetterAuthResolver } from './better-auth.resolver.js';
|
|
|
39
39
|
import { CoreBetterAuthApiMiddleware } from './core-better-auth-api.middleware.js';
|
|
40
40
|
import { CoreBetterAuthChallengeService } from './core-better-auth-challenge.service.js';
|
|
41
41
|
import { CoreBetterAuthEmailVerificationService } from './core-better-auth-email-verification.service.js';
|
|
42
|
+
import { CoreBetterAuthPathGuardMiddleware } from './core-better-auth-path-guard.middleware.js';
|
|
42
43
|
import { CoreBetterAuthRateLimitMiddleware } from './core-better-auth-rate-limit.middleware.js';
|
|
43
44
|
import { CoreBetterAuthRateLimiter } from './core-better-auth-rate-limiter.service.js';
|
|
44
45
|
import { revokeApiTokensOfUser } from '../api-token/core-api-token.registry.js';
|
|
@@ -431,8 +432,10 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
|
|
|
431
432
|
* 1. CoreBetterAuthMiddleware - Session validation and user mapping for all routes
|
|
432
433
|
* Must run FIRST so that req.betterAuthSession is available for downstream middleware.
|
|
433
434
|
* In JWT mode, this resolves the JWT to a real DB session (via getActiveSessionForUser).
|
|
434
|
-
* 2.
|
|
435
|
-
*
|
|
435
|
+
* 2. CoreBetterAuthPathGuardMiddleware - Refuses a non-canonical path under the base path (400), before
|
|
436
|
+
* anything below reads it
|
|
437
|
+
* 3. CoreBetterAuthRateLimitMiddleware - Rate limiting for auth endpoints
|
|
438
|
+
* 4. CoreBetterAuthApiMiddleware - Forwards plugin endpoints (passkey, 2FA, etc.) to Better Auth's native handler
|
|
436
439
|
* Runs AFTER session middleware so it can use req.betterAuthSession.session.token
|
|
437
440
|
* to authenticate requests in JWT mode.
|
|
438
441
|
*/
|
|
@@ -453,6 +456,13 @@ export class CoreBetterAuthModule implements NestModule, OnModuleInit {
|
|
|
453
456
|
consumer.apply(CoreBetterAuthMiddleware).forRoutes('{*path}');
|
|
454
457
|
CoreBetterAuthModule.logger.debug('CoreBetterAuthMiddleware registered for all routes');
|
|
455
458
|
|
|
459
|
+
// Refuse a non-canonical path (`.` / `..` segments, also encoded, backslashes, encoded slashes) before anything
|
|
460
|
+
// under the base path reads it: every check below reads the path as SENT, while Better-Auth routes the one
|
|
461
|
+
// `new URL()` resolved — `/callback/../sign-in/email` would pass the rate limiter as a callback and run as a
|
|
462
|
+
// sign-in. Registered unconditionally and FIRST, so it guards the limiter, the controller routes and the
|
|
463
|
+
// forwarding alike.
|
|
464
|
+
consumer.apply(CoreBetterAuthPathGuardMiddleware).forRoutes(`${basePath}/*path`);
|
|
465
|
+
|
|
456
466
|
// Apply rate limiting to Better-Auth endpoints only.
|
|
457
467
|
// Mirrors the "presence implies enabled" contract `CoreBetterAuthRateLimiter.configure()`
|
|
458
468
|
// implements. Gating the MOUNT on `.enabled` alone made that fix unobservable: the limiter
|
|
@@ -34,6 +34,7 @@ export * from './core-better-auth-email-verification.service.js';
|
|
|
34
34
|
export * from './core-better-auth-error-codes.helper.js';
|
|
35
35
|
export * from './core-better-auth-migration-status.model.js';
|
|
36
36
|
export * from './core-better-auth-models.js';
|
|
37
|
+
export * from './core-better-auth-path-guard.middleware.js';
|
|
37
38
|
export * from './core-better-auth-rate-limit.middleware.js';
|
|
38
39
|
export * from './core-better-auth-rate-limiter.service.js';
|
|
39
40
|
export * from './core-better-auth-signup-validator.service.js';
|
|
@@ -494,6 +494,15 @@ export const LtnsErrors = {
|
|
|
494
494
|
},
|
|
495
495
|
},
|
|
496
496
|
|
|
497
|
+
INVALID_REQUEST_PATH: {
|
|
498
|
+
code: 'LTNS_0305',
|
|
499
|
+
message: 'Invalid request path',
|
|
500
|
+
translations: {
|
|
501
|
+
de: 'Der Anfragepfad ist ungültig.',
|
|
502
|
+
en: 'The request path is invalid.',
|
|
503
|
+
},
|
|
504
|
+
},
|
|
505
|
+
|
|
497
506
|
// =====================================================
|
|
498
507
|
// Resource Errors (LTNS_0400-LTNS_0499)
|
|
499
508
|
// =====================================================
|
|
@@ -713,6 +722,15 @@ export const LtnsErrors = {
|
|
|
713
722
|
},
|
|
714
723
|
},
|
|
715
724
|
|
|
725
|
+
AI_SETTING_ENFORCED: {
|
|
726
|
+
code: 'LTNS_0609',
|
|
727
|
+
message: 'This AI setting is fixed by the server',
|
|
728
|
+
translations: {
|
|
729
|
+
de: 'Diese KI-Einstellung ist vom Server festgelegt und kann nicht geändert werden.',
|
|
730
|
+
en: 'This AI setting is fixed by the server and cannot be changed.',
|
|
731
|
+
},
|
|
732
|
+
},
|
|
733
|
+
|
|
716
734
|
// =====================================================
|
|
717
735
|
// Offline Sync Errors (LTNS_0700-LTNS_0799)
|
|
718
736
|
// =====================================================
|
|
@@ -104,6 +104,13 @@ hashed, unique), records the outcome afterwards and answers a repeat with that o
|
|
|
104
104
|
A repeat that finds a claim still being written answers `retry` — that is how two tabs sending the same push end in one
|
|
105
105
|
write and no false conflict. A claim older than 30 s belongs to a request that died and is taken over.
|
|
106
106
|
|
|
107
|
+
Once an item of a record is not stored (`conflict`, `rejected`, `not_found`, `retry`), the later items of that record
|
|
108
|
+
in the same push are **held back** (12.3.0+): each builds on the version the one before it produces (protocol §6.2), and
|
|
109
|
+
applied on its own its base version could match what another writer left. A held-back item is never applied or
|
|
110
|
+
claimed; it is answered `retry` behind a `retry`, otherwise `conflict` with the record as the caller may see it
|
|
111
|
+
(`not_found` without one). Items of other records are unaffected. This holds within one push; across pushes the
|
|
112
|
+
client keeps the order (protocol §11).
|
|
113
|
+
|
|
107
114
|
## Change order and windows
|
|
108
115
|
|
|
109
116
|
The store decides how changes are ordered (`capabilities.changeOrder`): on SQL a commit-ordered sequence (`syncSeq`) —
|
|
@@ -133,7 +140,10 @@ A hint tells a connected client that pulling is worthwhile; sync works without t
|
|
|
133
140
|
- **Long-lived channels are re-checked:** the GraphQL subscription runs on the WebSocket `CoreModule` re-checks; an SSE
|
|
134
141
|
stream is registered in the same registry and ends at once on an invalidation naming the caller or their tenant, and
|
|
135
142
|
otherwise before the first hint after `graphQl.subscriptionRevalidationMs` — the EventSource reconnects (`retry`) and
|
|
136
|
-
the new request passes the guards. A client pulls once after every (re)connect.
|
|
143
|
+
the new request passes the guards. A client pulls once after every (re)connect. In the log the planned end after the
|
|
144
|
+
interval is a debug line (`Closing SSE hint stream of user …: re-check interval reached, client reconnects`); an end
|
|
145
|
+
an invalidation brings stays at `log` (`… authorization invalidated, client reconnects`), so a real revocation stands
|
|
146
|
+
out (12.3.0; before, every planned end read `Closing GraphQL WebSocket …: Session no longer valid`).
|
|
137
147
|
|
|
138
148
|
## Tombstone purge
|
|
139
149
|
|
|
@@ -157,7 +157,7 @@ const SYSTEM_RECORD_FIELDS = new Set([
|
|
|
157
157
|
]);
|
|
158
158
|
|
|
159
159
|
/**
|
|
160
|
-
* Offline sync (protocol `docs/offline-sync-protocol.md`,
|
|
160
|
+
* Offline sync (protocol `docs/offline-sync-protocol.md`, offline-sync plan): pull in pages
|
|
161
161
|
* along the store's change order, push item by item with idempotency, conflict strategies and client ids, resync when a
|
|
162
162
|
* cursor cannot be continued consistently.
|
|
163
163
|
*
|
|
@@ -686,17 +686,61 @@ export class CoreSyncService implements OnApplicationBootstrap, OnModuleDestroy,
|
|
|
686
686
|
/**
|
|
687
687
|
* Apply the pushed items in the order sent, each on its own (protocol §6.3) — one result per item. A malformed body
|
|
688
688
|
* is refused as a whole before any item is applied.
|
|
689
|
+
*
|
|
690
|
+
* A later item of a record carries the version the item before it produces (§6.2). Once an item of a record was not
|
|
691
|
+
* stored, the later items of that record are held back: applied on their own, their base version could match what
|
|
692
|
+
* another writer left, and the change would land on data the client never saw.
|
|
689
693
|
*/
|
|
690
694
|
async push(model: string, body: unknown, currentUser: any): Promise<{ results: ISyncPushResult[] }> {
|
|
691
695
|
const registration = this.registration(model);
|
|
692
696
|
const items = this.pushItems(body);
|
|
693
697
|
const results: ISyncPushResult[] = [];
|
|
698
|
+
const heldBack = new Map<string, 'refused' | 'retry'>();
|
|
694
699
|
for (const item of items) {
|
|
695
|
-
|
|
700
|
+
const held = heldBack.get(item.id);
|
|
701
|
+
if (held) {
|
|
702
|
+
results.push(await this.heldBackItem(registration, item, held, currentUser));
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
const result = await this.pushItem(registration, item, currentUser);
|
|
706
|
+
if (result.status === 'retry') {
|
|
707
|
+
heldBack.set(item.id, 'retry');
|
|
708
|
+
} else if (result.status !== 'applied' && result.status !== 'merged') {
|
|
709
|
+
heldBack.set(item.id, 'refused');
|
|
710
|
+
}
|
|
711
|
+
results.push(result);
|
|
696
712
|
}
|
|
697
713
|
return { results };
|
|
698
714
|
}
|
|
699
715
|
|
|
716
|
+
/**
|
|
717
|
+
* An item held back behind an earlier item of its record that was not stored. It is never applied and never claimed,
|
|
718
|
+
* so a repeat of the batch is answered the same way (the earlier item repeats its recorded outcome). Behind a
|
|
719
|
+
* `retry` it is `retry` too, and the client sends both again; behind a refusal it is the conflict the `reject`
|
|
720
|
+
* strategy answers — the record as the caller may see it, `not_found` without one.
|
|
721
|
+
*/
|
|
722
|
+
protected async heldBackItem(
|
|
723
|
+
registration: SyncModelRegistration,
|
|
724
|
+
item: ISyncPushItem,
|
|
725
|
+
reason: 'refused' | 'retry',
|
|
726
|
+
currentUser: any,
|
|
727
|
+
): Promise<ISyncPushResult> {
|
|
728
|
+
if (!isSyncId(item.id, registration.idFormat)) {
|
|
729
|
+
return reply(item, 'rejected', { code: 'INVALID_ID' });
|
|
730
|
+
}
|
|
731
|
+
if (reason === 'retry') {
|
|
732
|
+
return reply(item, 'retry');
|
|
733
|
+
}
|
|
734
|
+
try {
|
|
735
|
+
return await this.currentConflict(registration, item, currentUser);
|
|
736
|
+
} catch (error) {
|
|
737
|
+
this.logger.warn(
|
|
738
|
+
`Sync: ${registration.name} ${item.id} held back, record unreadable, client retries (${(error as Error)?.message ?? error})`,
|
|
739
|
+
);
|
|
740
|
+
return reply(item, 'retry');
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
700
744
|
/** The items of a push body, checked for shape; anything malformed answers 400 for the whole request */
|
|
701
745
|
protected pushItems(body: unknown): ISyncPushItem[] {
|
|
702
746
|
const invalid = (reason: string) => new BadRequestException(`${ErrorCode.SYNC_INVALID_REQUEST}: ${reason}`);
|
|
@@ -1412,8 +1456,9 @@ export class CoreSyncService implements OnApplicationBootstrap, OnModuleDestroy,
|
|
|
1412
1456
|
response.once?.('close', end);
|
|
1413
1457
|
registerWsConnection({
|
|
1414
1458
|
carrier,
|
|
1459
|
+
channel: 'SSE hint stream',
|
|
1415
1460
|
// No credential to check again: every re-check ends the stream, and the reconnect is checked by the guards
|
|
1416
|
-
|
|
1461
|
+
endsOnRecheck: true,
|
|
1417
1462
|
socket: {
|
|
1418
1463
|
close: () => end(),
|
|
1419
1464
|
once: (event, listener) => {
|
|
@@ -175,6 +175,16 @@ See [Tenant README — BetterAuth Integration](./README.md#betterauth-iam-integr
|
|
|
175
175
|
|
|
176
176
|
See the module README: "Deactivated Tenants" and "Service-Level Role Checks".
|
|
177
177
|
|
|
178
|
+
### 12. Removing a Tenant (since 12.3.0)
|
|
179
|
+
|
|
180
|
+
- **Can a tenant be deleted in your project?** Call `tenantService.purgeTenantData(tenantId)` where the project deletes
|
|
181
|
+
its tenant record, after deleting the tenant's files through `CoreFileService`. WHY: the core has no tenant model and
|
|
182
|
+
cannot notice the deletion; until the purge runs, the tenant's API tokens keep authenticating and its records stay.
|
|
183
|
+
- **Does a model reference the tenant in a field other than `tenantId`?** Override `purgeTenantRecords()` and call
|
|
184
|
+
`super`. WHY: the purge finds `tenantId` paths and the core's own references only.
|
|
185
|
+
|
|
186
|
+
See the module README: "Removing a Tenant".
|
|
187
|
+
|
|
178
188
|
## Verification Checklist
|
|
179
189
|
|
|
180
190
|
- [ ] `pnpm run build` succeeds
|
|
@@ -206,4 +216,5 @@ See the module README: "Deactivated Tenants" and "Service-Level Role Checks".
|
|
|
206
216
|
| Public endpoint accessing tenantId-schema | 403 Safety Net exception | Use `@SkipTenantCheck()` + `RequestContext.runWithBypassTenantGuard()` |
|
|
207
217
|
| Passing user-supplied tenantId to create() | Cross-tenant write possible | Let plugin auto-set tenantId from context |
|
|
208
218
|
| Custom hierarchy doesn't match config | Roles fail unexpectedly | Ensure `createHierarchyRoles()` input matches `multiTenancy.roleHierarchy` |
|
|
209
|
-
| `@SkipTenantCheck()` on BetterAuth handler | Redundant since v11.21.2 | Remove — auto-skip handles this via `betterAuth.skipTenantCheck` (default) |
|
|
219
|
+
| `@SkipTenantCheck()` on BetterAuth handler | Redundant since v11.21.2 | Remove — auto-skip handles this via `betterAuth.skipTenantCheck` (default) |
|
|
220
|
+
| Deleting only the tenant record | Tenant tokens and records survive | Call `purgeTenantData(tenantId)` when the tenant is deleted |
|
|
@@ -391,6 +391,51 @@ Mechanism, for anyone extending the guard (`core-tenant.guard.ts`):
|
|
|
391
391
|
- **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.
|
|
392
392
|
- **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.
|
|
393
393
|
|
|
394
|
+
### Removing a Tenant (`purgeTenantData`, since 12.3.0)
|
|
395
|
+
|
|
396
|
+
The core has no tenant model, so it cannot notice that a project deleted one — but it knows every place where it
|
|
397
|
+
stores tenant data itself. When the project deletes its tenant record, it calls:
|
|
398
|
+
|
|
399
|
+
```typescript
|
|
400
|
+
const report = await this.tenantService.purgeTenantData(tenantId);
|
|
401
|
+
// { TenantMember: 4, Invoice: 120, ApiToken: 2, AiConnection: 1 } — models with nothing to delete are left out
|
|
402
|
+
|
|
403
|
+
// Count first, e.g. for a confirmation dialog:
|
|
404
|
+
await this.tenantService.purgeTenantData(tenantId, { dryRun: true });
|
|
405
|
+
|
|
406
|
+
// Leave a model alone (in addition to multiTenancy.excludeSchemas):
|
|
407
|
+
await this.tenantService.purgeTenantData(tenantId, { exclude: ['AuditEntry'] });
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
| Step | What is deleted |
|
|
411
|
+
| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
412
|
+
| 1 | Every way in, so nobody writes into the tenant while its data goes: each membership in any status (`deleteAllMembers()`), its cached access invalidated at once — on every replica with Redis — and the tenant's API tokens (`tenant`), which need no member |
|
|
413
|
+
| 2 | Every model registered with `CorePersistenceModule.forFeature()` that has a `tenantId` path, on whatever database it is routed to — the project's own models included |
|
|
414
|
+
| 3 | Models with a `tenantId` path registered only through `MongooseModule.forFeature()` (MongoDB) |
|
|
415
|
+
| 4 | Core models that name the tenant in another field: AI budget limits, connection preferences, tool grants and tool policies (`scope: 'tenant'`, `refId`) |
|
|
416
|
+
| 5 | AI connections: one only this tenant could use is deleted; from a shared one only the tenant is removed. An empty `tenantIds` list means "every tenant", so emptying it would hand the connection to all of them |
|
|
417
|
+
|
|
418
|
+
Not deleted:
|
|
419
|
+
|
|
420
|
+
- models in `multiTenancy.excludeSchemas` — a `tenantId` there is not ownership — and models named in `exclude`;
|
|
421
|
+
- users (the `users` table or collection): they are global and may belong to other tenants. That holds where `User`
|
|
422
|
+
carries a `tenantId` too (per-tenant users): delete those through the user service before the purge, which also
|
|
423
|
+
ends their Better-Auth sessions and accounts;
|
|
424
|
+
- **files**: the file metadata carries no `tenantId`, and only the project knows which files (GridFS, S3, file system)
|
|
425
|
+
belonged to the tenant. Delete them through `CoreFileService` before the purge (a TUS upload records
|
|
426
|
+
`metadata.tenantId`);
|
|
427
|
+
- references the core does not know of, e.g. a `companyId` field. Add them by overriding the protected
|
|
428
|
+
`purgeTenantRecords()` and calling `super`.
|
|
429
|
+
|
|
430
|
+
The memberships cannot be excluded. Records go in batches of 1000 ids, so a large tenant never holds a synchronized
|
|
431
|
+
model's change lock (and with it every other tenant's writes to that model) for the whole purge. A synchronized model
|
|
432
|
+
(offline sync) keeps a tombstone per deleted record until the tombstone purge removes it. The purge runs as a system operation (no tenant filter), so call it only from a route that
|
|
433
|
+
checked who may delete the tenant.
|
|
434
|
+
|
|
435
|
+
`tenantGuard.invalidateTenant(tenantId)` clears the tenant's cached memberships as well (since 12.3.0). Before, it
|
|
436
|
+
forgot only the `isTenantActive` answer, so memberships removed without `invalidateUser()` kept counting for up to
|
|
437
|
+
`cacheTtlMs`.
|
|
438
|
+
|
|
394
439
|
### SkipTenantCheck
|
|
395
440
|
|
|
396
441
|
For high-frequency endpoints that don't access tenant-scoped data, use `@SkipTenantCheck()` to avoid the membership lookup entirely.
|
|
@@ -466,9 +511,10 @@ this module:
|
|
|
466
511
|
global roles removed (no admin bypass), optionally restricted to one tenant and capped by
|
|
467
512
|
`maxTenantRole`.
|
|
468
513
|
|
|
469
|
-
Both are denied on routes without `@ApiTokenScopes()`. **
|
|
470
|
-
|
|
471
|
-
deletion, and a tenant token does not depend on any member, so it keeps authenticating
|
|
514
|
+
Both are denied on routes without `@ApiTokenScopes()`. **When you delete a tenant, call
|
|
515
|
+
`CoreTenantService.purgeTenantData()` (or at least `CoreApiTokenService.deleteAllForTenant()`)** — the core has no
|
|
516
|
+
tenant model to notice the deletion, and a tenant token does not depend on any member, so it keeps authenticating
|
|
517
|
+
until then.
|
|
472
518
|
|
|
473
519
|
## Related
|
|
474
520
|
|
|
@@ -811,11 +811,27 @@ export class CoreTenantGuard implements CanActivate, OnApplicationBootstrap, OnM
|
|
|
811
811
|
}
|
|
812
812
|
|
|
813
813
|
/**
|
|
814
|
-
* Forget
|
|
815
|
-
*
|
|
814
|
+
* Forget everything cached about a tenant locally (no broadcast): its `isTenantActive` answer, the cached
|
|
815
|
+
* memberships in it and every cached tenant list that contains it. Then re-check the open WebSocket connections
|
|
816
|
+
* scoped to it.
|
|
817
|
+
*
|
|
818
|
+
* The memberships matter for a tenant that is gone rather than switched off: when its memberships were removed
|
|
819
|
+
* without `invalidateUser()`, a cached membership kept it reachable until `cacheTtlMs` ran out. A tenant switched off
|
|
820
|
+
* through `isTenantActive` needs only the active flag, which every membership path asks right after.
|
|
816
821
|
*/
|
|
817
822
|
protected clearTenant(tenantId: string): void {
|
|
818
823
|
this.tenantActiveCache.delete(tenantId);
|
|
824
|
+
for (const key of this.membershipCache.keys()) {
|
|
825
|
+
// Key: `${userId}:${tenantId}` — user ids carry no ':', so the first one separates the two
|
|
826
|
+
if (key.slice(key.indexOf(':') + 1) === tenantId) {
|
|
827
|
+
this.membershipCache.delete(key);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
for (const [key, entry] of this.tenantIdsCache.entries()) {
|
|
831
|
+
if (entry.ids.includes(tenantId)) {
|
|
832
|
+
this.tenantIdsCache.delete(key);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
819
835
|
revalidateWsConnectionsForTenant(tenantId);
|
|
820
836
|
}
|
|
821
837
|
|