@lenne.tech/nest-server 12.2.0 → 12.3.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.
Files changed (152) hide show
  1. package/.claude/rules/better-auth.md +31 -4
  2. package/.claude/rules/configurable-features.md +7 -6
  3. package/.claude/rules/package-management.md +13 -1
  4. package/.claude/rules/persistence.md +2 -2
  5. package/.claude/rules/testing.md +5 -5
  6. package/.claude/rules/versioning.md +13 -2
  7. package/CLAUDE.md +5 -0
  8. package/FRAMEWORK-API.md +4 -3
  9. package/README.md +2 -2
  10. package/dist/config.env.js +2 -2
  11. package/dist/config.env.js.map +1 -1
  12. package/dist/core/common/helpers/graphql-ws-connection.helper.d.ts +4 -0
  13. package/dist/core/common/helpers/graphql-ws-connection.helper.js +17 -3
  14. package/dist/core/common/helpers/graphql-ws-connection.helper.js.map +1 -1
  15. package/dist/core/common/interfaces/server-options.interface.d.ts +8 -0
  16. package/dist/core/modules/ai/core-ai-mcp.controller.d.ts +3 -0
  17. package/dist/core/modules/ai/core-ai-mcp.controller.js +29 -0
  18. package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
  19. package/dist/core/modules/ai/core-ai.constants.d.ts +2 -0
  20. package/dist/core/modules/ai/core-ai.constants.js +2 -0
  21. package/dist/core/modules/ai/core-ai.constants.js.map +1 -1
  22. package/dist/core/modules/ai/core-ai.controller.d.ts +6 -0
  23. package/dist/core/modules/ai/core-ai.controller.js +31 -1
  24. package/dist/core/modules/ai/core-ai.controller.js.map +1 -1
  25. package/dist/core/modules/ai/core-ai.module.d.ts +2 -0
  26. package/dist/core/modules/ai/core-ai.module.js +8 -1
  27. package/dist/core/modules/ai/core-ai.module.js.map +1 -1
  28. package/dist/core/modules/ai/core-ai.resolver.d.ts +6 -0
  29. package/dist/core/modules/ai/core-ai.resolver.js +32 -0
  30. package/dist/core/modules/ai/core-ai.resolver.js.map +1 -1
  31. package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.d.ts +65 -1
  32. package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js +6 -3
  33. package/dist/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.js.map +1 -1
  34. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js +48 -1
  35. package/dist/core/modules/ai/helpers/ai-mcp-oauth.helper.js.map +1 -1
  36. package/dist/core/modules/ai/helpers/ai-tool-authorization.helper.d.ts +9 -0
  37. package/dist/core/modules/ai/helpers/ai-tool-authorization.helper.js +54 -0
  38. package/dist/core/modules/ai/helpers/ai-tool-authorization.helper.js.map +1 -0
  39. package/dist/core/modules/ai/index.d.ts +7 -0
  40. package/dist/core/modules/ai/index.js +6 -0
  41. package/dist/core/modules/ai/index.js.map +1 -1
  42. package/dist/core/modules/ai/inputs/core-ai-settings.input.d.ts +3 -0
  43. package/dist/core/modules/ai/inputs/core-ai-settings.input.js +31 -0
  44. package/dist/core/modules/ai/inputs/core-ai-settings.input.js.map +1 -0
  45. package/dist/core/modules/ai/interfaces/ai-tool.interface.d.ts +2 -0
  46. package/dist/core/modules/ai/models/core-ai-settings.model.d.ts +4 -0
  47. package/dist/core/modules/ai/models/core-ai-settings.model.js +30 -0
  48. package/dist/core/modules/ai/models/core-ai-settings.model.js.map +1 -0
  49. package/dist/core/modules/ai/models/core-ai-user-setting.model.d.ts +8 -0
  50. package/dist/core/modules/ai/models/core-ai-user-setting.model.js +45 -0
  51. package/dist/core/modules/ai/models/core-ai-user-setting.model.js.map +1 -0
  52. package/dist/core/modules/ai/services/core-ai-conversation.service.d.ts +4 -1
  53. package/dist/core/modules/ai/services/core-ai-conversation.service.js +16 -4
  54. package/dist/core/modules/ai/services/core-ai-conversation.service.js.map +1 -1
  55. package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js +6 -6
  56. package/dist/core/modules/ai/services/core-ai-mcp-oauth.service.js.map +1 -1
  57. package/dist/core/modules/ai/services/core-ai-mcp.service.d.ts +27 -2
  58. package/dist/core/modules/ai/services/core-ai-mcp.service.js +118 -11
  59. package/dist/core/modules/ai/services/core-ai-mcp.service.js.map +1 -1
  60. package/dist/core/modules/ai/services/core-ai-prompt-builder.service.js +9 -6
  61. package/dist/core/modules/ai/services/core-ai-prompt-builder.service.js.map +1 -1
  62. package/dist/core/modules/ai/services/core-ai-user-setting.service.d.ts +17 -0
  63. package/dist/core/modules/ai/services/core-ai-user-setting.service.js +72 -0
  64. package/dist/core/modules/ai/services/core-ai-user-setting.service.js.map +1 -0
  65. package/dist/core/modules/ai/services/core-ai.service.d.ts +8 -3
  66. package/dist/core/modules/ai/services/core-ai.service.js +68 -29
  67. package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
  68. package/dist/core/modules/better-auth/better-auth.config.js +3 -1
  69. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  70. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +2 -2
  71. package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
  72. package/dist/core/modules/better-auth/core-better-auth-path-guard.middleware.d.ts +8 -0
  73. package/dist/core/modules/better-auth/core-better-auth-path-guard.middleware.js +64 -0
  74. package/dist/core/modules/better-auth/core-better-auth-path-guard.middleware.js.map +1 -0
  75. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js +2 -1
  76. package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js.map +1 -1
  77. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +1 -1
  78. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +5 -3
  79. package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
  80. package/dist/core/modules/better-auth/core-better-auth.module.js +2 -0
  81. package/dist/core/modules/better-auth/core-better-auth.module.js.map +1 -1
  82. package/dist/core/modules/better-auth/index.d.ts +1 -0
  83. package/dist/core/modules/better-auth/index.js +1 -0
  84. package/dist/core/modules/better-auth/index.js.map +1 -1
  85. package/dist/core/modules/error-code/error-codes.d.ts +18 -0
  86. package/dist/core/modules/error-code/error-codes.js +16 -0
  87. package/dist/core/modules/error-code/error-codes.js.map +1 -1
  88. package/dist/core/modules/sync/core-sync.service.d.ts +1 -0
  89. package/dist/core/modules/sync/core-sync.service.js +31 -2
  90. package/dist/core/modules/sync/core-sync.service.js.map +1 -1
  91. package/dist/core/modules/tenant/core-tenant.guard.js +10 -0
  92. package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
  93. package/dist/core/modules/tenant/core-tenant.service.d.ts +21 -1
  94. package/dist/core/modules/tenant/core-tenant.service.js +153 -1
  95. package/dist/core/modules/tenant/core-tenant.service.js.map +1 -1
  96. package/dist/server/modules/error-code/error-codes.d.ts +2 -0
  97. package/dist/test/test.helper.d.ts +1 -0
  98. package/dist/test/test.helper.js +9 -2
  99. package/dist/test/test.helper.js.map +1 -1
  100. package/docs/REQUEST-LIFECYCLE.md +3 -2
  101. package/docs/error-codes.md +14 -0
  102. package/docs/offline-sync-protocol.md +17 -8
  103. package/migration-guides/12.0.x-to-12.1.x.md +0 -1
  104. package/migration-guides/12.2.x-to-12.3.x.md +440 -0
  105. package/package.json +20 -20
  106. package/src/config.env.ts +6 -2
  107. package/src/core/common/helpers/graphql-ws-connection.helper.ts +32 -4
  108. package/src/core/common/interfaces/server-options.interface.ts +80 -8
  109. package/src/core/common/persistence/sync-model.ts +1 -1
  110. package/src/core/modules/ai/INTEGRATION-CHECKLIST.md +36 -15
  111. package/src/core/modules/ai/README.md +163 -25
  112. package/src/core/modules/ai/core-ai-mcp.controller.ts +48 -1
  113. package/src/core/modules/ai/core-ai.constants.ts +6 -0
  114. package/src/core/modules/ai/core-ai.controller.ts +36 -1
  115. package/src/core/modules/ai/core-ai.module.ts +12 -0
  116. package/src/core/modules/ai/core-ai.resolver.ts +36 -0
  117. package/src/core/modules/ai/helpers/ai-mcp-oauth-consent.helper.ts +35 -4
  118. package/src/core/modules/ai/helpers/ai-mcp-oauth.helper.ts +68 -1
  119. package/src/core/modules/ai/helpers/ai-tool-authorization.helper.ts +99 -0
  120. package/src/core/modules/ai/index.ts +18 -0
  121. package/src/core/modules/ai/inputs/core-ai-settings.input.ts +24 -0
  122. package/src/core/modules/ai/interfaces/ai-hook.interface.ts +1 -1
  123. package/src/core/modules/ai/interfaces/ai-tool.interface.ts +38 -26
  124. package/src/core/modules/ai/models/core-ai-mode.model.ts +1 -1
  125. package/src/core/modules/ai/models/core-ai-settings.model.ts +23 -0
  126. package/src/core/modules/ai/models/core-ai-tool-policy.model.ts +1 -1
  127. package/src/core/modules/ai/models/core-ai-user-setting.model.ts +46 -0
  128. package/src/core/modules/ai/services/core-ai-conversation.service.ts +31 -6
  129. package/src/core/modules/ai/services/core-ai-mcp-oauth.service.ts +12 -9
  130. package/src/core/modules/ai/services/core-ai-mcp.service.ts +206 -13
  131. package/src/core/modules/ai/services/core-ai-prompt-builder.service.ts +13 -6
  132. package/src/core/modules/ai/services/core-ai-user-setting.service.ts +85 -0
  133. package/src/core/modules/ai/services/core-ai.service.ts +134 -35
  134. package/src/core/modules/api-token/README.md +4 -2
  135. package/src/core/modules/better-auth/CUSTOMIZATION.md +1 -1
  136. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +7 -0
  137. package/src/core/modules/better-auth/README.md +32 -1
  138. package/src/core/modules/better-auth/better-auth.config.ts +12 -1
  139. package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +5 -2
  140. package/src/core/modules/better-auth/core-better-auth-path-guard.middleware.ts +85 -0
  141. package/src/core/modules/better-auth/core-better-auth-rate-limit.middleware.ts +5 -2
  142. package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +14 -7
  143. package/src/core/modules/better-auth/core-better-auth.module.ts +12 -2
  144. package/src/core/modules/better-auth/index.ts +1 -0
  145. package/src/core/modules/error-code/error-codes.ts +18 -0
  146. package/src/core/modules/sync/README.md +11 -1
  147. package/src/core/modules/sync/core-sync.service.ts +48 -3
  148. package/src/core/modules/tenant/INTEGRATION-CHECKLIST.md +12 -1
  149. package/src/core/modules/tenant/README.md +49 -3
  150. package/src/core/modules/tenant/core-tenant.guard.ts +18 -2
  151. package/src/core/modules/tenant/core-tenant.service.ts +287 -2
  152. package/src/test/test.helper.ts +17 -3
@@ -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. CoreBetterAuthRateLimitMiddleware - Rate limiting for auth endpoints
435
- * 3. CoreBetterAuthApiMiddleware - Forwards plugin endpoints (passkey, 2FA, etc.) to Better Auth's native handler
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`, plan `.claude/plans/offline-sync/PLAN.md`): pull in pages
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
- results.push(await this.pushItem(registration, item, currentUser));
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
- reauthenticate: () => undefined,
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()`. **Call `CoreApiTokenService.deleteAllForTenant()`
470
- when you delete a tenant — nothing does it for you**: the core has no tenant model to notice the
471
- deletion, and a tenant token does not depend on any member, so it keeps authenticating until then.
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 a tenant's cached `isTenantActive` answer locally (no broadcast), and re-check the open
815
- * WebSocket connections scoped to it.
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
 
@@ -1,11 +1,23 @@
1
1
  import { BadRequestException, Injectable, Logger, NotFoundException, Optional } from '@nestjs/common';
2
- import { InjectModel } from '../../common/persistence/mongo/mongoose-compat.js';
3
- import type { Model } from 'mongoose';
2
+ import { InjectConnection, InjectModel } from '../../common/persistence/mongo/mongoose-compat.js';
3
+ import type { Connection, Model } from 'mongoose';
4
4
 
5
5
  import { looksLikeGlobalOnlyRole, looksLikeSystemRole, SYSTEM_ROLE_PREFIX } from '../../common/enums/role.enum.js';
6
6
  import type { DataStore } from '../../common/persistence/data-store.interface.js';
7
+ import { getModelDescriptors } from '../../common/persistence/model-metadata.registry.js';
8
+ import { defaultCollectionName } from '../../common/persistence/mongo/collection-name.js';
9
+ import { tableName } from '../../common/persistence/sql/sql-schema.shared.js';
10
+ import { getModelStore, getStoreForModel } from '../../common/persistence/persistence-runtime.registry.js';
7
11
  import { ConfigService } from '../../common/services/config.service.js';
8
12
  import { RequestContext } from '../../common/services/request-context.service.js';
13
+ import {
14
+ AI_BUDGET_LIMIT_MODEL,
15
+ AI_CONNECTION_MODEL,
16
+ AI_CONNECTION_PREFERENCE_MODEL,
17
+ AI_TOOL_GRANT_MODEL,
18
+ AI_TOOL_POLICY_MODEL,
19
+ } from '../ai/core-ai.constants.js';
20
+ import { API_TOKEN_MODEL_TOKEN } from '../api-token/core-api-token.constants.js';
9
21
  import { CoreTenantMemberModel } from './core-tenant-member.model.js';
10
22
  import { routedMemberStore } from './core-tenant-member.store.js';
11
23
  import { DEFAULT_ROLE_HIERARCHY, TENANT_MEMBER_MODEL_TOKEN, TenantMemberStatus } from './core-tenant.enums.js';
@@ -34,6 +46,14 @@ import { CoreTenantGuard } from './core-tenant.guard.js';
34
46
  export class CoreTenantService {
35
47
  protected readonly logger = new Logger(CoreTenantService.name);
36
48
 
49
+ /**
50
+ * The MongoDB connection, `null` without MongoDB. `purgeTenantData()` reads the models registered only through
51
+ * `MongooseModule.forFeature()` from it. Injected as a property, so a subclass constructor needs no change
52
+ */
53
+ @Optional()
54
+ @InjectConnection()
55
+ protected readonly mongoConnection?: Connection | null;
56
+
37
57
  constructor(
38
58
  @InjectModel(TENANT_MEMBER_MODEL_TOKEN) protected readonly memberModel: Model<CoreTenantMemberModel>,
39
59
  @Optional() protected readonly tenantGuard?: CoreTenantGuard,
@@ -298,6 +318,271 @@ export class CoreTenantService {
298
318
  }
299
319
  });
300
320
  }
321
+
322
+ /**
323
+ * Delete every membership of a tenant, whatever its status, and clear what the tenant guard cached for it.
324
+ *
325
+ * Meant for removing a tenant: unlike {@link removeMember} it deletes the rows instead of suspending them and skips
326
+ * the last-owner check, because the tenant itself goes. Every affected user is invalidated in the guard (on every
327
+ * replica with Redis), the tenant last. Answers how many memberships went.
328
+ */
329
+ async deleteAllMembers(tenantId: string): Promise<number> {
330
+ requireTenantId(tenantId);
331
+ return RequestContext.runWithBypassTenantGuard(async () => {
332
+ const where = { tenant: tenantId };
333
+ const store = this.memberDataStore();
334
+ const members = store ? await store.find(where) : await this.memberModel.find(where).select('user').lean().exec();
335
+ const deleted = store ? await store.deleteMany(where) : (await this.memberModel.deleteMany(where)).deletedCount;
336
+ for (const userId of new Set(members.map((member) => String(member.user)))) {
337
+ this.tenantGuard?.invalidateUser(userId);
338
+ }
339
+ this.tenantGuard?.invalidateTenant(tenantId);
340
+ return deleted;
341
+ });
342
+ }
343
+
344
+ /**
345
+ * Delete everything the framework knows to belong to a tenant. Answers how many records went per model (models with
346
+ * none are left out); with `dryRun` it only counts.
347
+ *
348
+ * First every way in ({@link endTenantAccess}): the memberships ({@link deleteAllMembers}) and the tenant's API
349
+ * tokens (`tenant`), which need no member — so nobody writes into the tenant while its data goes. Then, through
350
+ * {@link purgeTenantRecords}:
351
+ *
352
+ * - every model registered with `CorePersistenceModule.forFeature()` that has a `tenantId` path, on whatever
353
+ * database it is routed to — the project's own models included;
354
+ * - models with a `tenantId` path registered only through `MongooseModule.forFeature()` (MongoDB);
355
+ * - the core models that reference a tenant without a `tenantId` path: the AI budget limits, connection preferences,
356
+ * tool grants and tool policies (`scope: 'tenant'`, `refId`);
357
+ * - AI connections: one only this tenant could use is deleted, from a shared one the tenant is removed. Never the
358
+ * other way round — an empty `tenantIds` list means "every tenant".
359
+ *
360
+ * Left out: models in `multiTenancy.excludeSchemas` (a `tenantId` there is no ownership), every model named in
361
+ * `exclude`, users — the `users` table or collection, also where users carry a `tenantId`: a project with per-tenant
362
+ * users deletes them through its user service, which also ends their Better-Auth sessions and accounts — and files:
363
+ * their bytes can only be assigned to a tenant by the project, which deletes them through `CoreFileService`. The
364
+ * memberships cannot be excluded. A synchronized model keeps a tombstone
365
+ * per deleted record until the tombstone purge removes it.
366
+ */
367
+ async purgeTenantData(tenantId: string, options: TenantPurgeOptions = {}): Promise<Record<string, number>> {
368
+ requireTenantId(tenantId);
369
+ const dryRun = options.dryRun === true;
370
+ const exclude = new Set([
371
+ ...(ConfigService.configFastButReadOnly?.multiTenancy?.excludeSchemas ?? []),
372
+ ...(options.exclude ?? []),
373
+ ]);
374
+ exclude.delete(this.memberModel.modelName);
375
+
376
+ // Access ends before the data goes
377
+ const access = await this.endTenantAccess(tenantId, { dryRun, exclude });
378
+ const records = await this.purgeTenantRecords(tenantId, { dryRun, exclude });
379
+
380
+ return Object.fromEntries(Object.entries({ ...access, ...records }).filter(([, count]) => count > 0));
381
+ }
382
+
383
+ /**
384
+ * End every way into a tenant (or with `dryRun` count it): the memberships, and the references that grant access on
385
+ * their own — the tenant's API tokens, which need no member. Runs before {@link purgeTenantRecords}, or a member or an
386
+ * integration with a tenant token writes into the tenant while its data goes
387
+ */
388
+ protected async endTenantAccess(
389
+ tenantId: string,
390
+ { dryRun, exclude }: { dryRun: boolean; exclude: ReadonlySet<string> },
391
+ ): Promise<Record<string, number>> {
392
+ const report: Record<string, number> = {
393
+ [this.memberModel.modelName]: dryRun ? await this.countMembers(tenantId) : await this.deleteAllMembers(tenantId),
394
+ };
395
+ await RequestContext.runWithBypassTenantGuard(() =>
396
+ this.purgeReferences(tenantId, { access: true, dryRun, exclude }, report),
397
+ );
398
+ return report;
399
+ }
400
+
401
+ /**
402
+ * Delete (or with `dryRun` count) a tenant's records in every model except what {@link endTenantAccess} handles. The
403
+ * place to add a project's own references: override it, call `super`, add the counts. Runs after the access is gone;
404
+ * `exclude` already holds `multiTenancy.excludeSchemas`
405
+ */
406
+ protected async purgeTenantRecords(
407
+ tenantId: string,
408
+ { dryRun, exclude }: { dryRun: boolean; exclude: ReadonlySet<string> },
409
+ ): Promise<Record<string, number>> {
410
+ const report: Record<string, number> = {};
411
+ await RequestContext.runWithBypassTenantGuard(async () => {
412
+ const handled = new Set<string>([
413
+ this.memberModel.modelName,
414
+ AI_CONNECTION_MODEL,
415
+ ...TENANT_REFERENCES.map((reference) => reference.model),
416
+ ]);
417
+
418
+ // Every routed model with a tenantId path
419
+ for (const [name, descriptor] of getModelDescriptors()) {
420
+ if (tableName(descriptor) === USERS_COLLECTION) {
421
+ handled.add(name);
422
+ continue;
423
+ }
424
+ const store =
425
+ descriptor.tenantScoped &&
426
+ !handled.has(name) &&
427
+ !exclude.has(name) &&
428
+ getModelStore(this.mongoConnection, name);
429
+ if (store) {
430
+ handled.add(name);
431
+ const where = { tenantId };
432
+ report[name] = dryRun ? await store.count(where) : await this.deleteInBatches(store, where);
433
+ }
434
+ }
435
+
436
+ // MongoDB models no store serves (registered only through MongooseModule.forFeature())
437
+ for (const [name, model] of Object.entries(this.mongoConnection?.models ?? {})) {
438
+ if (
439
+ !handled.has(name) &&
440
+ !exclude.has(name) &&
441
+ model.schema?.path('tenantId') &&
442
+ (model.schema.get('collection') ?? defaultCollectionName(name)) !== USERS_COLLECTION
443
+ ) {
444
+ const where = { tenantId };
445
+ report[name] = dryRun ? await model.countDocuments(where) : (await model.deleteMany(where)).deletedCount;
446
+ }
447
+ }
448
+
449
+ // Core models that name the tenant in another field
450
+ await this.purgeReferences(tenantId, { access: false, dryRun, exclude }, report);
451
+
452
+ if (!exclude.has(AI_CONNECTION_MODEL)) {
453
+ report[AI_CONNECTION_MODEL] = await this.purgeTenantConnections(tenantId, dryRun);
454
+ }
455
+ });
456
+ return report;
457
+ }
458
+
459
+ /** The core references of {@link TENANT_REFERENCES} that do (`access`) or do not grant access, into `report` */
460
+ private async purgeReferences(
461
+ tenantId: string,
462
+ { access, dryRun, exclude }: { access: boolean; dryRun: boolean; exclude: ReadonlySet<string> },
463
+ report: Record<string, number>,
464
+ ): Promise<void> {
465
+ for (const reference of TENANT_REFERENCES) {
466
+ const store =
467
+ !!reference.access === access &&
468
+ !exclude.has(reference.model) &&
469
+ getModelStore(this.mongoConnection, reference.model);
470
+ if (store) {
471
+ const where = reference.where(tenantId);
472
+ report[reference.model] = dryRun ? await store.count(where) : await this.deleteInBatches(store, where);
473
+ }
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Take a tenant out of the AI connections: delete a connection only this tenant could use, remove the tenant from
479
+ * the list of a shared one. Never empty a list — an empty `tenantIds` means "every tenant", so that would hand the
480
+ * connection, API key included, to all of them. Answers how many connections were touched
481
+ */
482
+ protected async purgeTenantConnections(tenantId: string, dryRun: boolean): Promise<number> {
483
+ const store = getModelStore(this.mongoConnection, AI_CONNECTION_MODEL);
484
+ if (!store) {
485
+ return 0;
486
+ }
487
+ const connections = await store.find({ tenantIds: { $all: [tenantId] } });
488
+ if (dryRun) {
489
+ return connections.length;
490
+ }
491
+ for (const connection of connections) {
492
+ const remaining = (connection.tenantIds ?? []).filter((id: string) => id !== tenantId);
493
+ if (remaining.length === 0) {
494
+ await store.deleteById(connection.id);
495
+ } else {
496
+ await store.updateById(connection.id, { tenantIds: remaining });
497
+ }
498
+ }
499
+ return connections.length;
500
+ }
501
+
502
+ /**
503
+ * Delete what `where` matches in batches of {@link TENANT_PURGE_BATCH} ids, as the tombstone purge does. One statement
504
+ * over all of a tenant's rows of a synchronized SQL model holds the model's change lock until it commits, and every
505
+ * other tenant's writes to that model wait for it. Answers how many records went
506
+ */
507
+ protected async deleteInBatches(store: DataStore<any>, where: Record<string, unknown>): Promise<number> {
508
+ let deleted = 0;
509
+ let after: string | undefined;
510
+ for (;;) {
511
+ const batch = await store.find(
512
+ { ...where, ...(after ? { id: { $gt: after } } : {}) },
513
+ { limit: TENANT_PURGE_BATCH, select: ['id'], sort: { id: 1 } },
514
+ );
515
+ if (!batch.length) {
516
+ return deleted;
517
+ }
518
+ deleted += await store.deleteMany({ ...where, id: { $in: batch.map((record) => record.id) } });
519
+ if (batch.length < TENANT_PURGE_BATCH) {
520
+ return deleted;
521
+ }
522
+ after = batch[batch.length - 1].id;
523
+ }
524
+ }
525
+
526
+ /**
527
+ * The store serving the membership model on either database, or `undefined` for a model registered only through
528
+ * `MongooseModule.forFeature()` — then the Mongoose model is used
529
+ */
530
+ protected memberDataStore(): DataStore<any> | undefined {
531
+ return getStoreForModel(this.memberModel) ?? getModelStore(this.mongoConnection, this.memberModel.modelName);
532
+ }
533
+
534
+ /** How many memberships a tenant has, in any status */
535
+ protected countMembers(tenantId: string): Promise<number> {
536
+ const where = { tenant: tenantId };
537
+ const store = this.memberDataStore();
538
+ return RequestContext.runWithBypassTenantGuard(() =>
539
+ store ? store.count(where) : this.memberModel.countDocuments(where).exec(),
540
+ );
541
+ }
542
+ }
543
+
544
+ /** Options of {@link CoreTenantService.purgeTenantData} */
545
+ export interface TenantPurgeOptions {
546
+ /** Only count what would be deleted */
547
+ dryRun?: boolean;
548
+ /** Model names to leave alone, in addition to `multiTenancy.excludeSchemas`. The memberships always go */
549
+ exclude?: string[];
550
+ }
551
+
552
+ /** Records `purgeTenantData()` deletes per statement */
553
+ const TENANT_PURGE_BATCH = 1000;
554
+
555
+ /**
556
+ * The table / collection the user model shares with Better-Auth. `purgeTenantData()` never deletes from it, also where
557
+ * users carry a `tenantId`: a user leaves through the user service, which also ends the Better-Auth sessions and accounts
558
+ */
559
+ const USERS_COLLECTION = 'users';
560
+
561
+ /**
562
+ * Core models that reference a tenant without a `tenantId` path, and how they name it. `access`: the record lets
563
+ * somebody into the tenant on its own, so it goes together with the memberships, before the data
564
+ */
565
+ const TENANT_REFERENCES: ReadonlyArray<{
566
+ access?: boolean;
567
+ model: string;
568
+ where: (tenantId: string) => Record<string, unknown>;
569
+ }> = [
570
+ // A tenant token needs no membership
571
+ { access: true, model: API_TOKEN_MODEL_TOKEN, where: (tenantId) => ({ tenant: tenantId }) },
572
+ { model: AI_BUDGET_LIMIT_MODEL, where: (tenantId) => ({ refId: tenantId, scope: 'tenant' }) },
573
+ { model: AI_CONNECTION_PREFERENCE_MODEL, where: (tenantId) => ({ refId: tenantId, scope: 'tenant' }) },
574
+ { model: AI_TOOL_GRANT_MODEL, where: (tenantId) => ({ refId: tenantId, scope: 'tenant' }) },
575
+ { model: AI_TOOL_POLICY_MODEL, where: (tenantId) => ({ refId: tenantId, scope: 'tenant' }) },
576
+ ];
577
+
578
+ /**
579
+ * Refuse a tenant id a delete must not run with. `deleteMany({ tenantId: undefined })` matches every document on
580
+ * MongoDB, and an operator object (`{ $ne: null }`) would match every tenant
581
+ */
582
+ function requireTenantId(tenantId: unknown): asserts tenantId is string {
583
+ if (typeof tenantId !== 'string' || !tenantId.trim()) {
584
+ throw new BadRequestException('tenantId must be a non-empty string');
585
+ }
301
586
  }
302
587
 
303
588
  /**