feat(server): report enterprise instance metadata on license validation (#21793)
## 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)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21793?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
+90
-4
@@ -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<string, string>; 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<string, jest.Mock>) => 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 () => {
|
||||
|
||||
+63
-2
@@ -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<AppTokenEntity>,
|
||||
@InjectRepository(UserWorkspaceEntity)
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
@InjectRepository(UserEntity)
|
||||
private readonly userRepository: Repository<UserEntity>,
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
private readonly workspaceRepository: Repository<WorkspaceEntity>,
|
||||
) {}
|
||||
|
||||
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<EnterpriseInstanceMetadata> {
|
||||
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<number>,
|
||||
): Promise<number | null> {
|
||||
try {
|
||||
return await countFn();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async getAdminContactEmail(): Promise<string | null> {
|
||||
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[] {
|
||||
|
||||
+13
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user