Billing for self-hosts (#18075)

## Summary

Implements enterprise licensing and per-seat billing for self-hosted
environments, with Stripe as the single source of truth for subscription
data.

### Components

- **twenty-website** hosts the private key to sign `ENTERPRISE_KEY` and
`ENTERPRISE_VALIDITY_TOKEN`. It communicates with Stripe to emit the
daily `ENTERPRISE_VALIDITY_TOKEN` if the subscription is active, based
on the user's Stripe subscription ID stored in `ENTERPRISE_KEY`.
- **Stripe** is the single source of truth for subscription data
(status, seats, billing).
- **The client** (twenty-server + DB + workers) saves `ENTERPRISE_KEY`
in the `keyValuePair` table (or `.env` if
`IS_CONFIG_VARIABLES_IN_DB_ENABLED` is false) and the daily-renewed
`ENTERPRISE_VALIDITY_TOKEN` in the `appToken` table.
`ENTERPRISE_VALIDITY_TOKEN` is verified client-side using a public key
to grant access to enterprise features (RLS, SSO, audit logs, etc.).

### Flow

1. When requesting an upgrade to an enterprise plan (from **Enterprise**
in settings), the user is shown a modal to choose monthly/yearly
billing, then redirected to Stripe to enter payment details. After
checkout, they land on twenty-website where they are exposed to their
`ENTERPRISE_KEY`, which they paste in the UI. It is saved in the
`keyValuePair` table. On activation, a first `ENTERPRISE_VALIDITY_TOKEN`
with 30-day validity is stored in the `appToken` table.

2. **Every day**, a cron job runs and does two things:
- **Refreshes the validity token**: communicates with twenty-website to
get a new `ENTERPRISE_VALIDITY_TOKEN` with 30-day validity if the Stripe
subscription is still active. If the subscription is in cancellation,
the emitted token has a validity equal to the cancellation date. If it's
no longer valid, the token is not replaced. The cron only needs to run
every 30 days in practice, but runs daily so it's resilient to
occasional failures.
- **Reports seat count**: counts active (non-soft-deleted)
`UserWorkspace` entries and sends the count to twenty-website, which
updates the Stripe subscription quantity with proration. Seats are also
reported on first activation. If the subscription is canceled or
scheduled for cancellation, the seat update is skipped.

3. `ENTERPRISE_VALIDITY_TOKEN` is verified server-side via a public key
to grant access to enterprise features.

### Key concepts

Three distinct checks are exposed as GraphQL fields on `Workspace`:

| Field | Meaning |
|---|---|
| `hasValidEnterpriseKey` | Has any valid enterprise key (signed JWT
**or** legacy plain string) |
| `hasValidSignedEnterpriseKey` | `ENTERPRISE_KEY` is a properly signed
JWT (billing portal makes sense) |
| `hasValidEnterpriseValidityToken` | `ENTERPRISE_VALIDITY_TOKEN` is
present and not expired (expiration depends on signed token payload, not
on "expiresAt" on appToken table which is only indicative) |

Feature access is gated by `isValid()` =
`hasValidEnterpriseValidityToken || hasValidEnterpriseKey` (to support
both new and legacy keys during transition). After transition isValid()
= hasValidEnterpriseValidityToken

### Frontend states

The Enterprise settings page handles multiple states:
- **No key**: show "Get Enterprise" with checkout modal
- **Orphaned validity token** (token valid but no signed key): prompt
user to set a valid enterprise key
- **Active/trialing but no validity token**: show subscription status
with a "Reload validity token" action
- **Active/trialing**: show full subscription info, billing portal
access, cancel option
- **Cancellation scheduled**: show cancellation date, billing portal
- **Canceled**: show billing history link and option to start a new
subscription
- **Past due / Incomplete**: prompt to update payment or restart

### Temporary retro-compatibility: legacy plain-text keys

Previously, enterprise features were gated by a simple check: any
non-empty string in `ENTERPRISE_KEY` granted access. With this PR, we
transition to a controlled system relying on signed JWTs.

To avoid breaking existing self-hosted users:
- **Legacy plain-text keys still grant access** to enterprise features.
`hasValidEnterpriseKey` returns `true` for both signed JWTs and plain
strings, and `isValid()` checks `hasValidEnterpriseKey` as a fallback
when no validity token is present.
- **A deprecation banner** is shown at the top of the app when
`hasValidEnterpriseKey` is `true` but `hasValidSignedEnterpriseKey` is
`false`, informing the user that their key format is deprecated and they
should activate a new signed key.
- **No billing portal or subscription management** is available for
legacy keys since there is no Stripe subscription to manage.

This retro-compatibility will be removed in a future version. At that
point, `isValid()` will only check `hasValidEnterpriseValidityToken`.

### Edge cases

- **Air-gapped / production environments**: for self-hosted clients that
block external traffic (or for our own production), provide a long-lived
`ENTERPRISE_VALIDITY_TOKEN` (e.g. 99 years) directly in the `appToken`
table, with no `ENTERPRISE_KEY`. The daily cron will skip the refresh
(no enterprise key to authenticate with), but the pre-seeded validity
token will be used to grant feature access. No billing or seat reporting
occurs in this mode.
- **`IS_CONFIG_VARIABLES_IN_DB_ENABLED` is false**: if the user tries to
activate an enterprise key but DB config writes are disabled, the
backend returns a clear error asking them to add `ENTERPRISE_KEY` to
their `.env` file manually.
- **Canceled subscriptions**: the `/seats` endpoint skips Stripe updates
for canceled or cancellation-scheduled subscriptions to avoid Stripe API
errors.

### How to test
- launch twenty-website on a different url (eg localhost:1002)
- add ENTERPRISE_API_URL=http://localhost:3002/api/enterprise (or else)
in your server .env
- ask me for twenty-website's .env file content (STRIPE_SECRET_KEY;
STRIPE_ENTERPRISE_MONTHLY_PRICE_ID;STRIPE_ENTERPRISE_YEARLY_PRICE_ID;
ENTERPRISE_JWT_PRIVATE_KEY; ENTERPRISE_JWT_PUBLIC_KEY;
NEXT_PUBLIC_WEBSITE_URL)
- visit Admin panel / enterprise
This commit is contained in:
Marie
2026-03-12 15:07:53 +01:00
committed by GitHub
parent c59f420d21
commit c1da7be6d7
76 changed files with 4614 additions and 538 deletions
@@ -27,6 +27,7 @@ export enum AppTokenType {
PasswordResetToken = 'PASSWORD_RESET_TOKEN',
InvitationToken = 'INVITATION_TOKEN',
EmailVerificationToken = 'EMAIL_VERIFICATION_TOKEN',
EnterpriseValidityToken = 'ENTERPRISE_VALIDITY_TOKEN',
}
@Entity({ name: 'appToken', schema: 'core' })
@@ -29,6 +29,7 @@ export const AuthExceptionCode = appendCommonExceptionCode({
GOOGLE_API_AUTH_DISABLED: 'GOOGLE_API_AUTH_DISABLED',
MICROSOFT_API_AUTH_DISABLED: 'MICROSOFT_API_AUTH_DISABLED',
MISSING_ENVIRONMENT_VARIABLE: 'MISSING_ENVIRONMENT_VARIABLE',
ENTERPRISE_VALIDITY_TOKEN_NOT_VALID: 'ENTERPRISE_VALIDITY_TOKEN_NOT_VALID',
INVALID_JWT_TOKEN_TYPE: 'INVALID_JWT_TOKEN_TYPE',
TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED:
'TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED',
@@ -80,6 +81,8 @@ const getAuthExceptionUserFriendlyMessage = (
return msg`Two-factor authentication verification is required.`;
case AuthExceptionCode.USER_ALREADY_EXISTS:
return msg`A user with this email already exists.`;
case AuthExceptionCode.ENTERPRISE_VALIDITY_TOKEN_NOT_VALID:
return msg`Enterprise validity token is not valid.`;
case AuthExceptionCode.INTERNAL_SERVER_ERROR:
case AuthExceptionCode.INVALID_DATA:
case AuthExceptionCode.CLIENT_NOT_FOUND:
@@ -37,6 +37,7 @@ import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain-
import { SubdomainManagerModule } from 'src/engine/core-modules/domain/subdomain-manager/subdomain-manager.module';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
import { EmailVerificationModule } from 'src/engine/core-modules/email-verification/email-verification.module';
import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
@@ -120,6 +121,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
ApplicationModule,
WorkspaceCacheModule,
SecureHttpClientModule,
EnterpriseModule,
FileModule,
],
controllers: [
@@ -10,22 +10,22 @@ import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { EnterprisePlanService } from 'src/engine/core-modules/enterprise/services/enterprise-plan.service';
import { GuardRedirectService } from 'src/engine/core-modules/guard-redirect/services/guard-redirect.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
export class EnterpriseFeaturesEnabledGuard implements CanActivate {
constructor(
private readonly guardRedirectService: GuardRedirectService,
private readonly twentyConfigService: TwentyConfigService,
private readonly enterprisePlanService: EnterprisePlanService,
) {}
canActivate(context: ExecutionContext): boolean {
try {
if (!this.twentyConfigService.get('ENTERPRISE_KEY')) {
if (!this.enterprisePlanService.isValid()) {
throw new AuthException(
'Enterprise key missing',
AuthExceptionCode.MISSING_ENVIRONMENT_VARIABLE,
'Enterprise features are not enabled',
AuthExceptionCode.ENTERPRISE_VALIDITY_TOKEN_NOT_VALID,
);
}
@@ -27,6 +27,7 @@ export const authGraphqlApiExceptionHandler = (exception: AuthException) => {
case AuthExceptionCode.MISSING_ENVIRONMENT_VARIABLE:
case AuthExceptionCode.INVALID_JWT_TOKEN_TYPE:
case AuthExceptionCode.USER_ALREADY_EXISTS:
case AuthExceptionCode.ENTERPRISE_VALIDITY_TOKEN_NOT_VALID:
throw new ForbiddenError(exception);
case AuthExceptionCode.GOOGLE_API_AUTH_DISABLED:
case AuthExceptionCode.MICROSOFT_API_AUTH_DISABLED:
@@ -23,6 +23,7 @@ export const getAuthExceptionRestStatus = (exception: AuthException) => {
case AuthExceptionCode.EMAIL_NOT_VERIFIED:
case AuthExceptionCode.INVALID_JWT_TOKEN_TYPE:
case AuthExceptionCode.USER_ALREADY_EXISTS:
case AuthExceptionCode.ENTERPRISE_VALIDITY_TOKEN_NOT_VALID:
return 403;
case AuthExceptionCode.TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED:
case AuthExceptionCode.TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED:
@@ -32,6 +32,7 @@ import { BillingService } from 'src/engine/core-modules/billing/services/billing
import { MeteredCreditService } from 'src/engine/core-modules/billing/services/metered-credit.service';
import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { MessageQueueModule } from 'src/engine/core-modules/message-queue/message-queue.module';
@@ -66,6 +67,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
]),
DataSourceModule,
MetricsModule,
EnterpriseModule,
],
providers: [
BillingSubscriptionService,
@@ -38,6 +38,7 @@ import { StripeCustomerService } from 'src/engine/core-modules/billing/stripe/se
import { StripeSubscriptionScheduleService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription-schedule.service';
import { StripeSubscriptionService } from 'src/engine/core-modules/billing/stripe/services/stripe-subscription.service';
import { getPlanKeyFromSubscription } from 'src/engine/core-modules/billing/utils/get-plan-key-from-subscription.util';
import { EnterprisePlanService } from 'src/engine/core-modules/enterprise/services/enterprise-plan.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -61,6 +62,7 @@ export class BillingSubscriptionService {
@InjectRepository(BillingCustomerEntity)
private readonly billingCustomerRepository: Repository<BillingSubscriptionEntity>,
private readonly meteredCreditService: MeteredCreditService,
private readonly enterprisePlanService: EnterprisePlanService,
) {}
async getBillingSubscriptions(workspaceId: string) {
@@ -180,9 +182,7 @@ export class BillingSubscriptionService {
workspaceId: string,
): Promise<BillingEntitlementDTO[]> {
const isBillingEnabled = this.twentyConfigService.get('IS_BILLING_ENABLED');
const hasValidEnterpriseKey = isDefined(
this.twentyConfigService.get('ENTERPRISE_KEY'),
);
const hasValidEnterprisePlan = this.enterprisePlanService.isValid();
const entitlements = isBillingEnabled
? await this.billingEntitlementRepository.find({
@@ -202,7 +202,7 @@ export class BillingSubscriptionService {
return Object.values(BillingEntitlementKey).map((key) => ({
key,
value:
hasValidEnterpriseKey &&
hasValidEnterprisePlan &&
(!isBillingEnabled || (entitlementsByKey[key]?.value ?? false)),
}));
}
@@ -0,0 +1,2 @@
// Run daily at 4 AM UTC
export const ENTERPRISE_KEY_VALIDATION_CRON_PATTERN = '0 4 * * *';
@@ -0,0 +1,26 @@
// RS256 public key for verifying enterprise license JWTs signed by twenty.com
// The corresponding private key is held exclusively by twenty.com
export const ENTERPRISE_JWT_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAl12Me8NXrQhsgBTr8slx
2lTZNJCLwWCIs3zRWZzuHelUgNj2wFEM7R7wx0v/OxQHoXzXqAbgEu67HHNXTAnA
gcYGzjSqa6o8NZHqUrzjOgvP0Ck8EQYxNYrxHAiDUChMHsFNYvcx/savm6Pn1sTL
gcYnGuuuAYdsV0L78N5WsdbSfyAPyPv5ULYMSci7OLGUBlIa55da9Qwmie1HC+J4
MtSSnw9o9OzR1ekw9JxQdho+Rj1mQ9BuvBplGNLabolFZweYdYEyqXReRbqMmNz/
EuZs6PhKiH6l3sXY6kocC0ZV25rFhHgOChVA91BE8a+Wj0MtGGI/UL/b21G3zsr7
6wIDAQAB
-----END PUBLIC KEY-----`;
// Dev-only
export const ENTERPRISE_JWT_DEV_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsHEWc84t399KlYyhRkBO
QCXE8/HE/TKvMk35ccoUeZXnBNhuGpWcHuM6+6ekHiToT239hkBy+Bk/Ybd6wVrs
Vn0Vc0KarRsmeOrJu+sVREL5AUWt0gitpBoNeBzdfgW8dzdyVKDSCUNvXzEOQKkT
+tmDYhvSs8VSmzer2juSaj4xQ35X/sM+Ea3IHFx8mf9d6fMAJ5u8AE0fAFD9XUbe
Dmj1SnUz4yy11EeY48+wTguk9WBFjOsA/1Dnc6jL/MN8zH77xRlIs/iPjBriPhCN
njn0rDVHHdat+3NqKlSbFcQPVzYicxDRXaakJ2IEJhxpEoOFWHJ9bNQKudRqfaoF
BQIDAQAB
-----END PUBLIC KEY-----`;
// signed with ENTERPRISE_JWT_DEV_PRIVATE_KEY (expires 2125)
export const ENTERPRISE_DEV_VALIDITY_TOKEN =
'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkZXYtc3Vic2NyaXB0aW9uLWlkIiwic3RhdHVzIjoidmFsaWQiLCJpYXQiOjE3NzMzMDg4MzMsImV4cCI6NDg5NzUxMTIzM30.qhfrW_SV2Y86fWtWXsALlAVhxmMxylUUIefN0fki10Q2NTGGqFVXZrNn2WacJY37yq3m5y4WgwZw34ua6E0ff_YUXsrlY5OHJWHT9DMqKCRn-JujHJnnYp3VHLncy5CvxH5r9mfPFp-5AWe1pYeR1T63sTiejH3sfDrNE357SB7KVti8LCcnsJxEtXB2tRnvyvdun7A-GKoKYEIam-16ZRKKFs6GaWo8ObHdfm8yBt6uK4DZSGPWb644QyWh9FtDxbzJ0ti54DuHSlErLgIp1NNEsMA0MK7zFY7StRaOdt72rxE1ZHwN7e6HhweTU4ORVUPfYkjDFLB2fF7Pa7Kvdg';
@@ -0,0 +1,5 @@
/* @license Enterprise */
const DAYS_TO_MS = (days: number) => days * 24 * 60 * 60 * 1000;
export const ENTERPRISE_VALIDITY_TOKEN_DEFAULT_EXPIRATION_MS = DAYS_TO_MS(30);
@@ -0,0 +1,35 @@
/* @license Enterprise */
import { Command, CommandRunner } from 'nest-commander';
import { ENTERPRISE_KEY_VALIDATION_CRON_PATTERN } from 'src/engine/core-modules/enterprise/constants/enterprise-key-validation-cron-pattern.constant';
import { EnterpriseKeyValidationCronJob } from 'src/engine/core-modules/enterprise/cron/jobs/enterprise-key-validation.cron.job';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
@Command({
name: 'cron:enterprise-key-validation',
description:
'Starts a daily cron job to refresh the enterprise validity token',
})
export class EnterpriseKeyValidationCronCommand extends CommandRunner {
constructor(
@InjectMessageQueue(MessageQueue.cronQueue)
private readonly messageQueueService: MessageQueueService,
) {
super();
}
async run(): Promise<void> {
await this.messageQueueService.addCron<undefined>({
jobName: EnterpriseKeyValidationCronJob.name,
data: undefined,
options: {
repeat: {
pattern: ENTERPRISE_KEY_VALIDATION_CRON_PATTERN,
},
},
});
}
}
@@ -0,0 +1,74 @@
/* @license Enterprise */
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
import { ENTERPRISE_KEY_VALIDATION_CRON_PATTERN } from 'src/engine/core-modules/enterprise/constants/enterprise-key-validation-cron-pattern.constant';
import { EnterprisePlanService } from 'src/engine/core-modules/enterprise/services/enterprise-plan.service';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
@Injectable()
@Processor(MessageQueue.cronQueue)
export class EnterpriseKeyValidationCronJob {
private readonly logger = new Logger(EnterpriseKeyValidationCronJob.name);
constructor(
private readonly enterprisePlanService: EnterprisePlanService,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
) {}
@Process(EnterpriseKeyValidationCronJob.name)
@SentryCronMonitor(
EnterpriseKeyValidationCronJob.name,
ENTERPRISE_KEY_VALIDATION_CRON_PATTERN,
)
async handle(): Promise<void> {
this.logger.log(
'Starting enterprise validity token refresh and seat report...',
);
const refreshSuccess =
await this.enterprisePlanService.refreshValidityToken();
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.',
);
}
try {
const seatCount = await this.getActiveUserWorkspaceCount();
const reportSuccess =
await this.enterprisePlanService.reportSeats(seatCount);
if (reportSuccess) {
this.logger.log(`Reported ${seatCount} seats to enterprise API`);
} else {
this.logger.warn('Seat report did not succeed');
}
} catch (error) {
this.logger.warn(
`Failed to get seat count or report: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
}
}
private async getActiveUserWorkspaceCount(): Promise<number> {
const count = await this.userWorkspaceRepository.count({
where: { deletedAt: IsNull() },
});
return Math.max(1, count);
}
}
@@ -0,0 +1,18 @@
/* @license Enterprise */
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class EnterpriseLicenseInfoDTO {
@Field(() => Boolean)
isValid: boolean;
@Field(() => String, { nullable: true })
licensee: string | null;
@Field(() => Date, { nullable: true })
expiresAt: Date | null;
@Field(() => String, { nullable: true })
subscriptionId: string | null;
}
@@ -0,0 +1,24 @@
/* @license Enterprise */
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class EnterpriseSubscriptionStatusDTO {
@Field(() => String)
status: string;
@Field(() => String, { nullable: true })
licensee: string | null;
@Field(() => Date, { nullable: true })
expiresAt: Date | null;
@Field(() => Date, { nullable: true })
cancelAt: Date | null;
@Field(() => Date, { nullable: true })
currentPeriodEnd: Date | null;
@Field(() => Boolean)
isCancellationScheduled: boolean;
}
@@ -0,0 +1,13 @@
/* @license Enterprise */
import { ArgsType, Field } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
@ArgsType()
export class SetEnterpriseKeyInput {
@Field(() => String)
@IsString()
@IsNotEmpty()
enterpriseKey: string;
}
@@ -0,0 +1,25 @@
/* @license Enterprise */
import { Catch, type ExceptionFilter } from '@nestjs/common';
import { assertUnreachable } from 'twenty-shared/utils';
import {
EnterpriseException,
EnterpriseExceptionCode,
} from 'src/engine/core-modules/enterprise/enterprise.exception';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
@Catch(EnterpriseException)
export class EnterpriseExceptionFilter implements ExceptionFilter {
catch(exception: EnterpriseException) {
switch (exception.code) {
case EnterpriseExceptionCode.INVALID_ENTERPRISE_KEY:
case EnterpriseExceptionCode.CONFIG_VARIABLES_IN_DB_DISABLED:
throw new UserInputError(exception);
default: {
assertUnreachable(exception.code);
}
}
}
}
@@ -0,0 +1,38 @@
/* @license Enterprise */
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
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',
}
const getEnterpriseExceptionUserFriendlyMessage = (
code: EnterpriseExceptionCode,
) => {
switch (code) {
case EnterpriseExceptionCode.INVALID_ENTERPRISE_KEY:
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.`;
default:
assertUnreachable(code);
}
};
export class EnterpriseException extends CustomException<EnterpriseExceptionCode> {
constructor(
message: string,
code: EnterpriseExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ?? getEnterpriseExceptionUserFriendlyMessage(code),
});
}
}
@@ -0,0 +1,25 @@
/* @license Enterprise */
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { EnterpriseKeyValidationCronJob } from 'src/engine/core-modules/enterprise/cron/jobs/enterprise-key-validation.cron.job';
import { EnterpriseResolver } from 'src/engine/core-modules/enterprise/enterprise.resolver';
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';
@Module({
imports: [
TwentyConfigModule,
TypeOrmModule.forFeature([UserWorkspaceEntity, AppTokenEntity]),
],
providers: [
EnterprisePlanService,
EnterpriseKeyValidationCronJob,
EnterpriseResolver,
],
exports: [EnterprisePlanService, EnterpriseKeyValidationCronJob],
})
export class EnterpriseModule {}
@@ -0,0 +1,150 @@
/* @license Enterprise */
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
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 {
EnterpriseException,
EnterpriseExceptionCode,
} from 'src/engine/core-modules/enterprise/enterprise.exception';
import { EnterprisePlanService } from 'src/engine/core-modules/enterprise/services/enterprise-plan.service';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { ConfigVariableExceptionCode } from 'src/engine/core-modules/twenty-config/twenty-config.exception';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { AdminPanelGuard } from 'src/engine/guards/admin-panel-guard';
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';
@Resolver()
@UsePipes(ResolverValidationPipe)
@UseFilters(EnterpriseExceptionFilter, PreventNestToAutoLogGraphqlErrorsFilter)
export class EnterpriseResolver {
constructor(
private readonly enterprisePlanService: EnterprisePlanService,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
) {}
private async getActiveUserWorkspaceCount(): Promise<number> {
const count = await this.userWorkspaceRepository.count({
where: { deletedAt: IsNull() },
});
return Math.max(1, count);
}
@Query(() => String, { nullable: true })
@UseGuards(
WorkspaceAuthGuard,
BillingDisabledGuard,
AdminPanelGuard,
NoPermissionGuard,
)
async enterprisePortalSession(
// for existing subscriptions
@Args('returnUrlPath', { nullable: true }) returnUrlPath?: string,
): Promise<string | null> {
return this.enterprisePlanService.getPortalUrl(returnUrlPath ?? undefined);
}
@Query(() => String, { nullable: true })
@UseGuards(
WorkspaceAuthGuard,
BillingDisabledGuard,
AdminPanelGuard,
NoPermissionGuard,
)
async enterpriseCheckoutSession(
// for new subscriptions
@Args('billingInterval', { nullable: true }) billingInterval?: string,
): Promise<string | null> {
const interval = billingInterval === 'yearly' ? 'yearly' : 'monthly';
const seatCount = await this.getActiveUserWorkspaceCount();
return this.enterprisePlanService.getCheckoutUrl(interval, seatCount);
}
@Query(() => EnterpriseSubscriptionStatusDTO, { nullable: true })
@UseGuards(
WorkspaceAuthGuard,
BillingDisabledGuard,
AdminPanelGuard,
NoPermissionGuard,
)
async enterpriseSubscriptionStatus(): Promise<EnterpriseSubscriptionStatusDTO | null> {
return this.enterprisePlanService.getSubscriptionStatus();
}
@Mutation(() => Boolean)
@UseGuards(
WorkspaceAuthGuard,
BillingDisabledGuard,
AdminPanelGuard,
NoPermissionGuard,
)
async refreshEnterpriseValidityToken(): Promise<boolean> {
return this.enterprisePlanService.refreshValidityToken();
}
@Mutation(() => EnterpriseLicenseInfoDTO)
@UseGuards(
WorkspaceAuthGuard,
BillingDisabledGuard,
AdminPanelGuard,
NoPermissionGuard,
)
async setEnterpriseKey(
@Args('enterpriseKey') enterpriseKey: string,
): Promise<EnterpriseLicenseInfoDTO> {
try {
if (
!this.enterprisePlanService.isValidEnterpriseKeyFormat(enterpriseKey)
) {
throw new EnterpriseException(
'Invalid enterprise key',
EnterpriseExceptionCode.INVALID_ENTERPRISE_KEY,
);
}
await this.enterprisePlanService.setEnterpriseKey(enterpriseKey);
await this.enterprisePlanService.refreshValidityToken();
const seatCount = await this.getActiveUserWorkspaceCount();
await this.enterprisePlanService.reportSeats(seatCount);
return await this.enterprisePlanService.getLicenseInfo();
} catch (error) {
if (error instanceof EnterpriseException) {
throw error;
}
if (
error instanceof Error &&
'code' in error &&
error.code === ConfigVariableExceptionCode.DATABASE_CONFIG_DISABLED
) {
throw new EnterpriseException(
'IS_CONFIG_VARIABLES_IN_DB_ENABLED is false on the server. Please add ENTERPRISE_KEY to your .env file manually.',
EnterpriseExceptionCode.CONFIG_VARIABLES_IN_DB_DISABLED,
);
}
return {
isValid: false,
licensee: null,
expiresAt: null,
subscriptionId: null,
};
}
}
}
@@ -0,0 +1,806 @@
/* @license Enterprise */
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { EnterprisePlanService } from 'src/engine/core-modules/enterprise/services/enterprise-plan.service';
import {
ConfigVariableException,
ConfigVariableExceptionCode,
} from 'src/engine/core-modules/twenty-config/twenty-config.exception';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
const mockCryptoVerify = jest.fn();
jest.mock('crypto', () => ({
...jest.requireActual('crypto'),
verify: (...args: unknown[]) => mockCryptoVerify(...args),
}));
const createFakeJwt = (payload: Record<string, unknown>): string => {
const header = Buffer.from(
JSON.stringify({ alg: 'RS256', typ: 'JWT' }),
).toString('base64url');
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
const signature = Buffer.from('fake-signature').toString('base64url');
return `${header}.${body}.${signature}`;
};
const MOCK_API_URL = 'https://enterprise.example.com';
const FUTURE_TIMESTAMP = Math.floor(Date.now() / 1000) + 3600;
const PAST_TIMESTAMP = Math.floor(Date.now() / 1000) - 3600;
const MOCK_KEY_PAYLOAD = {
sub: 'sub-123',
licensee: 'ACME Corp',
iat: 1000,
};
const MOCK_VALIDITY_PAYLOAD = {
sub: 'sub-123',
status: 'valid',
iat: 1000,
exp: FUTURE_TIMESTAMP,
};
const MOCK_EXPIRED_VALIDITY_PAYLOAD = {
sub: 'sub-123',
status: 'valid',
iat: 1000,
exp: PAST_TIMESTAMP,
};
describe('EnterprisePlanService', () => {
let service: EnterprisePlanService;
const configGetMock = jest.fn();
const configSetMock = jest.fn();
const appTokenFindOneMock = jest.fn();
const transactionMock = jest.fn();
const fetchMock = jest.fn();
let originalFetch: typeof global.fetch;
const setupEnterpriseKey = (key?: string) => {
configGetMock.mockImplementation((configKey: string) => {
if (configKey === 'ENTERPRISE_KEY') return key;
if (configKey === 'ENTERPRISE_API_URL') return MOCK_API_URL;
return undefined;
});
};
const setupValidState = async (
overrides: {
keyPayload?: Record<string, unknown>;
validityPayload?: Record<string, unknown>;
cryptoVerifyResult?: boolean;
} = {},
) => {
const {
keyPayload = MOCK_KEY_PAYLOAD,
validityPayload = MOCK_VALIDITY_PAYLOAD,
cryptoVerifyResult = true,
} = overrides;
const fakeKey = createFakeJwt(keyPayload);
const fakeValidityToken = createFakeJwt(validityPayload);
setupEnterpriseKey(fakeKey);
mockCryptoVerify.mockReturnValue(cryptoVerifyResult);
appTokenFindOneMock.mockResolvedValue({ value: fakeValidityToken });
await service.onModuleInit();
};
beforeEach(async () => {
jest.clearAllMocks();
originalFetch = global.fetch;
global.fetch = fetchMock as unknown as typeof fetch;
configGetMock.mockImplementation((key: string) => {
if (key === 'ENTERPRISE_API_URL') return MOCK_API_URL;
return undefined;
});
appTokenFindOneMock.mockResolvedValue(null);
const module: TestingModule = await Test.createTestingModule({
providers: [
EnterprisePlanService,
{
provide: TwentyConfigService,
useValue: {
get: configGetMock,
set: configSetMock,
},
},
{
provide: getRepositoryToken(AppTokenEntity),
useValue: {
findOne: appTokenFindOneMock,
target: AppTokenEntity,
manager: {
transaction: transactionMock,
},
},
},
],
}).compile();
service = module.get<EnterprisePlanService>(EnterprisePlanService);
});
afterEach(() => {
global.fetch = originalFetch;
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('onModuleInit', () => {
it('should populate caches when enterprise key and validity token exist', async () => {
await setupValidState();
expect(service.hasValidSignedEnterpriseKey()).toBe(true);
expect(service.hasValidEnterpriseValidityToken()).toBe(true);
});
it('should handle missing enterprise key', async () => {
setupEnterpriseKey(undefined);
appTokenFindOneMock.mockResolvedValue(null);
await service.onModuleInit();
expect(service.hasValidSignedEnterpriseKey()).toBe(false);
expect(service.hasValidEnterpriseValidityToken()).toBe(false);
});
it('should handle DB error when loading validity token', async () => {
setupEnterpriseKey(createFakeJwt(MOCK_KEY_PAYLOAD));
mockCryptoVerify.mockReturnValue(true);
appTokenFindOneMock.mockRejectedValue(new Error('DB connection failed'));
await service.onModuleInit();
expect(service.hasValidSignedEnterpriseKey()).toBe(true);
expect(service.hasValidEnterpriseValidityToken()).toBe(false);
});
it('should fall back to ENTERPRISE_VALIDITY_TOKEN config when DB has no token', async () => {
const fakeKey = createFakeJwt(MOCK_KEY_PAYLOAD);
const fakeValidityToken = createFakeJwt(MOCK_VALIDITY_PAYLOAD);
configGetMock.mockImplementation((key: string) => {
if (key === 'ENTERPRISE_KEY') return fakeKey;
if (key === 'ENTERPRISE_API_URL') return MOCK_API_URL;
if (key === 'ENTERPRISE_VALIDITY_TOKEN') return fakeValidityToken;
return undefined;
});
mockCryptoVerify.mockReturnValue(true);
appTokenFindOneMock.mockResolvedValue(null);
await service.onModuleInit();
expect(service.hasValidEnterpriseValidityToken()).toBe(true);
});
it('should prefer DB token over ENTERPRISE_VALIDITY_TOKEN config', async () => {
const fakeKey = createFakeJwt(MOCK_KEY_PAYLOAD);
const dbToken = createFakeJwt(MOCK_VALIDITY_PAYLOAD);
const envToken = createFakeJwt(MOCK_EXPIRED_VALIDITY_PAYLOAD);
configGetMock.mockImplementation((key: string) => {
if (key === 'ENTERPRISE_KEY') return fakeKey;
if (key === 'ENTERPRISE_API_URL') return MOCK_API_URL;
if (key === 'ENTERPRISE_VALIDITY_TOKEN') return envToken;
return undefined;
});
mockCryptoVerify.mockReturnValue(true);
appTokenFindOneMock.mockResolvedValue({ value: dbToken });
await service.onModuleInit();
expect(service.hasValidEnterpriseValidityToken()).toBe(true);
});
it('should reject validity token with non-valid status', async () => {
const invalidStatusPayload = {
...MOCK_VALIDITY_PAYLOAD,
status: 'revoked',
};
setupEnterpriseKey(createFakeJwt(MOCK_KEY_PAYLOAD));
mockCryptoVerify.mockReturnValue(true);
appTokenFindOneMock.mockResolvedValue({
value: createFakeJwt(invalidStatusPayload),
});
await service.onModuleInit();
expect(service.hasValidEnterpriseValidityToken()).toBe(false);
});
});
describe('hasValidSignedEnterpriseKey', () => {
it('should return false when no enterprise key is configured', async () => {
setupEnterpriseKey(undefined);
await service.onModuleInit();
expect(service.hasValidSignedEnterpriseKey()).toBe(false);
});
it('should return true when key has valid signature', async () => {
setupEnterpriseKey(createFakeJwt(MOCK_KEY_PAYLOAD));
mockCryptoVerify.mockReturnValue(true);
await service.onModuleInit();
expect(service.hasValidSignedEnterpriseKey()).toBe(true);
});
it('should return false when key has invalid signature', async () => {
setupEnterpriseKey(createFakeJwt(MOCK_KEY_PAYLOAD));
mockCryptoVerify.mockReturnValue(false);
await service.onModuleInit();
expect(service.hasValidSignedEnterpriseKey()).toBe(false);
});
it('should return false when key is not a valid JWT format', async () => {
setupEnterpriseKey('not-a-jwt');
await service.onModuleInit();
expect(service.hasValidSignedEnterpriseKey()).toBe(false);
});
});
describe('hasValidEnterpriseValidityToken', () => {
it('should return false when no validity token exists', async () => {
setupEnterpriseKey(undefined);
appTokenFindOneMock.mockResolvedValue(null);
await service.onModuleInit();
expect(service.hasValidEnterpriseValidityToken()).toBe(false);
});
it('should return true when validity token is valid and not expired', async () => {
await setupValidState();
expect(service.hasValidEnterpriseValidityToken()).toBe(true);
});
it('should return false when validity token is expired', async () => {
await setupValidState({
validityPayload: MOCK_EXPIRED_VALIDITY_PAYLOAD,
});
expect(service.hasValidEnterpriseValidityToken()).toBe(false);
});
});
describe('hasValidEnterpriseKey', () => {
it('should return true when signed enterprise key is valid', async () => {
await setupValidState();
expect(service.hasValidEnterpriseKey()).toBe(true);
});
it('should return true with legacy unsigned key as fallback', async () => {
setupEnterpriseKey('some-legacy-key');
mockCryptoVerify.mockReturnValue(false);
appTokenFindOneMock.mockResolvedValue(null);
await service.onModuleInit();
expect(service.hasValidSignedEnterpriseKey()).toBe(false);
expect(service.hasValidEnterpriseKey()).toBe(true);
});
it('should return false when no key is configured', async () => {
setupEnterpriseKey(undefined);
await service.onModuleInit();
expect(service.hasValidEnterpriseKey()).toBe(false);
});
});
describe('isValid', () => {
it('should return true when validity token is valid', async () => {
await setupValidState();
expect(service.isValid()).toBe(true);
});
it('should return true with legacy key as fallback', async () => {
setupEnterpriseKey('some-legacy-key');
mockCryptoVerify.mockReturnValue(false);
appTokenFindOneMock.mockResolvedValue(null);
await service.onModuleInit();
expect(service.isValid()).toBe(true);
});
it('should return false when no key or token exists', async () => {
setupEnterpriseKey(undefined);
appTokenFindOneMock.mockResolvedValue(null);
await service.onModuleInit();
expect(service.isValid()).toBe(false);
});
});
describe('isValidEnterpriseKeyFormat', () => {
it('should return true for valid JWT format', () => {
mockCryptoVerify.mockReturnValue(true);
const validKey = createFakeJwt(MOCK_KEY_PAYLOAD);
expect(service.isValidEnterpriseKeyFormat(validKey)).toBe(true);
});
it('should return false for invalid JWT format', () => {
expect(service.isValidEnterpriseKeyFormat('not-a-jwt')).toBe(false);
});
it('should return false when signature verification fails', () => {
mockCryptoVerify.mockReturnValue(false);
const invalidKey = createFakeJwt(MOCK_KEY_PAYLOAD);
expect(service.isValidEnterpriseKeyFormat(invalidKey)).toBe(false);
});
});
describe('getLicenseInfo', () => {
it('should return valid license info when validity token exists', async () => {
await setupValidState();
const licenseInfo = await service.getLicenseInfo();
expect(licenseInfo).toEqual({
isValid: true,
licensee: 'ACME Corp',
expiresAt: new Date(FUTURE_TIMESTAMP * 1000),
subscriptionId: 'sub-123',
});
});
it('should return expired license info when validity token is expired', async () => {
await setupValidState({
validityPayload: MOCK_EXPIRED_VALIDITY_PAYLOAD,
});
const licenseInfo = await service.getLicenseInfo();
expect(licenseInfo).toEqual({
isValid: false,
licensee: 'ACME Corp',
expiresAt: new Date(PAST_TIMESTAMP * 1000),
subscriptionId: 'sub-123',
});
});
it('should return legacy license info when only legacy key exists', async () => {
setupEnterpriseKey('some-legacy-key');
mockCryptoVerify.mockReturnValue(false);
appTokenFindOneMock.mockResolvedValue(null);
const licenseInfo = await service.getLicenseInfo();
expect(licenseInfo).toEqual({
isValid: true,
licensee: null,
expiresAt: null,
subscriptionId: null,
});
});
it('should return invalid license info when no key exists', async () => {
setupEnterpriseKey(undefined);
appTokenFindOneMock.mockResolvedValue(null);
const licenseInfo = await service.getLicenseInfo();
expect(licenseInfo).toEqual({
isValid: false,
licensee: null,
expiresAt: null,
subscriptionId: null,
});
});
});
describe('setEnterpriseKey', () => {
it('should set the enterprise key via config service', async () => {
configSetMock.mockResolvedValue(undefined);
await service.setEnterpriseKey('new-enterprise-key');
expect(configSetMock).toHaveBeenCalledWith(
'ENTERPRISE_KEY',
'new-enterprise-key',
);
});
it('should throw specific error when DB config is disabled', async () => {
configSetMock.mockRejectedValue(
new ConfigVariableException(
'Database config disabled',
ConfigVariableExceptionCode.DATABASE_CONFIG_DISABLED,
),
);
await expect(service.setEnterpriseKey('key')).rejects.toThrow(
'IS_CONFIG_VARIABLES_IN_DB_ENABLED is false on your server',
);
});
it('should re-throw other errors', async () => {
configSetMock.mockRejectedValue(new Error('Unexpected error'));
await expect(service.setEnterpriseKey('key')).rejects.toThrow(
'Unexpected error',
);
});
});
describe('refreshValidityToken', () => {
it('should return false when no enterprise key is configured', async () => {
setupEnterpriseKey(undefined);
const result = await service.refreshValidityToken();
expect(result).toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});
it('should return false when key is not a valid signed JWT', async () => {
setupEnterpriseKey('not-a-valid-jwt');
const result = await service.refreshValidityToken();
expect(result).toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});
it('should refresh and return true when API call succeeds', async () => {
const fakeKey = createFakeJwt(MOCK_KEY_PAYLOAD);
const fakeValidityToken = createFakeJwt(MOCK_VALIDITY_PAYLOAD);
setupEnterpriseKey(fakeKey);
mockCryptoVerify.mockReturnValue(true);
fetchMock.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ validityToken: fakeValidityToken }),
});
transactionMock.mockImplementation(
async (callback: (manager: Record<string, jest.Mock>) => void) => {
await callback({
update: jest.fn(),
save: jest.fn(),
});
},
);
appTokenFindOneMock.mockResolvedValue({ value: fakeValidityToken });
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 }),
});
});
it('should return false when API returns non-OK response', async () => {
const fakeKey = createFakeJwt(MOCK_KEY_PAYLOAD);
setupEnterpriseKey(fakeKey);
mockCryptoVerify.mockReturnValue(true);
fetchMock.mockResolvedValue({
ok: false,
status: 401,
json: () => Promise.resolve({ error: 'Unauthorized' }),
});
const result = await service.refreshValidityToken();
expect(result).toBe(false);
});
it('should return false when API response is missing validityToken', async () => {
const fakeKey = createFakeJwt(MOCK_KEY_PAYLOAD);
setupEnterpriseKey(fakeKey);
mockCryptoVerify.mockReturnValue(true);
fetchMock.mockResolvedValue({
ok: true,
json: () => Promise.resolve({}),
});
const result = await service.refreshValidityToken();
expect(result).toBe(false);
});
it('should return false on network error', async () => {
const fakeKey = createFakeJwt(MOCK_KEY_PAYLOAD);
setupEnterpriseKey(fakeKey);
mockCryptoVerify.mockReturnValue(true);
fetchMock.mockRejectedValue(new Error('Network error'));
const result = await service.refreshValidityToken();
expect(result).toBe(false);
});
});
describe('reportSeats', () => {
it('should return false when no enterprise key is configured', async () => {
setupEnterpriseKey(undefined);
const result = await service.reportSeats(10);
expect(result).toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});
it('should return false when key is not a valid signed JWT', async () => {
setupEnterpriseKey('not-a-valid-jwt');
appTokenFindOneMock.mockResolvedValue(null);
await service.onModuleInit();
const result = await service.reportSeats(10);
expect(result).toBe(false);
});
it('should report seats and return true on success', async () => {
await setupValidState();
fetchMock.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ success: true }),
});
const result = await service.reportSeats(25);
expect(result).toBe(true);
expect(fetchMock).toHaveBeenCalledWith(
`${MOCK_API_URL}/seats`,
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}),
);
const callBody = JSON.parse(
(fetchMock.mock.calls[0] as [string, { body: string }])[1].body,
);
expect(callBody.seatCount).toBe(25);
});
it('should return false when API returns non-OK response', async () => {
await setupValidState();
fetchMock.mockResolvedValue({ ok: false, status: 500 });
const result = await service.reportSeats(10);
expect(result).toBe(false);
});
it('should return false on network error', async () => {
await setupValidState();
fetchMock.mockRejectedValue(new Error('Connection refused'));
const result = await service.reportSeats(10);
expect(result).toBe(false);
});
});
describe('getSubscriptionStatus', () => {
it('should return null when no enterprise key is configured', async () => {
setupEnterpriseKey(undefined);
const result = await service.getSubscriptionStatus();
expect(result).toBeNull();
});
it('should return subscription status on success', async () => {
await setupValidState();
const cancelAtTimestamp = Math.floor(Date.now() / 1000) + 86400;
const periodEndTimestamp = Math.floor(Date.now() / 1000) + 2592000;
fetchMock.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
status: 'active',
cancelAt: cancelAtTimestamp,
currentPeriodEnd: periodEndTimestamp,
isCancellationScheduled: false,
}),
});
const result = await service.getSubscriptionStatus();
expect(result).toEqual({
status: 'active',
licensee: 'ACME Corp',
expiresAt: new Date(FUTURE_TIMESTAMP * 1000),
cancelAt: new Date(cancelAtTimestamp * 1000),
currentPeriodEnd: new Date(periodEndTimestamp * 1000),
isCancellationScheduled: false,
});
});
it('should return null when API returns non-OK response', async () => {
await setupValidState();
fetchMock.mockResolvedValue({ ok: false, status: 500 });
const result = await service.getSubscriptionStatus();
expect(result).toBeNull();
});
it('should return null on network error', async () => {
await setupValidState();
fetchMock.mockRejectedValue(new Error('Network error'));
const result = await service.getSubscriptionStatus();
expect(result).toBeNull();
});
it('should handle null cancelAt and currentPeriodEnd', async () => {
await setupValidState();
fetchMock.mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
status: 'active',
cancelAt: null,
currentPeriodEnd: null,
}),
});
const result = await service.getSubscriptionStatus();
expect(result?.cancelAt).toBeNull();
expect(result?.currentPeriodEnd).toBeNull();
expect(result?.isCancellationScheduled).toBe(false);
});
});
describe('getPortalUrl', () => {
it('should return null when no API URL is configured', async () => {
configGetMock.mockReturnValue(undefined);
const result = await service.getPortalUrl();
expect(result).toBeNull();
});
it('should return null when no valid enterprise key exists', async () => {
setupEnterpriseKey(undefined);
const result = await service.getPortalUrl();
expect(result).toBeNull();
});
it('should return portal URL on success', async () => {
await setupValidState();
fetchMock.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ url: 'https://portal.example.com' }),
});
const result = await service.getPortalUrl('https://return.example.com');
expect(result).toBe('https://portal.example.com');
expect(fetchMock).toHaveBeenCalledWith(
`${MOCK_API_URL}/portal`,
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}),
);
});
it('should return null when API fails', async () => {
await setupValidState();
fetchMock.mockResolvedValue({ ok: false, status: 500 });
const result = await service.getPortalUrl();
expect(result).toBeNull();
});
it('should return null on network error', async () => {
await setupValidState();
fetchMock.mockRejectedValue(new Error('Network error'));
const result = await service.getPortalUrl();
expect(result).toBeNull();
});
});
describe('getCheckoutUrl', () => {
it('should return null when no API URL is configured', async () => {
configGetMock.mockReturnValue(undefined);
const result = await service.getCheckoutUrl('monthly', 5);
expect(result).toBeNull();
});
it('should return checkout URL on success', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ url: 'https://checkout.example.com' }),
});
const result = await service.getCheckoutUrl('yearly', 10);
expect(result).toBe('https://checkout.example.com');
expect(fetchMock).toHaveBeenCalledWith(`${MOCK_API_URL}/checkout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ billingInterval: 'yearly', seatCount: 10 }),
});
});
it('should return null when API fails', async () => {
fetchMock.mockResolvedValue({ ok: false, status: 500 });
const result = await service.getCheckoutUrl('monthly', 5);
expect(result).toBeNull();
});
it('should return null on network error', async () => {
fetchMock.mockRejectedValue(new Error('Network error'));
const result = await service.getCheckoutUrl('monthly', 5);
expect(result).toBeNull();
});
it('should return null when API response has no url', async () => {
fetchMock.mockResolvedValue({
ok: true,
json: () => Promise.resolve({}),
});
const result = await service.getCheckoutUrl('monthly', 5);
expect(result).toBeNull();
});
});
});
@@ -0,0 +1,532 @@
/* @license Enterprise */
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import * as crypto from 'crypto';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, Repository } from 'typeorm';
import {
AppTokenEntity,
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import {
ENTERPRISE_JWT_DEV_PUBLIC_KEY,
ENTERPRISE_JWT_PUBLIC_KEY,
} from 'src/engine/core-modules/enterprise/constants/enterprise-public-key.constant';
import {
type EnterpriseKeyPayload,
type EnterpriseLicenseInfo,
type EnterpriseValidityPayload,
} from 'src/engine/core-modules/enterprise/types/enterprise-key-payload.type';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
import {
ConfigVariableException,
ConfigVariableExceptionCode,
} from 'src/engine/core-modules/twenty-config/twenty-config.exception';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
export class EnterprisePlanService implements OnModuleInit {
private readonly logger = new Logger(EnterprisePlanService.name);
private cachedValidityPayload: EnterpriseValidityPayload | null = null;
private cachedKeyPayload: EnterpriseKeyPayload | null = null;
constructor(
private readonly twentyConfigService: TwentyConfigService,
@InjectRepository(AppTokenEntity)
private readonly appTokenRepository: Repository<AppTokenEntity>,
) {}
async onModuleInit() {
this.refreshKeyPayload();
await this.loadValidityToken();
}
private refreshKeyPayload(): void {
const enterpriseKey = this.twentyConfigService.get('ENTERPRISE_KEY');
if (!enterpriseKey) {
this.cachedKeyPayload = null;
return;
}
const payload = this.verifyJwt<EnterpriseKeyPayload>(enterpriseKey);
this.cachedKeyPayload = payload;
}
private async loadValidityToken(): Promise<void> {
try {
const dbToken = await this.appTokenRepository.findOne({
where: {
type: AppTokenType.EnterpriseValidityToken,
userId: IsNull(),
workspaceId: IsNull(),
revokedAt: IsNull(),
},
order: { createdAt: 'DESC' },
});
const tokenValue =
dbToken?.value ??
this.twentyConfigService.get('ENTERPRISE_VALIDITY_TOKEN');
if (!tokenValue) {
this.cachedValidityPayload = null;
return;
}
const payload = this.verifyJwt<EnterpriseValidityPayload>(tokenValue);
if (payload && payload.status === 'valid') {
this.cachedValidityPayload = payload;
} else {
this.cachedValidityPayload = null;
}
} catch (error) {
this.logger.warn(
`Failed to load validity token: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
this.cachedValidityPayload = null;
}
}
private async saveNewValidityTokenToDb(token: string): Promise<void> {
const payload = this.verifyJwt<EnterpriseValidityPayload>(token);
if (!isDefined(payload)) {
return;
}
await this.appTokenRepository.manager.transaction(
async (transactionalEntityManager) => {
await transactionalEntityManager.update(
this.appTokenRepository.target,
{
type: AppTokenType.EnterpriseValidityToken,
userId: IsNull(),
workspaceId: IsNull(),
revokedAt: IsNull(),
},
{ revokedAt: new Date() },
);
await transactionalEntityManager.save(this.appTokenRepository.target, {
type: AppTokenType.EnterpriseValidityToken,
value: token,
userId: null,
workspaceId: null,
expiresAt: new Date(payload.exp * 1000),
});
},
);
}
hasValidSignedEnterpriseKey(): boolean {
this.refreshKeyPayload();
return isDefined(this.cachedKeyPayload);
}
hasValidEnterpriseValidityToken(): boolean {
if (isDefined(this.cachedValidityPayload)) {
const now = Math.floor(Date.now() / 1000);
return this.cachedValidityPayload.exp > now;
}
return false;
}
hasValidEnterpriseKey(): boolean {
if (this.hasValidSignedEnterpriseKey()) {
return true;
}
return this.checkLegacyKey();
}
isValid(): boolean {
if (this.hasValidEnterpriseValidityToken()) {
return true;
}
return this.checkLegacyKey(); // temporary
}
isValidEnterpriseKeyFormat(key: string): boolean {
return this.verifyJwt<EnterpriseKeyPayload>(key) !== null;
}
private checkLegacyKey(): boolean {
const enterpriseKey = this.twentyConfigService.get('ENTERPRISE_KEY');
if (!isDefined(enterpriseKey)) {
return false;
}
this.logger.warn(
'Unsigned enterprise keys are deprecated and will stop working ' +
'in a future version. Please obtain a signed key from twenty.com.',
);
return true;
}
async getLicenseInfo(): Promise<EnterpriseLicenseInfo> {
this.refreshKeyPayload();
await this.loadValidityToken();
if (isDefined(this.cachedValidityPayload)) {
const now = Math.floor(Date.now() / 1000);
return {
isValid: this.cachedValidityPayload.exp > now,
licensee: this.cachedKeyPayload?.licensee ?? null,
expiresAt: new Date(this.cachedValidityPayload.exp * 1000),
subscriptionId: this.cachedValidityPayload.sub,
};
}
if (this.checkLegacyKey()) {
return {
isValid: true,
licensee: null,
expiresAt: null,
subscriptionId: null,
};
}
return {
isValid: false,
licensee: null,
expiresAt: null,
subscriptionId: null,
};
}
async setEnterpriseKey(enterpriseKey: string): Promise<void> {
try {
await this.twentyConfigService.set('ENTERPRISE_KEY', enterpriseKey);
} catch (error) {
if (
error instanceof ConfigVariableException &&
error.code === ConfigVariableExceptionCode.DATABASE_CONFIG_DISABLED
) {
throw new ConfigVariableException(
'IS_CONFIG_VARIABLES_IN_DB_ENABLED is false on your server. ' +
'Please add ENTERPRISE_KEY to your .env file manually.',
ConfigVariableExceptionCode.DATABASE_CONFIG_DISABLED,
);
}
throw error;
}
}
async refreshValidityToken(): Promise<boolean> {
const enterpriseKey = this.twentyConfigService.get('ENTERPRISE_KEY');
if (!enterpriseKey) {
this.logger.warn('No ENTERPRISE_KEY configured, skipping refresh');
return false;
}
this.refreshKeyPayload();
if (!isDefined(this.cachedKeyPayload)) {
this.logger.warn(
'ENTERPRISE_KEY is not a valid signed JWT, skipping refresh',
);
return false;
}
const apiUrl = this.twentyConfigService.get('ENTERPRISE_API_URL');
const validateUrl = `${apiUrl}/validate`;
try {
const response = await fetch(validateUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enterpriseKey }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
this.logger.warn(
`Enterprise refresh failed with status ${response.status}: ${errorData.error ?? 'Unknown error'}`,
);
return false;
}
const data = await response.json();
if (!data.validityToken) {
this.logger.warn('Enterprise refresh response missing validityToken');
return false;
}
await this.saveNewValidityTokenToDb(data.validityToken);
await this.loadValidityToken();
this.logger.log('Enterprise validity token refreshed successfully');
return true;
} catch (error) {
this.logger.warn(
`Enterprise refresh failed: ${error instanceof Error ? error.message : 'Network error'}. Current validity token will continue to work until expiration.`,
);
return false;
}
}
async reportSeats(seatCount: number): Promise<boolean> {
const enterpriseKey = this.twentyConfigService.get('ENTERPRISE_KEY');
if (!enterpriseKey) {
return false;
}
if (!isDefined(this.cachedKeyPayload)) {
return false;
}
const apiUrl = this.twentyConfigService.get('ENTERPRISE_API_URL');
const seatsUrl = `${apiUrl}/seats`;
try {
const response = await fetch(seatsUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enterpriseKey, seatCount }),
});
if (!response.ok) {
this.logger.warn(
`Seat reporting failed with status ${response.status}`,
);
return false;
}
this.logger.log(`Reported ${seatCount} seats to enterprise API`);
return true;
} catch (error) {
this.logger.warn(
`Seat reporting failed: ${error instanceof Error ? error.message : 'Network error'}`,
);
return false;
}
}
async getSubscriptionStatus(): Promise<{
status: string;
licensee: string | null;
expiresAt: Date | null;
cancelAt: Date | null;
currentPeriodEnd: Date | null;
isCancellationScheduled: boolean;
} | null> {
this.refreshKeyPayload();
const enterpriseKey = this.twentyConfigService.get('ENTERPRISE_KEY');
if (!enterpriseKey || !isDefined(this.cachedKeyPayload)) {
return null;
}
const licenseInfo = await this.getLicenseInfo();
const apiUrl = this.twentyConfigService.get('ENTERPRISE_API_URL');
const statusUrl = `${apiUrl}/status`;
try {
const response = await fetch(statusUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enterpriseKey }),
});
if (!response.ok) {
this.logger.warn(
`Enterprise status request failed with status ${response.status}`,
);
return null;
}
const data = await response.json();
return {
status: data.status,
licensee: licenseInfo.licensee,
expiresAt: licenseInfo.expiresAt,
cancelAt: data.cancelAt ? new Date(data.cancelAt * 1000) : null,
currentPeriodEnd: data.currentPeriodEnd
? new Date(data.currentPeriodEnd * 1000)
: null,
isCancellationScheduled: data.isCancellationScheduled ?? false,
};
} catch (error) {
this.logger.warn(
`Enterprise status request failed: ${error instanceof Error ? error.message : 'Network error'}`,
);
return null;
}
}
async getPortalUrl(returnUrl?: string): Promise<string | null> {
const apiUrl = this.twentyConfigService.get('ENTERPRISE_API_URL');
if (!apiUrl) {
return null;
}
this.refreshKeyPayload();
const enterpriseKey = this.twentyConfigService.get('ENTERPRISE_KEY');
if (enterpriseKey && isDefined(this.cachedKeyPayload)) {
return this.requestPortalUrlWithKey(apiUrl, enterpriseKey, returnUrl);
}
return null;
}
private async requestPortalUrlWithKey(
apiUrl: string,
enterpriseKey: string,
returnUrl?: string,
): Promise<string | null> {
const portalUrl = `${apiUrl}/portal`;
try {
const response = await fetch(portalUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enterpriseKey, returnUrl }),
});
if (!response.ok) {
this.logger.warn(
`Enterprise portal request failed with status ${response.status}`,
);
return null;
}
const data = await response.json();
return data.url ?? null;
} catch (error) {
this.logger.warn(
`Enterprise portal request failed: ${error instanceof Error ? error.message : 'Network error'}`,
);
return null;
}
}
async getCheckoutUrl(
billingInterval: 'monthly' | 'yearly' = 'monthly',
seatCount: number,
): Promise<string | null> {
const apiUrl = this.twentyConfigService.get('ENTERPRISE_API_URL');
if (!apiUrl) {
return null;
}
const checkoutUrl = `${apiUrl}/checkout`;
try {
const response = await fetch(checkoutUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ billingInterval, seatCount }),
});
if (!response.ok) {
this.logger.warn(
`Enterprise checkout request failed with status ${response.status}`,
);
return null;
}
const data = await response.json();
return data.url ?? null;
} catch (error) {
this.logger.warn(
`Enterprise checkout request failed: ${error instanceof Error ? error.message : 'Network error'}`,
);
return null;
}
}
private getPublicKey(): string {
const nodeEnv = this.twentyConfigService.get('NODE_ENV');
return nodeEnv === NodeEnvironment.DEVELOPMENT
? ENTERPRISE_JWT_DEV_PUBLIC_KEY
: ENTERPRISE_JWT_PUBLIC_KEY;
}
private verifyJwt<T extends Record<string, unknown>>(
token: string,
): T | null {
try {
const parts = token.split('.');
if (parts.length !== 3) {
return null;
}
const [encodedHeader, encodedPayload, signature] = parts;
const signingInput = `${encodedHeader}.${encodedPayload}`;
const signatureBuffer = Buffer.from(
signature.replace(/-/g, '+').replace(/_/g, '/') +
'='.repeat((4 - (signature.length % 4)) % 4),
'base64',
);
const isValid = crypto.verify(
'sha256',
Buffer.from(signingInput),
{
key: this.getPublicKey(),
padding: crypto.constants.RSA_PKCS1_PADDING,
},
signatureBuffer,
);
if (!isValid) {
return null;
}
const payloadStr = Buffer.from(
encodedPayload.replace(/-/g, '+').replace(/_/g, '/') +
'='.repeat((4 - (encodedPayload.length % 4)) % 4),
'base64',
).toString('utf-8');
return JSON.parse(payloadStr) as T;
} catch {
return null;
}
}
}
@@ -0,0 +1,19 @@
export type EnterpriseKeyPayload = {
sub: string;
licensee: string;
iat: number;
};
export type EnterpriseValidityPayload = {
sub: string;
status: 'valid';
iat: number;
exp: number;
};
export type EnterpriseLicenseInfo = {
isValid: boolean;
licensee: string | null;
expiresAt: Date | null;
subscriptionId: string | null;
};
@@ -5,6 +5,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { ClickHouseModule } from 'src/database/clickHouse/clickHouse.module';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module';
import { GuardRedirectModule } from 'src/engine/core-modules/guard-redirect/guard-redirect.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@@ -17,6 +18,7 @@ import { EventLogsService } from './event-logs.service';
ClickHouseModule,
PermissionsModule,
BillingModule,
EnterpriseModule,
GuardRedirectModule,
TypeOrmModule.forFeature([UserWorkspaceEntity]),
],
@@ -11,18 +11,20 @@ import { UpdateSubscriptionQuantityJob } from 'src/engine/core-modules/billing/j
import { StripeModule } from 'src/engine/core-modules/billing/stripe/stripe.module';
import { EmailSenderJob } from 'src/engine/core-modules/email/email-sender.job';
import { EmailModule } from 'src/engine/core-modules/email/email.module';
import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { UpdateWorkspaceMemberEmailJob } from 'src/engine/core-modules/user/jobs/update-workspace-member-email.job';
import { UserVarsModule } from 'src/engine/core-modules/user/user-vars/user-vars.module';
import { UserModule } from 'src/engine/core-modules/user/user.module';
import { WebhookJobModule } from 'src/engine/metadata-modules/webhook/jobs/webhook-job.module';
import { HandleWorkspaceMemberDeletedJob } from 'src/engine/core-modules/workspace/handle-workspace-member-deleted.job';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
import { AiAgentMonitorModule } from 'src/engine/metadata-modules/ai/ai-agent-monitor/ai-agent-monitor.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/logic-function.module';
import { NavigationMenuItemModule } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { WebhookJobModule } from 'src/engine/metadata-modules/webhook/jobs/webhook-job.module';
import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module';
import { CleanOnboardingWorkspacesJob } from 'src/engine/workspace-manager/workspace-cleaner/crons/clean-onboarding-workspaces.job';
import { CleanSuspendedWorkspacesJob } from 'src/engine/workspace-manager/workspace-cleaner/crons/clean-suspended-workspaces.job';
@@ -32,7 +34,6 @@ import { CalendarEventParticipantManagerModule } from 'src/modules/calendar/cale
import { CalendarModule } from 'src/modules/calendar/calendar.module';
import { AutoCompaniesAndContactsCreationJobModule } from 'src/modules/contact-creation-manager/jobs/auto-companies-and-contacts-creation-job.module';
import { FavoriteModule } from 'src/modules/favorite/favorite.module';
import { NavigationMenuItemModule } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module';
import { MessagingModule } from 'src/modules/messaging/messaging.module';
import { TimelineJobModule } from 'src/modules/timeline/jobs/timeline-job.module';
import { TimelineActivityModule } from 'src/modules/timeline/timeline-activity.module';
@@ -67,6 +68,7 @@ import { WorkflowModule } from 'src/modules/workflow/workflow.module';
AuditJobModule,
AiAgentMonitorModule,
LogicFunctionModule,
EnterpriseModule,
],
providers: [
CleanSuspendedWorkspacesJob,
@@ -6,6 +6,7 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { GuardRedirectModule } from 'src/engine/core-modules/guard-redirect/guard-redirect.module';
@@ -23,6 +24,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
FeatureFlagEntity,
]),
BillingModule,
EnterpriseModule,
GuardRedirectModule,
PermissionsModule,
FeatureFlagModule,
@@ -1536,6 +1536,24 @@ export class ConfigVariables {
@IsOptional()
ENTERPRISE_KEY: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.SERVER_CONFIG,
isSensitive: true,
description:
'Signed enterprise validity token (JWT). Used as fallback when no token is stored in the database.',
type: ConfigVariableType.STRING,
})
@IsOptional()
ENTERPRISE_VALIDITY_TOKEN: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.SERVER_CONFIG,
description: 'Base URL for the Enterprise API on twenty.com',
type: ConfigVariableType.STRING,
})
@IsOptional()
ENTERPRISE_API_URL: string = 'https://twenty.com/api/enterprise';
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.OTHER,
description: 'Health monitoring time window in minutes',
@@ -201,6 +201,10 @@ export class TwentyConfigService {
return this.get('TYPEORM_LOGGING');
}
isBillingEnabled(): boolean {
return this.get('IS_BILLING_ENABLED') === true;
}
private validateNotEnvOnly<T extends keyof ConfigVariables>(
key: T,
operation: string,
@@ -7,6 +7,7 @@ import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { ApprovedAccessDomainModule } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { OnboardingModule } from 'src/engine/core-modules/onboarding/onboarding.module';
@@ -49,6 +50,7 @@ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/works
TokenModule,
PermissionsModule,
OnboardingModule,
EnterpriseModule,
FeatureFlagModule,
],
services: [UserWorkspaceService],
@@ -572,4 +572,12 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
),
};
}
public async getActiveUserWorkspaceCountTotal(): Promise<number> {
const count = await this.userWorkspaceRepository.count({
where: { deletedAt: IsNull() },
});
return Math.max(1, count);
}
}
@@ -14,6 +14,7 @@ import { DnsManagerModule } from 'src/engine/core-modules/dns-manager/dns-manage
import { CustomDomainManagerModule } from 'src/engine/core-modules/domain/custom-domain-manager/custom-domain-manager.module';
import { SubdomainManagerModule } from 'src/engine/core-modules/domain/subdomain-manager/subdomain-manager.module';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
import { EnterpriseModule } from 'src/engine/core-modules/enterprise/enterprise.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
@@ -29,6 +30,7 @@ import { WorkspaceGaugeService } from 'src/engine/core-modules/workspace/workspa
import { workspaceAutoResolverOpts } from 'src/engine/core-modules/workspace/workspace.auto-resolver-opts';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceResolver } from 'src/engine/core-modules/workspace/workspace.resolver';
import { BillingDisabledGuard } from 'src/engine/guards/billing-disabled.guard';
import { AiAgentModule } from 'src/engine/metadata-modules/ai/ai-agent/ai-agent.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
@@ -76,6 +78,7 @@ import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-m
ViewModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
ApplicationModule,
EnterpriseModule,
],
services: [WorkspaceService],
resolvers: workspaceAutoResolverOpts,
@@ -86,6 +89,7 @@ import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-m
WorkspaceResolver,
WorkspaceService,
WorkspaceGaugeService,
BillingDisabledGuard,
CheckCustomDomainValidRecordsCronCommand,
CheckCustomDomainValidRecordsCronJob,
],
@@ -25,6 +25,7 @@ import { DomainValidRecords } from 'src/engine/core-modules/dns-manager/dtos/dom
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
import { CustomDomainManagerService } from 'src/engine/core-modules/domain/custom-domain-manager/services/custom-domain-manager.service';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { EnterprisePlanService } from 'src/engine/core-modules/enterprise/services/enterprise-plan.service';
import { FeatureFlagDTO } from 'src/engine/core-modules/feature-flag/dtos/feature-flag.dto';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
@@ -97,6 +98,7 @@ export class WorkspaceResolver {
private readonly dnsManagerService: DnsManagerService,
private readonly customDomainManagerService: CustomDomainManagerService,
private readonly applicationService: ApplicationService,
private readonly enterprisePlanService: EnterprisePlanService,
) {}
@Query(() => WorkspaceEntity)
@@ -174,7 +176,7 @@ export class WorkspaceResolver {
async billingSubscriptions(
@Parent() workspace: WorkspaceEntity,
): Promise<BillingSubscriptionEntity[] | undefined> {
if (!this.twentyConfigService.get('IS_BILLING_ENABLED')) {
if (!this.twentyConfigService.isBillingEnabled()) {
return [];
}
@@ -272,7 +274,7 @@ export class WorkspaceResolver {
async currentBillingSubscription(
@Parent() workspace: WorkspaceEntity,
): Promise<BillingSubscriptionEntity | undefined> {
if (!this.twentyConfigService.get('IS_BILLING_ENABLED')) {
if (!this.twentyConfigService.isBillingEnabled()) {
return;
}
@@ -310,7 +312,17 @@ export class WorkspaceResolver {
@ResolveField(() => Boolean)
hasValidEnterpriseKey(): boolean {
return isDefined(this.twentyConfigService.get('ENTERPRISE_KEY'));
return this.enterprisePlanService.hasValidEnterpriseKey();
}
@ResolveField(() => Boolean)
hasValidSignedEnterpriseKey(): boolean {
return this.enterprisePlanService.hasValidSignedEnterpriseKey();
}
@ResolveField(() => Boolean)
hasValidEnterpriseValidityToken(): boolean {
return this.enterprisePlanService.hasValidEnterpriseValidityToken();
}
@ResolveField(() => WorkspaceUrlsDTO)