@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
|
@@ -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
|
/**
|
package/src/test/test.helper.ts
CHANGED
|
@@ -7,6 +7,7 @@ import * as util from 'util';
|
|
|
7
7
|
import ws from 'ws';
|
|
8
8
|
|
|
9
9
|
import { getStringIds } from '../core/common/helpers/db.helper.js';
|
|
10
|
+
import { ConfigService } from '../core/common/services/config.service.js';
|
|
10
11
|
import { resolveBetterAuthSessionCookieName } from '../core/modules/better-auth/better-auth-cookie-prefix.helper.js';
|
|
11
12
|
import { isObjectIdLike } from '../core/common/helpers/id.helper.js';
|
|
12
13
|
|
|
@@ -239,6 +240,12 @@ export interface TestMcpOptions {
|
|
|
239
240
|
* Options for `testHelper.mcpSession()`
|
|
240
241
|
*/
|
|
241
242
|
export interface TestMcpSessionOptions extends Omit<TestMcpOptions, 'sessionId' | 'statusCode'> {
|
|
243
|
+
/**
|
|
244
|
+
* Whether the server runs without sessions; then no session id is expected or sent, and
|
|
245
|
+
* `close()` does nothing. Defaults to `ai.mcp.stateless` of the running configuration.
|
|
246
|
+
*/
|
|
247
|
+
stateless?: boolean;
|
|
248
|
+
|
|
242
249
|
/**
|
|
243
250
|
* `clientInfo` sent with `initialize`. Default: `{ name: 'nest-server-test-helper', version: '1.0.0' }`
|
|
244
251
|
*/
|
|
@@ -342,7 +349,7 @@ export interface TestMcpSession {
|
|
|
342
349
|
): Promise<null | TestMcpMessage>;
|
|
343
350
|
|
|
344
351
|
/**
|
|
345
|
-
* `mcp-session-id` the server assigned
|
|
352
|
+
* `mcp-session-id` the server assigned; `''` on a stateless server (`ai.mcp.stateless`)
|
|
346
353
|
*/
|
|
347
354
|
sessionId: string;
|
|
348
355
|
}
|
|
@@ -697,8 +704,12 @@ export class TestHelper {
|
|
|
697
704
|
base,
|
|
698
705
|
);
|
|
699
706
|
expect(init.message?.error).toBeUndefined();
|
|
700
|
-
|
|
701
|
-
const
|
|
707
|
+
// A stateless server (`ai.mcp.stateless`) issues no session id; every request stands alone there.
|
|
708
|
+
const stateless = options.stateless ?? ConfigService.get<{ stateless?: boolean }>('ai.mcp')?.stateless === true;
|
|
709
|
+
if (!stateless) {
|
|
710
|
+
expect(init.sessionId).toBeTruthy();
|
|
711
|
+
}
|
|
712
|
+
const sessionId: string = stateless ? '' : (init.sessionId ?? '');
|
|
702
713
|
await this.mcp({ method: 'notifications/initialized' }, { ...base, sessionId });
|
|
703
714
|
|
|
704
715
|
let nextId = 2;
|
|
@@ -718,6 +729,9 @@ export class TestHelper {
|
|
|
718
729
|
return { ...result, json: TestHelper.parseJsonOrUndefined(result.content?.[0]?.text) };
|
|
719
730
|
},
|
|
720
731
|
close: async () => {
|
|
732
|
+
if (!sessionId) {
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
721
735
|
await this.rest(path, {
|
|
722
736
|
cookies: base.cookies,
|
|
723
737
|
headers: { ...base.headers, 'mcp-session-id': sessionId },
|