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:
Félix Malfait
2026-06-18 21:16:57 +02:00
committed by GitHub
parent 5053c63f02
commit 7b5ee8a7bc
4 changed files with 174 additions and 7 deletions
@@ -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,
@@ -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 () => {
@@ -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[] {
@@ -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;
};