From 7b5ee8a7bc626f927ba08203916c500b7d4c74fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Thu, 18 Jun 2026 21:16:57 +0200 Subject: [PATCH] feat(server): report enterprise instance metadata on license validation (#21793) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Enriches the **enterprise-only** license-validation channel (`/validate`, `/seats`) with best-effort instance metadata so the licensing backend can later reconcile seats and surface signs of license abuse (e.g. one subscription on many `serverId`s, a `serverId` on many URLs, dev-mode-in-prod). Reported alongside the existing `enterpriseKey` (and `seatCount` on `/seats`), under a new `instanceMetadata` object: | Field | Purpose | |---|---| | `serverId`, `serverUrl` | instance identity — sharing / clone signals | | `workspaceCount`, `activeUserWorkspaceCount`, `distinctUserCount` | seat reconciliation / overage | | `appVersion`, `nodeEnv`, `telemetryEnabled` | fleet/support; dev-mode-in-prod signal | | `adminContactEmail` | **single** administrative contact (oldest active user) for license administration — explicitly *not* an abuse signal | | `sentAt` | timestamp | No CRM data, record contents, or member PII beyond the one admin contact are sent. ## Why it's safe for existing instances - **Enterprise-only.** Gathering runs only after the `ENTERPRISE_KEY` checks, so free/community instances make no extra queries and send nothing — unchanged behavior. - **Never blocks a refresh.** Each lookup is isolated (`safeCount` / try-catch); any failure degrades to `null` and the license refresh / seat report proceeds. - **Purely additive.** `enterpriseKey` and `seatCount` are preserved; the `/validate` and `/seats` handlers ignore unknown fields, so this can ship ahead of any backend consumer. - **No schema or token-verification changes** → no migration, existing validity tokens keep validating. ## Verification - `nx test twenty-server` — full unit suite green (5829 passed), including the updated `enterprise-plan.service.spec` with a new metadata-payload test - `nx typecheck twenty-server` — pass - `oxlint` + `oxfmt --check` on changed files — clean ## Deliberately out of scope (follow-ups) - **Server-side correlation/detection** and **short-TTL + instance-bound validity tokens** live on the signing/billing side (`twenty-website`) and need a coordinated rollout (enforcing token binding now would break already-issued tokens). - **`adminContactEmail`** is PII on a contractual enterprise channel — the enterprise terms should disclose it before rollout. - The dev-key / build-provenance hardening discussed separately is **not** part of this PR. Opening as **draft** for review of the field set and the cross-repo rollout plan before wiring a consumer. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_0114DV9tctTjVo8eggBGgtKc --- _Generated by [Claude Code](https://claude.ai/code/session_0114DV9tctTjVo8eggBGgtKc)_ Review in cubic --------- Co-authored-by: Charles Bochet --- .../enterprise/enterprise.module.ts | 9 +- .../__tests__/enterprise-plan.service.spec.ts | 94 ++++++++++++++++++- .../services/enterprise-plan.service.ts | 65 ++++++++++++- .../types/enterprise-key-payload.type.ts | 13 +++ 4 files changed, 174 insertions(+), 7 deletions(-) diff --git a/packages/twenty-server/src/engine/core-modules/enterprise/enterprise.module.ts b/packages/twenty-server/src/engine/core-modules/enterprise/enterprise.module.ts index c08620f49b..feb6e44091 100644 --- a/packages/twenty-server/src/engine/core-modules/enterprise/enterprise.module.ts +++ b/packages/twenty-server/src/engine/core-modules/enterprise/enterprise.module.ts @@ -9,11 +9,18 @@ import { EnterpriseResolver } from 'src/engine/core-modules/enterprise/enterpris import { EnterprisePlanService } from 'src/engine/core-modules/enterprise/services/enterprise-plan.service'; import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; +import { UserEntity } from 'src/engine/core-modules/user/user.entity'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; @Module({ imports: [ TwentyConfigModule, - TypeOrmModule.forFeature([UserWorkspaceEntity, AppTokenEntity]), + TypeOrmModule.forFeature([ + UserWorkspaceEntity, + AppTokenEntity, + UserEntity, + WorkspaceEntity, + ]), ], providers: [ EnterprisePlanService, diff --git a/packages/twenty-server/src/engine/core-modules/enterprise/services/__tests__/enterprise-plan.service.spec.ts b/packages/twenty-server/src/engine/core-modules/enterprise/services/__tests__/enterprise-plan.service.spec.ts index 9c8e785bc1..309ad3ca35 100644 --- a/packages/twenty-server/src/engine/core-modules/enterprise/services/__tests__/enterprise-plan.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/enterprise/services/__tests__/enterprise-plan.service.spec.ts @@ -11,6 +11,9 @@ import { ConfigVariableExceptionCode, } from 'src/engine/core-modules/twenty-config/twenty-config.exception'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; +import { UserEntity } from 'src/engine/core-modules/user/user.entity'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; const mockCryptoVerify = jest.fn(); @@ -61,6 +64,10 @@ describe('EnterprisePlanService', () => { const appTokenFindOneMock = jest.fn(); const transactionMock = jest.fn(); const fetchMock = jest.fn(); + const workspaceCountMock = jest.fn(); + const userCountMock = jest.fn(); + const userWorkspaceCountMock = jest.fn(); + const userFindOneMock = jest.fn(); let originalFetch: typeof global.fetch; @@ -109,6 +116,10 @@ describe('EnterprisePlanService', () => { }); appTokenFindOneMock.mockResolvedValue(null); + workspaceCountMock.mockResolvedValue(0); + userCountMock.mockResolvedValue(0); + userWorkspaceCountMock.mockResolvedValue(0); + userFindOneMock.mockResolvedValue(null); const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -130,6 +141,18 @@ describe('EnterprisePlanService', () => { }, }, }, + { + provide: getRepositoryToken(UserWorkspaceEntity), + useValue: { count: userWorkspaceCountMock }, + }, + { + provide: getRepositoryToken(UserEntity), + useValue: { count: userCountMock, findOne: userFindOneMock }, + }, + { + provide: getRepositoryToken(WorkspaceEntity), + useValue: { count: workspaceCountMock }, + }, ], }).compile(); @@ -483,11 +506,74 @@ describe('EnterprisePlanService', () => { const result = await service.refreshValidityToken(); expect(result).toBe(true); - expect(fetchMock).toHaveBeenCalledWith(`${MOCK_API_URL}/validate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ enterpriseKey: fakeKey }), + + const [validateUrl, validateOptions] = fetchMock.mock.calls[0] as [ + string, + { method: string; headers: Record; body: string }, + ]; + + expect(validateUrl).toBe(`${MOCK_API_URL}/validate`); + expect(validateOptions.method).toBe('POST'); + + const validateBody = JSON.parse(validateOptions.body); + + expect(validateBody.enterpriseKey).toBe(fakeKey); + expect(validateBody.instanceMetadata).toBeDefined(); + }); + + it('should include instance metadata in the validate payload', async () => { + const fakeKey = createFakeJwt(MOCK_KEY_PAYLOAD); + + configGetMock.mockImplementation((key: string) => { + if (key === 'ENTERPRISE_KEY') return fakeKey; + if (key === 'ENTERPRISE_API_URL') return MOCK_API_URL; + if (key === 'SERVER_ID') return 'server-abc'; + if (key === 'SERVER_URL') return 'https://crm.example.com'; + if (key === 'APP_VERSION') return '1.2.3'; + if (key === 'NODE_ENV') return NodeEnvironment.PRODUCTION; + if (key === 'TELEMETRY_ENABLED') return true; + + return undefined; }); + mockCryptoVerify.mockReturnValue(true); + workspaceCountMock.mockResolvedValue(2); + userWorkspaceCountMock.mockResolvedValue(7); + userCountMock.mockResolvedValue(5); + userFindOneMock.mockResolvedValue({ email: 'admin@example.com' }); + + fetchMock.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + validityToken: createFakeJwt(MOCK_VALIDITY_PAYLOAD), + }), + }); + transactionMock.mockImplementation( + async (callback: (manager: Record) => void) => { + await callback({ update: jest.fn(), save: jest.fn() }); + }, + ); + appTokenFindOneMock.mockResolvedValue({ + value: createFakeJwt(MOCK_VALIDITY_PAYLOAD), + }); + + await service.refreshValidityToken(); + + const [, options] = fetchMock.mock.calls[0] as [string, { body: string }]; + const body = JSON.parse(options.body); + + expect(body.instanceMetadata).toMatchObject({ + serverId: 'server-abc', + serverUrl: 'https://crm.example.com', + appVersion: '1.2.3', + nodeEnv: NodeEnvironment.PRODUCTION, + telemetryEnabled: true, + workspaceCount: 2, + activeUserWorkspaceCount: 7, + distinctUserCount: 5, + adminContactEmail: 'admin@example.com', + }); + expect(typeof body.instanceMetadata.sentAt).toBe('string'); }); it('should return false when API returns non-OK response', async () => { diff --git a/packages/twenty-server/src/engine/core-modules/enterprise/services/enterprise-plan.service.ts b/packages/twenty-server/src/engine/core-modules/enterprise/services/enterprise-plan.service.ts index 997c4182b1..56f89f4c6c 100644 --- a/packages/twenty-server/src/engine/core-modules/enterprise/services/enterprise-plan.service.ts +++ b/packages/twenty-server/src/engine/core-modules/enterprise/services/enterprise-plan.service.ts @@ -17,6 +17,7 @@ import { ENTERPRISE_JWT_PUBLIC_KEY, } from 'src/engine/core-modules/enterprise/constants/enterprise-public-key.constant'; import { + type EnterpriseInstanceMetadata, type EnterpriseKeyPayload, type EnterpriseLicenseInfo, type EnterpriseValidityPayload, @@ -27,6 +28,9 @@ import { ConfigVariableExceptionCode, } from 'src/engine/core-modules/twenty-config/twenty-config.exception'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; +import { UserEntity } from 'src/engine/core-modules/user/user.entity'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; @Injectable() export class EnterprisePlanService implements OnModuleInit { @@ -38,6 +42,12 @@ export class EnterprisePlanService implements OnModuleInit { private readonly twentyConfigService: TwentyConfigService, @InjectRepository(AppTokenEntity) private readonly appTokenRepository: Repository, + @InjectRepository(UserWorkspaceEntity) + private readonly userWorkspaceRepository: Repository, + @InjectRepository(UserEntity) + private readonly userRepository: Repository, + @InjectRepository(WorkspaceEntity) + private readonly workspaceRepository: Repository, ) {} async onModuleInit() { @@ -215,10 +225,12 @@ export class EnterprisePlanService implements OnModuleInit { const validateUrl = `${apiUrl}/validate`; try { + const instanceMetadata = await this.gatherInstanceMetadata(); + const response = await fetch(validateUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ enterpriseKey }), + body: JSON.stringify({ enterpriseKey, instanceMetadata }), }); if (!response.ok) { @@ -269,10 +281,12 @@ export class EnterprisePlanService implements OnModuleInit { const seatsUrl = `${apiUrl}/seats`; try { + const instanceMetadata = await this.gatherInstanceMetadata(); + const response = await fetch(seatsUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ enterpriseKey, seatCount }), + body: JSON.stringify({ enterpriseKey, seatCount, instanceMetadata }), }); if (!response.ok) { @@ -441,6 +455,53 @@ export class EnterprisePlanService implements OnModuleInit { } } + // Best-effort only: must never throw and fail a license refresh. + private async gatherInstanceMetadata(): Promise { + return { + serverId: this.twentyConfigService.get('SERVER_ID') ?? null, + serverUrl: this.twentyConfigService.get('SERVER_URL') ?? null, + appVersion: this.twentyConfigService.get('APP_VERSION') ?? null, + nodeEnv: this.twentyConfigService.get('NODE_ENV') ?? null, + telemetryEnabled: + this.twentyConfigService.get('TELEMETRY_ENABLED') ?? null, + workspaceCount: await this.safeCount(() => + this.workspaceRepository.count(), + ), + activeUserWorkspaceCount: await this.safeCount(() => + this.userWorkspaceRepository.count({ where: { deletedAt: IsNull() } }), + ), + distinctUserCount: await this.safeCount(() => + this.userRepository.count({ where: { deletedAt: IsNull() } }), + ), + adminContactEmail: await this.getAdminContactEmail(), + sentAt: new Date().toISOString(), + }; + } + + private async safeCount( + countFn: () => Promise, + ): Promise { + try { + return await countFn(); + } catch { + return null; + } + } + + private async getAdminContactEmail(): Promise { + try { + const user = await this.userRepository.findOne({ + where: { deletedAt: IsNull() }, + order: { createdAt: 'ASC' }, + select: { email: true }, + }); + + return user?.email ?? null; + } catch { + return null; + } + } + // In development and Jest integration tests, try both keys so production keys // work locally private getPublicKeysToTry(): string[] { diff --git a/packages/twenty-server/src/engine/core-modules/enterprise/types/enterprise-key-payload.type.ts b/packages/twenty-server/src/engine/core-modules/enterprise/types/enterprise-key-payload.type.ts index 64a30d523f..f798aff24e 100644 --- a/packages/twenty-server/src/engine/core-modules/enterprise/types/enterprise-key-payload.type.ts +++ b/packages/twenty-server/src/engine/core-modules/enterprise/types/enterprise-key-payload.type.ts @@ -17,3 +17,16 @@ export type EnterpriseLicenseInfo = { expiresAt: Date | null; subscriptionId: string | null; }; + +export type EnterpriseInstanceMetadata = { + serverId: string | null; + serverUrl: string | null; + appVersion: string | null; + nodeEnv: string | null; + telemetryEnabled: boolean | null; + workspaceCount: number | null; + activeUserWorkspaceCount: number | null; + distinctUserCount: number | null; + adminContactEmail: string | null; + sentAt: string; +};