(Billing for self hosts) Tie enterprise key to server (#22464)

# Enterprise key: bind to a server, free dev instances, self-serve
transfer, shorter license

## Summary

Enterprise keys were being reused across multiple instances (e.g. one
prod + one dev, or several environments), which broke seat accounting
and made licensing ambiguous. This PR ties each enterprise key to a
**single server**, while giving customers a legitimate, self-serve way
to run a **free development instance** and to **move their key** when
they replace a server.

## Product behavior

### 1. Enterprise key is bound to one server
- The first server to validate an enterprise key **claims** it
(claim-on-first-use). From then on, that key is bound to that one server
(until unbound - see 3.).
- Any other instance that presents the **same key from a different
server is hard-rejected**: it does not receive a license, so enterprise
features stay off there.
- Each instance has a stable server identifier. If one isn't set, the
instance generates and persists one automatically on first validation
(in keyValuePair table), so existing customers generally don't need to
do anything (unless they have disabled config variables in db then they
should add it to .env).

### 2. Free development instance
- Every enterprise subscription gets **one free, non-billable
development instance** in addition to its production instance.
- An instance registers as development by declaring its instance type as
`development` (done by default when validating the enterprise key, then
can be toggled from UI or by updating value in keyValuePair table).
- The free dev slot is only granted while there is an **active
production instance** on the same subscription (so it's a perk for
paying customers, not a way to run for free).
- Only **one** dev instance can be active at a time per subscription,
and it is **not counted as a billable seat**.

### 3. Self-serve unbind / rebind (transfer)
- Admins can **release** the binding from the enterprise settings, which
frees the key so it can be **claimed by a new server**.
- This is the intended path when **sunsetting an instance and standing
up a new one** (migration, re-hosting, disaster recovery): release on
the old/dead box, then the new box claims it on its next validation.
- To prevent abuse, releases are **rate-limited (10 per rolling 30
days)**; hitting the limit shows a clear message.

### 4. Automatic release of dead servers
- If a bound server stops checking in for **14 days**, its binding is
considered stale and is **auto-released**, so a replacement can claim
the key without any manual step. This covers the case where the old
server is already gone and can't release itself.

### 5. Shorter license validity (30 → 7 days)
- The license (validity token) now expires after **7 days** instead of
30. The daily background refresh keeps healthy instances licensed
transparently.
- This limits the value of copying a license from one instance to
another, since a copied license now stops working within a week.

### 6. License issuance is rate-limited
- Issuing a new license is capped at **twice per 24h, independently for
production and for development**. This tolerates the normal daily
refresh (including small drift between runs) while blocking bursts of
license minting for cloned instances.
- Hitting this limit never revokes an existing, still-valid license —
the current one keeps working until it expires; the manual "refresh"
button just reports that the daily limit was reached.

## What changes for existing self-hosted customers

**If you run a single production instance with one enterprise key:**
nothing to do. On the next validation your instance reports its server
identifier, claims the binding, and keeps working.

**If you reuse one key across several instances (e.g. prod + dev, or
multiple environments):** only the **first** instance to validate keeps
its license. The others will **lose enterprise features**. To migrate:
- Keep your production instance as-is (it claims the binding).
- For a secondary/testing box, mark it as a **development instance**
(set the instance type to `development`) to use the free dev slot — no
extra cost.
- If you genuinely need multiple production instances, you'll need
**separate subscriptions/keys** for each.

**If you're replacing a server (decommissioning + rebuilding):**
- **Release** the binding from enterprise settings on the old instance,
then start the new one — it will claim the key automatically.
- If the old server is already gone, just wait for the **14-day
auto-release**, or contact support.

**Legacy instances that can't persist a server identifier
automatically:** set the server identifier explicitly in your
environment configuration (the instance logs a message telling you to do
so).

**Offline instances:** because licenses now last 7 days, an instance
that can't reach our licensing endpoint for more than a week will lose
enterprise features until it can check in again.

> A migration email will be sent to affected customers separately.

## Technical implementation (brief)

- Binding state lives in the **subscription's billing metadata** (bound
server id + last-seen timestamps for prod and dev, release timestamps,
and license-issuance timestamps). No new database is introduced on the
licensing side; the billing provider's subscription metadata is the
source of truth.
<img width="976" height="413" alt="metadata_3"
src="https://github.com/user-attachments/assets/ccc64822-e177-4223-a65a-4a4602aedf0e"
/>

- On each validation, a pure **binding resolver** takes the reported
server id + instance type + current metadata and returns `allowed` (with
the metadata to persist and whether the seat is billable) or `rejected`.
It handles claim-on-first-use, staleness/auto-release, the
dev-requires-active-prod rule, and the single-dev-slot rule.
- **Rate limits** (release + license issuance) use a shared
sliding-window helper stored as pruned timestamp lists in the same
metadata, so the metadata self-cleans and never grows unbounded. License
issuance uses **separate windows per instance type**.
- The self-hosted instance **generates and persists a server
identifier** if none is configured, and sends it (plus instance type) as
instance metadata on validation.
- A rejected binding returns a specific error code; the instance
**revokes its stored license** on that code. A license-issuance
rate-limit instead **throws a typed exception that surfaces to the
manual refresh** while leaving the existing license untouched; the daily
refresh job swallows it.
- License lifetime is a configurable duration (defaulted from 30 to **7
days**), clamped to the subscription's cancellation date when sooner.
This commit is contained in:
Marie
2026-07-06 18:07:03 +02:00
committed by GitHub
parent ed2b2f8911
commit 8a4bcd1445
54 changed files with 2028 additions and 78 deletions
@@ -5,6 +5,7 @@ import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/
import { ClientConfigService } from 'src/engine/core-modules/client-config/services/client-config.service';
import { ModelFamily } from 'src/engine/metadata-modules/ai/ai-models/types/model-family.enum';
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/types/model-id.type';
import { ENTERPRISE_INSTANCE_TYPE } from 'twenty-shared/constants';
import { ClientConfigController } from './client-config.controller';
@@ -108,6 +109,7 @@ describe('ClientConfigController', () => {
isCloudflareIntegrationEnabled: false,
isClickHouseConfigured: false,
isWorkspaceSchemaDDLLocked: false,
enterpriseInstanceType: ENTERPRISE_INSTANCE_TYPE.PRODUCTION,
};
jest
@@ -345,6 +345,9 @@ export class ClientConfig {
@Field(() => Boolean)
isWorkspaceSchemaDDLLocked: boolean;
@Field(() => String)
enterpriseInstanceType: string;
@Field(() => ClientConfigMaintenanceMode, { nullable: true })
maintenance?: ClientConfigMaintenanceMode;
}
@@ -3,13 +3,14 @@ import { Test, type TestingModule } from '@nestjs/testing';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service';
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces';
import { ClientConfigService } from 'src/engine/core-modules/client-config/services/client-config.service';
import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service';
import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service';
import { ENTERPRISE_INSTANCE_TYPE } from 'twenty-shared/constants';
describe('ClientConfigService', () => {
let service: ClientConfigService;
@@ -190,6 +191,7 @@ describe('ClientConfigService', () => {
calendarBookingPageId: 'team/twenty/talk-to-us',
isCloudflareIntegrationEnabled: false,
isClickHouseConfigured: false,
enterpriseInstanceType: ENTERPRISE_INSTANCE_TYPE.PRODUCTION,
});
});
@@ -19,6 +19,7 @@ import { toDisplayCredits } from 'src/engine/core-modules/usage/utils/to-display
import {
AUTO_SELECT_FAST_MODEL_ID,
AUTO_SELECT_SMART_MODEL_ID,
ENTERPRISE_INSTANCE_TYPE,
} from 'twenty-shared/constants';
import { MODEL_FAMILY_LABELS } from 'src/engine/metadata-modules/ai/ai-models/constants/model-family-labels.const';
import { getNativeModelCapabilities } from 'src/engine/metadata-modules/ai/ai-models/utils/get-native-model-capabilities.util';
@@ -280,6 +281,9 @@ export class ClientConfigService {
isWorkspaceSchemaDDLLocked: this.twentyConfigService.get(
'WORKSPACE_SCHEMA_DDL_LOCKED',
),
enterpriseInstanceType:
this.twentyConfigService.get('ENTERPRISE_INSTANCE_TYPE') ??
ENTERPRISE_INSTANCE_TYPE.PRODUCTION,
};
const maintenanceMode =
@@ -34,14 +34,21 @@ export class EnterpriseKeyValidationCronJob {
'Starting enterprise validity token refresh and seat report...',
);
const refreshSuccess =
await this.enterprisePlanService.refreshValidityToken();
try {
const refreshSuccess =
await this.enterprisePlanService.refreshValidityToken();
if (refreshSuccess) {
this.logger.log('Enterprise validity token refreshed successfully');
} else {
if (refreshSuccess) {
this.logger.log('Enterprise validity token refreshed successfully');
} else {
this.logger.warn(
'Enterprise validity token refresh did not succeed. ' +
'Existing validity token will continue to work until expiration.',
);
}
} catch (error) {
this.logger.warn(
'Enterprise validity token refresh did not succeed. ' +
`Enterprise validity token refresh failed: ${error instanceof Error ? error.message : 'Unknown error'}. ` +
'Existing validity token will continue to work until expiration.',
);
}
@@ -8,7 +8,10 @@ import {
EnterpriseException,
EnterpriseExceptionCode,
} from 'src/engine/core-modules/enterprise/enterprise.exception';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
ForbiddenError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
@Catch(EnterpriseException)
export class EnterpriseExceptionFilter implements ExceptionFilter {
@@ -16,7 +19,14 @@ export class EnterpriseExceptionFilter implements ExceptionFilter {
switch (exception.code) {
case EnterpriseExceptionCode.INVALID_ENTERPRISE_KEY:
case EnterpriseExceptionCode.CONFIG_VARIABLES_IN_DB_DISABLED:
case EnterpriseExceptionCode.ENTERPRISE_MISSING_SERVER_ID:
throw new UserInputError(exception);
case EnterpriseExceptionCode.ENTERPRISE_KEY_BOUND_TO_ANOTHER_SERVER:
case EnterpriseExceptionCode.ENTERPRISE_DEV_REQUIRES_ACTIVE_PRODUCTION:
case EnterpriseExceptionCode.ENTERPRISE_DEV_SLOT_IN_USE:
case EnterpriseExceptionCode.ENTERPRISE_RELEASE_RATE_LIMITED:
case EnterpriseExceptionCode.ENTERPRISE_VALIDITY_TOKEN_RATE_LIMITED:
throw new ForbiddenError(exception);
default: {
assertUnreachable(exception.code);
}
@@ -9,6 +9,12 @@ import { CustomException } from 'src/utils/custom-exception';
export enum EnterpriseExceptionCode {
INVALID_ENTERPRISE_KEY = 'INVALID_ENTERPRISE_KEY',
CONFIG_VARIABLES_IN_DB_DISABLED = 'CONFIG_VARIABLES_IN_DB_DISABLED',
ENTERPRISE_KEY_BOUND_TO_ANOTHER_SERVER = 'ENTERPRISE_KEY_BOUND_TO_ANOTHER_SERVER',
ENTERPRISE_MISSING_SERVER_ID = 'ENTERPRISE_MISSING_SERVER_ID',
ENTERPRISE_DEV_REQUIRES_ACTIVE_PRODUCTION = 'ENTERPRISE_DEV_REQUIRES_ACTIVE_PRODUCTION',
ENTERPRISE_DEV_SLOT_IN_USE = 'ENTERPRISE_DEV_SLOT_IN_USE',
ENTERPRISE_RELEASE_RATE_LIMITED = 'ENTERPRISE_RELEASE_RATE_LIMITED',
ENTERPRISE_VALIDITY_TOKEN_RATE_LIMITED = 'ENTERPRISE_VALIDITY_TOKEN_RATE_LIMITED',
}
const getEnterpriseExceptionUserFriendlyMessage = (
@@ -19,6 +25,18 @@ const getEnterpriseExceptionUserFriendlyMessage = (
return msg`Invalid enterprise key.`;
case EnterpriseExceptionCode.CONFIG_VARIABLES_IN_DB_DISABLED:
return msg`IS_CONFIG_VARIABLES_IN_DB_ENABLED is false on your server. Please add ENTERPRISE_KEY to your .env file manually.`;
case EnterpriseExceptionCode.ENTERPRISE_KEY_BOUND_TO_ANOTHER_SERVER:
return msg`This enterprise key is already in use on another server instance. Release it from that server, or transfer it to this one.`;
case EnterpriseExceptionCode.ENTERPRISE_MISSING_SERVER_ID:
return msg`This instance did not report a server identifier. Set SERVER_ID on this instance, then try again.`;
case EnterpriseExceptionCode.ENTERPRISE_DEV_REQUIRES_ACTIVE_PRODUCTION:
return msg`A free development instance requires an active production instance on this enterprise subscription.`;
case EnterpriseExceptionCode.ENTERPRISE_DEV_SLOT_IN_USE:
return msg`The development instance slot for this enterprise key is already in use on another server.`;
case EnterpriseExceptionCode.ENTERPRISE_RELEASE_RATE_LIMITED:
return msg`You have reached the maximum number of server transfers allowed in the last 30 days for this enterprise key. Please try again later.`;
case EnterpriseExceptionCode.ENTERPRISE_VALIDITY_TOKEN_RATE_LIMITED:
return msg`You have reached the maximum number of license refreshes allowed today for this enterprise key. Please try again later.`;
default:
assertUnreachable(code);
}
@@ -5,10 +5,11 @@ import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import { isDefined } from 'twenty-shared/utils';
import { EnterpriseExceptionFilter } from 'src/engine/core-modules/enterprise/enterprise-exception.filter';
import { EnterpriseLicenseInfoDTO } from 'src/engine/core-modules/enterprise/dtos/enterprise-license-info.dto';
import { EnterpriseSubscriptionStatusDTO } from 'src/engine/core-modules/enterprise/dtos/enterprise-subscription-status.dto';
import { EnterpriseExceptionFilter } from 'src/engine/core-modules/enterprise/enterprise-exception.filter';
import {
EnterpriseException,
EnterpriseExceptionCode,
@@ -23,6 +24,15 @@ import { BillingDisabledGuard } from 'src/engine/guards/billing-disabled.guard';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
// Server-binding rejections that should surface as an activation failure with
// their own user-facing message (rather than being silently swallowed).
const SERVER_BINDING_REJECTION_CODES: EnterpriseExceptionCode[] = [
EnterpriseExceptionCode.ENTERPRISE_KEY_BOUND_TO_ANOTHER_SERVER,
EnterpriseExceptionCode.ENTERPRISE_MISSING_SERVER_ID,
EnterpriseExceptionCode.ENTERPRISE_DEV_REQUIRES_ACTIVE_PRODUCTION,
EnterpriseExceptionCode.ENTERPRISE_DEV_SLOT_IN_USE,
];
@Resolver()
@UsePipes(ResolverValidationPipe)
@UseFilters(EnterpriseExceptionFilter, PreventNestToAutoLogGraphqlErrorsFilter)
@@ -41,6 +51,26 @@ export class EnterpriseResolver {
return Math.max(1, count);
}
// Turn a server-binding rejection from the last refresh into a user-facing
// error, so activation and manual refresh surface the real reason instead of
// silently failing.
private throwIfServerBindingRejected(): void {
const rejectionCode =
this.enterprisePlanService.getLastRefreshRejectionCode();
if (
isDefined(rejectionCode) &&
SERVER_BINDING_REJECTION_CODES.includes(
rejectionCode as EnterpriseExceptionCode,
)
) {
throw new EnterpriseException(
`Enterprise key rejected: ${rejectionCode}`,
rejectionCode as EnterpriseExceptionCode,
);
}
}
@Query(() => String, { nullable: true })
@UseGuards(
WorkspaceAuthGuard,
@@ -91,7 +121,30 @@ export class EnterpriseResolver {
NoPermissionGuard,
)
async refreshEnterpriseValidityToken(): Promise<boolean> {
return this.enterprisePlanService.refreshValidityToken();
const refreshed = await this.enterprisePlanService.refreshValidityToken();
this.throwIfServerBindingRejected();
return refreshed;
}
@Mutation(() => EnterpriseLicenseInfoDTO)
@UseGuards(
WorkspaceAuthGuard,
BillingDisabledGuard,
AdminPanelGuard,
NoPermissionGuard,
)
async releaseEnterpriseServerBinding(): Promise<EnterpriseLicenseInfoDTO> {
await this.enterprisePlanService.releaseServerBinding();
await this.enterprisePlanService.refreshValidityToken();
const seatCount = await this.getActiveUserWorkspaceCount();
await this.enterprisePlanService.reportSeats(seatCount);
return this.enterprisePlanService.getLicenseInfo();
}
@Mutation(() => EnterpriseLicenseInfoDTO)
@@ -118,6 +171,8 @@ export class EnterpriseResolver {
await this.enterprisePlanService.refreshValidityToken();
this.throwIfServerBindingRejected();
const seatCount = await this.getActiveUserWorkspaceCount();
await this.enterprisePlanService.reportSeats(seatCount);
@@ -5,8 +5,11 @@ import { InjectRepository } from '@nestjs/typeorm';
import * as crypto from 'crypto';
import { isNonEmptyString } from '@sniptt/guards';
import { ENTERPRISE_INSTANCE_TYPE } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, Repository } from 'typeorm';
import { v4 } from 'uuid';
import {
AppTokenEntity,
@@ -16,6 +19,10 @@ import {
ENTERPRISE_JWT_DEV_PUBLIC_KEY,
ENTERPRISE_JWT_PUBLIC_KEY,
} from 'src/engine/core-modules/enterprise/constants/enterprise-public-key.constant';
import {
EnterpriseException,
EnterpriseExceptionCode,
} from 'src/engine/core-modules/enterprise/enterprise.exception';
import {
type EnterpriseInstanceMetadata,
type EnterpriseKeyPayload,
@@ -37,6 +44,13 @@ export class EnterprisePlanService implements OnModuleInit {
private readonly logger = new Logger(EnterprisePlanService.name);
private cachedValidityPayload: EnterpriseValidityPayload | null = null;
private cachedKeyPayload: EnterpriseKeyPayload | null = null;
private lastRefreshRejectionCode: string | null = null;
static readonly ENTERPRISE_KEY_BOUND_TO_ANOTHER_SERVER_CODE =
'ENTERPRISE_KEY_BOUND_TO_ANOTHER_SERVER';
static readonly ENTERPRISE_VALIDITY_TOKEN_RATE_LIMITED_CODE =
'ENTERPRISE_VALIDITY_TOKEN_RATE_LIMITED';
constructor(
private readonly twentyConfigService: TwentyConfigService,
@@ -202,7 +216,33 @@ export class EnterprisePlanService implements OnModuleInit {
}
}
getLastRefreshRejectionCode(): string | null {
return this.lastRefreshRejectionCode;
}
private async revokeStoredValidityToken(): Promise<void> {
this.cachedValidityPayload = null;
try {
await this.appTokenRepository.update(
{
type: AppTokenType.EnterpriseValidityToken,
userId: IsNull(),
workspaceId: IsNull(),
revokedAt: IsNull(),
},
{ revokedAt: new Date() },
);
} catch (error) {
this.logger.warn(
`Failed to revoke stored validity token: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
}
async refreshValidityToken(): Promise<boolean> {
this.lastRefreshRejectionCode = null;
const enterpriseKey = this.twentyConfigService.get('ENTERPRISE_KEY');
if (!enterpriseKey) {
@@ -240,6 +280,33 @@ export class EnterprisePlanService implements OnModuleInit {
`Enterprise refresh failed with status ${response.status}: ${errorData.error ?? 'Unknown error'}`,
);
if (
errorData.code ===
EnterprisePlanService.ENTERPRISE_VALIDITY_TOKEN_RATE_LIMITED_CODE
) {
// Rate limited: the existing token stays valid, surface the reason so
// callers (e.g. the manual refresh button) can tell the user.
throw new EnterpriseException(
'Validity token refresh rate limit exceeded',
EnterpriseExceptionCode.ENTERPRISE_VALIDITY_TOKEN_RATE_LIMITED,
);
}
if (isNonEmptyString(errorData.code)) {
this.lastRefreshRejectionCode = errorData.code;
}
// Only a key claimed by a different server means this instance is
// definitively displaced, so revoke its stored license. Other
// rejections (missing SERVER_ID, dev-needs-prod, dev-slot-taken) are
// recoverable: the existing token simply expires without reissue.
if (
errorData.code ===
EnterprisePlanService.ENTERPRISE_KEY_BOUND_TO_ANOTHER_SERVER_CODE
) {
await this.revokeStoredValidityToken();
}
return false;
}
@@ -258,6 +325,10 @@ export class EnterprisePlanService implements OnModuleInit {
return true;
} catch (error) {
if (error instanceof EnterpriseException) {
throw error;
}
this.logger.warn(
`Enterprise refresh failed: ${error instanceof Error ? error.message : 'Network error'}. Current validity token will continue to work until expiration.`,
);
@@ -309,6 +380,67 @@ export class EnterprisePlanService implements OnModuleInit {
}
}
async releaseServerBinding(): Promise<boolean> {
const enterpriseKey = this.twentyConfigService.get('ENTERPRISE_KEY');
if (!enterpriseKey) {
return false;
}
this.refreshKeyPayload();
if (!isDefined(this.cachedKeyPayload)) {
return false;
}
const apiUrl = this.twentyConfigService.get('ENTERPRISE_API_URL');
const releaseUrl = `${apiUrl}/release`;
try {
const instanceMetadata = await this.gatherInstanceMetadata();
const response = await fetch(releaseUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enterpriseKey, instanceMetadata }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
this.logger.warn(
`Enterprise binding release failed with status ${response.status}: ${errorData.error ?? 'Unknown error'}`,
);
if (
errorData.code ===
EnterpriseExceptionCode.ENTERPRISE_RELEASE_RATE_LIMITED
) {
throw new EnterpriseException(
'Enterprise server binding release rate limit reached',
EnterpriseExceptionCode.ENTERPRISE_RELEASE_RATE_LIMITED,
);
}
return false;
}
this.logger.log('Enterprise server binding released successfully');
return true;
} catch (error) {
if (error instanceof EnterpriseException) {
throw error;
}
this.logger.warn(
`Enterprise binding release failed: ${error instanceof Error ? error.message : 'Network error'}`,
);
return false;
}
}
async getSubscriptionStatus(): Promise<{
status: string;
licensee: string | null;
@@ -455,10 +587,34 @@ export class EnterprisePlanService implements OnModuleInit {
}
}
// Best-effort only: must never throw and fail a license refresh.
async getOrCreateServerId(): Promise<string | null> {
const existingServerId = this.twentyConfigService.get('SERVER_ID');
if (isNonEmptyString(existingServerId)) {
return existingServerId;
}
const newServerId = v4();
try {
await this.twentyConfigService.set('SERVER_ID', newServerId);
return newServerId;
} catch (error) {
this.logger.warn(
`Could not persist a generated SERVER_ID: ${error instanceof Error ? error.message : 'Unknown error'}. Set SERVER_ID in your .env file.`,
);
return null;
}
}
private async gatherInstanceMetadata(): Promise<EnterpriseInstanceMetadata> {
return {
serverId: this.twentyConfigService.get('SERVER_ID') ?? null,
serverId: await this.getOrCreateServerId(),
instanceType:
this.twentyConfigService.get('ENTERPRISE_INSTANCE_TYPE') ??
ENTERPRISE_INSTANCE_TYPE.PRODUCTION,
serverUrl: this.twentyConfigService.get('SERVER_URL') ?? null,
appVersion: this.twentyConfigService.get('APP_VERSION') ?? null,
nodeEnv: this.twentyConfigService.get('NODE_ENV') ?? null,
@@ -502,7 +658,7 @@ export class EnterprisePlanService implements OnModuleInit {
}
}
// In development and Jest integration tests, try both keys so production keys
// In development and Jest integration tests, tries both keys so production keys
// work locally
private getPublicKeysToTry(): string[] {
const nodeEnv = this.twentyConfigService.get('NODE_ENV');
@@ -1,3 +1,5 @@
import { type EnterpriseInstanceType } from 'twenty-shared/constants';
export type EnterpriseKeyPayload = {
sub: string;
licensee: string;
@@ -20,6 +22,7 @@ export type EnterpriseLicenseInfo = {
export type EnterpriseInstanceMetadata = {
serverId: string | null;
instanceType: EnterpriseInstanceType;
serverUrl: string | null;
appVersion: string | null;
nodeEnv: string | null;
@@ -5,6 +5,7 @@ import {
IsDateString,
IsDefined,
IsEnum,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
@@ -14,6 +15,10 @@ import {
type ValidationError,
validateSync,
} from 'class-validator';
import {
ENTERPRISE_INSTANCE_TYPE,
type EnterpriseInstanceType,
} from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import { type LoggerOptions } from 'typeorm/logger/LoggerOptions';
@@ -23,8 +28,8 @@ import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interface
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces';
import { DpaRegion } from 'src/engine/core-modules/dpa/enums/dpa-region.enum';
import { CodeInterpreterDriverType } from 'src/engine/core-modules/code-interpreter/code-interpreter.interface';
import { DpaRegion } from 'src/engine/core-modules/dpa/enums/dpa-region.enum';
import { EmailDriver } from 'src/engine/core-modules/email/enums/email-driver.enum';
import { EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-driver.type';
import { ExceptionHandlerDriver } from 'src/engine/core-modules/exception-handler/interfaces';
@@ -1338,13 +1343,24 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.SERVER_CONFIG,
description:
'Unique identifier for this server instance, generated as UUID v4 during database seeding',
'Unique identifier for this server instance, generated as UUID v4 during database seeding and persisted in the database. Can be overridden via the environment when IS_CONFIG_VARIABLES_IN_DB_ENABLED is false.',
type: ConfigVariableType.STRING,
isEnvOnly: true,
})
@IsOptional()
SERVER_ID: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.SERVER_CONFIG,
description:
"Declares whether this instance is a 'production' (billable per seat) or 'development' (included at no additional cost) enterprise instance. A subscription can register a single free development instance in addition to its production one.",
type: ConfigVariableType.ENUM,
options: Object.values(ENTERPRISE_INSTANCE_TYPE),
})
@IsOptional()
@IsIn(Object.values(ENTERPRISE_INSTANCE_TYPE))
ENTERPRISE_INSTANCE_TYPE: EnterpriseInstanceType =
ENTERPRISE_INSTANCE_TYPE.PRODUCTION;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.SERVER_CONFIG,
description: 'Base URL for public domains',