From 8a4bcd14453eaf2ca25329ea3d9abb11b02aa06b Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:07:03 +0200 Subject: [PATCH] (Billing for self hosts) Tie enterprise key to server (#22464) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 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. metadata_3 - 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. --- .../src/metadata/generated/schema.graphql | 2 + .../src/metadata/generated/schema.ts | 4 + .../src/metadata/generated/types.ts | 6 + .../src/generated-metadata/graphql.ts | 8 + .../client-config/hooks/useClientConfig.ts | 11 + .../states/enterpriseInstanceTypeState.ts | 11 + .../client-config/types/ClientConfig.ts | 2 + .../components/InformationBannerWrapper.tsx | 2 + ...InformationBannerNonProductionInstance.tsx | 25 + .../releaseEnterpriseServerBinding.ts | 12 + .../enterprise/SettingsEnterprise.tsx | 309 +++++++++- packages/twenty-server/.env.example | 1 + .../client-config.controller.spec.ts | 2 + .../client-config/client-config.entity.ts | 3 + .../services/client-config.service.spec.ts | 4 +- .../services/client-config.service.ts | 4 + .../enterprise-key-validation.cron.job.ts | 19 +- .../enterprise/enterprise-exception.filter.ts | 12 +- .../enterprise/enterprise.exception.ts | 18 + .../enterprise/enterprise.resolver.ts | 59 +- .../services/enterprise-plan.service.ts | 162 ++++- .../types/enterprise-key-payload.type.ts | 3 + .../twenty-config/config-variables.ts | 22 +- .../src/constants/EnterpriseInstanceType.ts | 7 + packages/twenty-shared/src/constants/index.ts | 2 + packages/twenty-website/package.json | 2 + .../src/app/api/enterprise/reissue/route.ts | 25 +- .../src/app/api/enterprise/release/route.ts | 104 ++++ .../src/app/api/enterprise/seats/route.ts | 49 +- .../src/app/api/enterprise/status/route.ts | 20 +- .../src/app/api/enterprise/validate/route.ts | 97 ++- .../enterprise/enterprise-instance-type.ts | 7 + .../enterprise/enterprise-rate-limit-code.ts | 6 + .../enterprise/evaluate-release-rate-limit.ts | 32 + .../evaluate-sliding-window-rate-limit.ts | 63 ++ ...uate-validity-token-emission-rate-limit.ts | 35 ++ .../enterprise/get-auto-release-days.ts | 17 + .../enterprise/get-enterprise-config-error.ts | 32 + .../get-release-limit-per-window.ts | 17 + ...alidity-token-emission-limit-per-window.ts | 17 + .../src/platform/enterprise/index.ts | 37 ++ .../enterprise/is-billable-seat-reporter.ts | 24 + .../enterprise/normalize-server-id.ts | 12 + .../enterprise/parse-instance-type.ts | 10 + .../enterprise/resolve-server-binding.test.ts | 569 ++++++++++++++++++ .../enterprise/resolve-server-binding.ts | 174 ++++++ .../enterprise/server-binding-outcome.ts | 7 + .../server-binding-rejection-code.ts | 11 + .../enterprise/sign-validity-token.test.ts | 4 +- .../enterprise/sign-validity-token.ts | 2 +- .../enterprise/stripe-metadata-key.ts | 9 + .../platform/enterprise/stripe-metadata.ts | 1 + .../validity-token-emissions-key.ts | 11 + yarn.lock | 2 + 54 files changed, 2028 insertions(+), 78 deletions(-) create mode 100644 packages/twenty-front/src/modules/client-config/states/enterpriseInstanceTypeState.ts create mode 100644 packages/twenty-front/src/modules/information-banner/components/enterprise/InformationBannerNonProductionInstance.tsx create mode 100644 packages/twenty-front/src/modules/settings/enterprise/graphql/mutations/releaseEnterpriseServerBinding.ts create mode 100644 packages/twenty-shared/src/constants/EnterpriseInstanceType.ts create mode 100644 packages/twenty-website/src/app/api/enterprise/release/route.ts create mode 100644 packages/twenty-website/src/platform/enterprise/enterprise-instance-type.ts create mode 100644 packages/twenty-website/src/platform/enterprise/enterprise-rate-limit-code.ts create mode 100644 packages/twenty-website/src/platform/enterprise/evaluate-release-rate-limit.ts create mode 100644 packages/twenty-website/src/platform/enterprise/evaluate-sliding-window-rate-limit.ts create mode 100644 packages/twenty-website/src/platform/enterprise/evaluate-validity-token-emission-rate-limit.ts create mode 100644 packages/twenty-website/src/platform/enterprise/get-auto-release-days.ts create mode 100644 packages/twenty-website/src/platform/enterprise/get-enterprise-config-error.ts create mode 100644 packages/twenty-website/src/platform/enterprise/get-release-limit-per-window.ts create mode 100644 packages/twenty-website/src/platform/enterprise/get-validity-token-emission-limit-per-window.ts create mode 100644 packages/twenty-website/src/platform/enterprise/is-billable-seat-reporter.ts create mode 100644 packages/twenty-website/src/platform/enterprise/normalize-server-id.ts create mode 100644 packages/twenty-website/src/platform/enterprise/parse-instance-type.ts create mode 100644 packages/twenty-website/src/platform/enterprise/resolve-server-binding.test.ts create mode 100644 packages/twenty-website/src/platform/enterprise/resolve-server-binding.ts create mode 100644 packages/twenty-website/src/platform/enterprise/server-binding-outcome.ts create mode 100644 packages/twenty-website/src/platform/enterprise/server-binding-rejection-code.ts create mode 100644 packages/twenty-website/src/platform/enterprise/stripe-metadata-key.ts create mode 100644 packages/twenty-website/src/platform/enterprise/stripe-metadata.ts create mode 100644 packages/twenty-website/src/platform/enterprise/validity-token-emissions-key.ts diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index 7ff190d3f3..664e1b11a6 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -1961,6 +1961,7 @@ type ClientConfig { isCloudflareIntegrationEnabled: Boolean! isClickHouseConfigured: Boolean! isWorkspaceSchemaDDLLocked: Boolean! + enterpriseInstanceType: String! maintenance: ClientConfigMaintenanceMode } @@ -3301,6 +3302,7 @@ type Mutation { createFileUpload(filename: String!, size: Float!, fileFolder: FileFolder!, fieldMetadataId: String, fieldMetadataUniversalIdentifier: String): FileUploadTarget! completeFileUpload(fileId: String!): FileWithSignedUrl! refreshEnterpriseValidityToken: Boolean! + releaseEnterpriseServerBinding: EnterpriseLicenseInfoDTO! setEnterpriseKey(enterpriseKey: String!): EnterpriseLicenseInfoDTO! uploadEmailAttachmentFile(file: Upload!): FileWithSignedUrl! uploadAiChatFile(file: Upload!): FileWithSignedUrl! diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 9a99374479..047a9bb090 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -1598,6 +1598,7 @@ export interface ClientConfig { isCloudflareIntegrationEnabled: Scalars['Boolean'] isClickHouseConfigured: Scalars['Boolean'] isWorkspaceSchemaDDLLocked: Scalars['Boolean'] + enterpriseInstanceType: Scalars['String'] maintenance?: ClientConfigMaintenanceMode __typename: 'ClientConfig' } @@ -2833,6 +2834,7 @@ export interface Mutation { createFileUpload: FileUploadTarget completeFileUpload: FileWithSignedUrl refreshEnterpriseValidityToken: Scalars['Boolean'] + releaseEnterpriseServerBinding: EnterpriseLicenseInfoDTO setEnterpriseKey: EnterpriseLicenseInfoDTO uploadEmailAttachmentFile: FileWithSignedUrl uploadAiChatFile: FileWithSignedUrl @@ -4713,6 +4715,7 @@ export interface ClientConfigGenqlSelection{ isCloudflareIntegrationEnabled?: boolean | number isClickHouseConfigured?: boolean | number isWorkspaceSchemaDDLLocked?: boolean | number + enterpriseInstanceType?: boolean | number maintenance?: ClientConfigMaintenanceModeGenqlSelection __typename?: boolean | number __scalar?: boolean | number @@ -6078,6 +6081,7 @@ export interface MutationGenqlSelection{ createFileUpload?: (FileUploadTargetGenqlSelection & { __args: {filename: Scalars['String'], size: Scalars['Float'], fileFolder: FileFolder, fieldMetadataId?: (Scalars['String'] | null), fieldMetadataUniversalIdentifier?: (Scalars['String'] | null)} }) completeFileUpload?: (FileWithSignedUrlGenqlSelection & { __args: {fileId: Scalars['String']} }) refreshEnterpriseValidityToken?: boolean | number + releaseEnterpriseServerBinding?: EnterpriseLicenseInfoDTOGenqlSelection setEnterpriseKey?: (EnterpriseLicenseInfoDTOGenqlSelection & { __args: {enterpriseKey: Scalars['String']} }) uploadEmailAttachmentFile?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} }) uploadAiChatFile?: (FileWithSignedUrlGenqlSelection & { __args: {file: Scalars['Upload']} }) diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index 4696143775..601533039d 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -3821,6 +3821,9 @@ export default { "isWorkspaceSchemaDDLLocked": [ 6 ], + "enterpriseInstanceType": [ + 1 + ], "maintenance": [ 195 ], @@ -7146,6 +7149,9 @@ export default { "refreshEnterpriseValidityToken": [ 6 ], + "releaseEnterpriseServerBinding": [ + 125 + ], "setEnterpriseKey": [ 125, { diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 7316c2184c..549d0b33f7 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -924,6 +924,7 @@ export type ClientConfig = { canManageFeatureFlags: Scalars['Boolean']['output']; captcha: Captcha; defaultSubdomain?: Maybe; + enterpriseInstanceType: Scalars['String']['output']; frontDomain: Scalars['String']['output']; isAttachmentPreviewEnabled: Scalars['Boolean']['output']; isClickHouseConfigured: Scalars['Boolean']['output']; @@ -2641,6 +2642,7 @@ export type Mutation = { /** @deprecated Use installApplication instead */ installMarketplaceApp: Scalars['Boolean']['output']; refreshEnterpriseValidityToken: Scalars['Boolean']['output']; + releaseEnterpriseServerBinding: EnterpriseLicenseInfoDto; removeQueryFromEventStream: Scalars['Boolean']['output']; removeRoleFromAgent: Scalars['Boolean']['output']; renameChatThread: AgentChatThread; @@ -8080,6 +8082,11 @@ export type RefreshEnterpriseValidityTokenMutationVariables = Exact<{ [key: stri export type RefreshEnterpriseValidityTokenMutation = { __typename?: 'Mutation', refreshEnterpriseValidityToken: boolean }; +export type ReleaseEnterpriseServerBindingMutationVariables = Exact<{ [key: string]: never; }>; + + +export type ReleaseEnterpriseServerBindingMutation = { __typename?: 'Mutation', releaseEnterpriseServerBinding: { __typename?: 'EnterpriseLicenseInfoDTO', isValid: boolean, licensee?: string | null, expiresAt?: string | null, subscriptionId?: string | null } }; + export type SetEnterpriseKeyMutationVariables = Exact<{ enterpriseKey: Scalars['String']['input']; }>; @@ -9016,6 +9023,7 @@ export const DeleteEmailingDomainDocument = {"kind":"Document","definitions":[{" export const VerifyEmailingDomainDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"VerifyEmailingDomain"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"verifyEmailingDomain"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"verifiedAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode; export const GetEmailingDomainsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetEmailingDomains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getEmailingDomains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"verifiedAt"}},{"kind":"Field","name":{"kind":"Name","value":"verificationRecords"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}},{"kind":"Field","name":{"kind":"Name","value":"priority"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode; export const RefreshEnterpriseValidityTokenDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RefreshEnterpriseValidityToken"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"refreshEnterpriseValidityToken"}}]}}]} as unknown as DocumentNode; +export const ReleaseEnterpriseServerBindingDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ReleaseEnterpriseServerBinding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"releaseEnterpriseServerBinding"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"isValid"}},{"kind":"Field","name":{"kind":"Name","value":"licensee"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}},{"kind":"Field","name":{"kind":"Name","value":"subscriptionId"}}]}}]}}]} as unknown as DocumentNode; export const SetEnterpriseKeyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SetEnterpriseKey"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"enterpriseKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"setEnterpriseKey"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"enterpriseKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"enterpriseKey"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"isValid"}},{"kind":"Field","name":{"kind":"Name","value":"licensee"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}},{"kind":"Field","name":{"kind":"Name","value":"subscriptionId"}}]}}]}}]} as unknown as DocumentNode; export const EnterpriseCheckoutSessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EnterpriseCheckoutSession"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"billingInterval"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"enterpriseCheckoutSession"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"billingInterval"},"value":{"kind":"Variable","name":{"kind":"Name","value":"billingInterval"}}}]}]}}]} as unknown as DocumentNode; export const EnterprisePortalSessionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"EnterprisePortalSession"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"returnUrlPath"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"enterprisePortalSession"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"returnUrlPath"},"value":{"kind":"Variable","name":{"kind":"Name","value":"returnUrlPath"}}}]}]}}]} as unknown as DocumentNode; diff --git a/packages/twenty-front/src/modules/client-config/hooks/useClientConfig.ts b/packages/twenty-front/src/modules/client-config/hooks/useClientConfig.ts index 9214f979e3..8bc1e25d54 100644 --- a/packages/twenty-front/src/modules/client-config/hooks/useClientConfig.ts +++ b/packages/twenty-front/src/modules/client-config/hooks/useClientConfig.ts @@ -14,6 +14,7 @@ import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/i import { isClickHouseConfiguredState } from '@/client-config/states/isClickHouseConfiguredState'; import { isCloudflareIntegrationEnabledState } from '@/client-config/states/isCloudflareIntegrationEnabledState'; import { isDDLLockedState } from '@/client-config/states/isDDLLockedState'; +import { enterpriseInstanceTypeState } from '@/client-config/states/enterpriseInstanceTypeState'; import { isEmailingDomainInDemoModeState } from '@/client-config/states/isEmailingDomainInDemoModeState'; import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState'; import { isGoogleCalendarEnabledState } from '@/client-config/states/isGoogleCalendarEnabledState'; @@ -34,6 +35,7 @@ import { getClientConfig } from '@/client-config/utils/getClientConfig'; import { allowRequestsToTwentyIconsState } from '@/client-config/states/allowRequestsToTwentyIcons'; import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; +import { ENTERPRISE_INSTANCE_TYPE } from 'twenty-shared/constants'; type UseClientConfigResult = { data: { clientConfig: ClientConfig } | undefined; @@ -126,6 +128,10 @@ export const useClientConfig = (): UseClientConfigResult => { const setMaintenanceMode = useSetAtomState(maintenanceModeState); + const setEnterpriseInstanceType = useSetAtomState( + enterpriseInstanceTypeState, + ); + const setAppVersion = useSetAtomState(appVersionState); const fetchClientConfig = useCallback(async () => { @@ -210,6 +216,10 @@ export const useClientConfig = (): UseClientConfigResult => { setIsClickHouseConfigured(clientConfig?.isClickHouseConfigured ?? false); setIsDDLLocked(clientConfig?.isWorkspaceSchemaDDLLocked ?? false); setMaintenanceMode(clientConfig?.maintenance ?? null); + setEnterpriseInstanceType( + clientConfig?.enterpriseInstanceType ?? + ENTERPRISE_INSTANCE_TYPE.PRODUCTION, + ); } catch (err) { const error = err instanceof Error ? err : new Error('Failed to fetch client config'); @@ -248,6 +258,7 @@ export const useClientConfig = (): UseClientConfigResult => { setIsDDLLocked, setLabPublicFeatureFlags, setMaintenanceMode, + setEnterpriseInstanceType, setIsMicrosoftCalendarEnabled, setIsMicrosoftMessagingEnabled, setSentryConfig, diff --git a/packages/twenty-front/src/modules/client-config/states/enterpriseInstanceTypeState.ts b/packages/twenty-front/src/modules/client-config/states/enterpriseInstanceTypeState.ts new file mode 100644 index 0000000000..63e6945e25 --- /dev/null +++ b/packages/twenty-front/src/modules/client-config/states/enterpriseInstanceTypeState.ts @@ -0,0 +1,11 @@ +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; +import { + ENTERPRISE_INSTANCE_TYPE, + type EnterpriseInstanceType, +} from 'twenty-shared/constants'; + +export const enterpriseInstanceTypeState = + createAtomState({ + key: 'enterpriseInstanceTypeState', + defaultValue: ENTERPRISE_INSTANCE_TYPE.PRODUCTION, + }); diff --git a/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts b/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts index c251fb16ae..33ce41b14f 100644 --- a/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts +++ b/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts @@ -9,6 +9,7 @@ import { type Sentry, type Support, } from '~/generated-metadata/graphql'; +import { type EnterpriseInstanceType } from 'twenty-shared/constants'; import { type OnboardingConfig } from '@/client-config/types/OnboardingConfig'; export type ClientConfig = { @@ -45,4 +46,5 @@ export type ClientConfig = { isTwoFactorAuthenticationEnabled: boolean; allowRequestsToTwentyIcons: boolean; maintenance?: ClientConfigMaintenanceMode; + enterpriseInstanceType?: EnterpriseInstanceType; }; diff --git a/packages/twenty-front/src/modules/information-banner/components/InformationBannerWrapper.tsx b/packages/twenty-front/src/modules/information-banner/components/InformationBannerWrapper.tsx index c9280f904d..9700ed8099 100644 --- a/packages/twenty-front/src/modules/information-banner/components/InformationBannerWrapper.tsx +++ b/packages/twenty-front/src/modules/information-banner/components/InformationBannerWrapper.tsx @@ -3,6 +3,7 @@ import { isDefined } from 'twenty-shared/utils'; import { WorkspaceActivationStatus } from 'twenty-shared/workspace'; import { InformationBannerBillingSubscriptionPaused } from '@/information-banner/components/billing/InformationBannerBillingSubscriptionPaused'; +import { InformationBannerNonProductionInstance } from '@/information-banner/components/enterprise/InformationBannerNonProductionInstance'; import { InformationBannerEndTrialPeriod } from '@/information-banner/components/billing/InformationBannerEndTrialPeriod'; import { InformationBannerFailPaymentInfo } from '@/information-banner/components/billing/InformationBannerFailPaymentInfo'; import { InformationBannerNoBillingSubscription } from '@/information-banner/components/billing/InformationBannerNoBillingSubscription'; @@ -63,6 +64,7 @@ export const InformationBannerWrapper = () => { return ( + {isAccountSyncEnabled && ( diff --git a/packages/twenty-front/src/modules/information-banner/components/enterprise/InformationBannerNonProductionInstance.tsx b/packages/twenty-front/src/modules/information-banner/components/enterprise/InformationBannerNonProductionInstance.tsx new file mode 100644 index 0000000000..38fb92cd2c --- /dev/null +++ b/packages/twenty-front/src/modules/information-banner/components/enterprise/InformationBannerNonProductionInstance.tsx @@ -0,0 +1,25 @@ +import { enterpriseInstanceTypeState } from '@/client-config/states/enterpriseInstanceTypeState'; +import { InformationBanner } from '@/information-banner/components/InformationBanner'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { t } from '@lingui/core/macro'; +import { ENTERPRISE_INSTANCE_TYPE } from 'twenty-shared/constants'; +import { isDefined } from 'twenty-shared/utils'; + +export const InformationBannerNonProductionInstance = () => { + const enterpriseInstanceType = useAtomStateValue(enterpriseInstanceTypeState); + + if ( + !isDefined(enterpriseInstanceType) || + enterpriseInstanceType === ENTERPRISE_INSTANCE_TYPE.PRODUCTION + ) { + return null; + } + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/enterprise/graphql/mutations/releaseEnterpriseServerBinding.ts b/packages/twenty-front/src/modules/settings/enterprise/graphql/mutations/releaseEnterpriseServerBinding.ts new file mode 100644 index 0000000000..1dda3cc4c2 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/enterprise/graphql/mutations/releaseEnterpriseServerBinding.ts @@ -0,0 +1,12 @@ +import { gql } from '@apollo/client'; + +export const RELEASE_ENTERPRISE_SERVER_BINDING = gql` + mutation ReleaseEnterpriseServerBinding { + releaseEnterpriseServerBinding { + isValid + licensee + expiresAt + subscriptionId + } + } +`; diff --git a/packages/twenty-front/src/pages/settings/enterprise/SettingsEnterprise.tsx b/packages/twenty-front/src/pages/settings/enterprise/SettingsEnterprise.tsx index 8b2f8f75b5..ba198b8d5e 100644 --- a/packages/twenty-front/src/pages/settings/enterprise/SettingsEnterprise.tsx +++ b/packages/twenty-front/src/pages/settings/enterprise/SettingsEnterprise.tsx @@ -2,25 +2,34 @@ import { Trans, useLingui } from '@lingui/react/macro'; import { useCallback, useEffect, useState } from 'react'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { useApolloAdminClient } from '@/settings/admin-panel/apollo/hooks/useApolloAdminClient'; +import { GET_DATABASE_CONFIG_VARIABLE } from '@/settings/admin-panel/config-variables/graphql/queries/getDatabaseConfigVariable'; +import { useConfigVariableActions } from '@/settings/admin-panel/config-variables/hooks/useConfigVariableActions'; import { SubscriptionInfoContainer } from '@/settings/billing/components/SubscriptionInfoContainer'; import { SubscriptionInfoRowContainer } from '@/settings/billing/components/internal/SubscriptionInfoRowContainer'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; +import { SettingsPageLayout } from '@/settings/components/layout/SettingsPageLayout'; import { ENTERPRISE_PLAN_MODAL_ID, EnterprisePlanModal, } from '@/settings/enterprise/components/EnterprisePlanModal'; import { REFRESH_ENTERPRISE_VALIDITY_TOKEN } from '@/settings/enterprise/graphql/mutations/refreshEnterpriseValidityToken'; +import { RELEASE_ENTERPRISE_SERVER_BINDING } from '@/settings/enterprise/graphql/mutations/releaseEnterpriseServerBinding'; import { SET_ENTERPRISE_KEY } from '@/settings/enterprise/graphql/mutations/setEnterpriseKey'; import { ENTERPRISE_PORTAL_SESSION } from '@/settings/enterprise/graphql/queries/enterprisePortalSession'; import { ENTERPRISE_SUBSCRIPTION_STATUS } from '@/settings/enterprise/graphql/queries/enterpriseSubscriptionStatus'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput'; +import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; import { useModal } from '@/ui/layout/modal/hooks/useModal'; -import { SettingsPageLayout } from '@/settings/components/layout/SettingsPageLayout'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { useLoadCurrentUser } from '@/users/hooks/useLoadCurrentUser'; import { useLazyQuery, useMutation } from '@apollo/client/react'; import { styled } from '@linaria/react'; +import { + ENTERPRISE_INSTANCE_TYPE, + type EnterpriseInstanceType, +} from 'twenty-shared/constants'; import { SettingsPath } from 'twenty-shared/types'; import { getSettingsPath, isDefined } from 'twenty-shared/utils'; import { @@ -31,12 +40,15 @@ import { IconKey, IconUser, } from 'twenty-ui/icon'; -import { H2Title } from 'twenty-ui/typography'; import { Button } from 'twenty-ui/input'; import { Section } from 'twenty-ui/layout'; import { themeCssVariables } from 'twenty-ui/theme-constants'; +import { H2Title } from 'twenty-ui/typography'; import { isGraphqlErrorOfType } from '~/utils/is-graphql-error-of-type.util'; +const RELEASE_ENTERPRISE_BINDING_CONFIRMATION_MODAL_ID = + 'release-enterprise-binding-confirmation-modal'; + type SettingsEnterpriseProps = { isAdminPanelTab?: boolean; }; @@ -108,14 +120,65 @@ export const SettingsEnterprise = ({ const [refreshValidityTokenMutation] = useMutation<{ refreshEnterpriseValidityToken: boolean; }>(REFRESH_ENTERPRISE_VALIDITY_TOKEN); + const [releaseServerBindingMutation] = useMutation<{ + releaseEnterpriseServerBinding: { + isValid: boolean; + licensee: string | null; + expiresAt: string | null; + subscriptionId: string | null; + }; + }>(RELEASE_ENTERPRISE_SERVER_BINDING); const [fetchPortalSession] = useLazyQuery<{ enterprisePortalSession: string | null; }>(ENTERPRISE_PORTAL_SESSION); const [isRefreshingToken, setIsRefreshingToken] = useState(false); + const [isReleasing, setIsReleasing] = useState(false); + const [isBoundToAnotherServer, setIsBoundToAnotherServer] = useState(false); const { openModal } = useModal(); const { enqueueErrorSnackBar, enqueueSuccessSnackBar } = useSnackBar(); const { loadCurrentUser } = useLoadCurrentUser(); + const apolloAdminClient = useApolloAdminClient(); + const { + handleUpdateVariable: updateInstanceTypeVariable, + handleDeleteVariable: deleteInstanceTypeVariable, + } = useConfigVariableActions('ENTERPRISE_INSTANCE_TYPE'); + const [instanceType, setInstanceType] = useState( + ENTERPRISE_INSTANCE_TYPE.PRODUCTION, + ); + const [isInstanceTypeFromDb, setIsInstanceTypeFromDb] = useState(false); + const [isUpdatingInstanceType, setIsUpdatingInstanceType] = useState(false); + + useEffect(() => { + const loadInstanceType = async () => { + try { + const { data } = await apolloAdminClient.query<{ + getDatabaseConfigVariable: { + value: unknown; + source: string; + } | null; + }>({ + query: GET_DATABASE_CONFIG_VARIABLE, + variables: { key: 'ENTERPRISE_INSTANCE_TYPE' }, + fetchPolicy: 'network-only', + }); + + const variable = data?.getDatabaseConfigVariable; + + setInstanceType( + variable?.value === ENTERPRISE_INSTANCE_TYPE.DEVELOPMENT + ? ENTERPRISE_INSTANCE_TYPE.DEVELOPMENT + : ENTERPRISE_INSTANCE_TYPE.PRODUCTION, + ); + setIsInstanceTypeFromDb(variable?.source === 'DATABASE'); + } catch { + // Best-effort: the instance-type control simply stays at its default. + } + }; + + loadInstanceType(); + }, [apolloAdminClient]); + const hasSignedEnterpriseKey = currentWorkspace?.hasValidSignedEnterpriseKey === true; const hasValidityToken = @@ -203,7 +266,27 @@ export const SettingsEnterprise = ({ }); } } catch (error) { - if (isGraphqlErrorOfType(error, 'CONFIG_VARIABLES_IN_DB_DISABLED')) { + const isServerBindingRejection = + isGraphqlErrorOfType(error, 'ENTERPRISE_KEY_BOUND_TO_ANOTHER_SERVER') || + isGraphqlErrorOfType(error, 'ENTERPRISE_MISSING_SERVER_ID') || + isGraphqlErrorOfType( + error, + 'ENTERPRISE_DEV_REQUIRES_ACTIVE_PRODUCTION', + ) || + isGraphqlErrorOfType(error, 'ENTERPRISE_DEV_SLOT_IN_USE'); + + if (isServerBindingRejection) { + setIsBoundToAnotherServer( + isGraphqlErrorOfType(error, 'ENTERPRISE_KEY_BOUND_TO_ANOTHER_SERVER'), + ); + await loadCurrentUser(); + enqueueErrorSnackBar({ + apolloError: error, + options: { duration: 10000 }, + }); + } else if ( + isGraphqlErrorOfType(error, 'CONFIG_VARIABLES_IN_DB_DISABLED') + ) { enqueueErrorSnackBar({ apolloError: error, options: { duration: 10000 }, @@ -263,6 +346,7 @@ export const SettingsEnterprise = ({ const { data } = await refreshValidityTokenMutation(); if (data?.refreshEnterpriseValidityToken === true) { + setIsBoundToAnotherServer(false); enqueueSuccessSnackBar({ message: t`Validity token refreshed successfully`, }); @@ -272,10 +356,34 @@ export const SettingsEnterprise = ({ message: t`Could not refresh validity token. Please contact support.`, }); } - } catch { - enqueueErrorSnackBar({ - message: t`Error refreshing validity token. Please contact support.`, - }); + } catch (error) { + if ( + isGraphqlErrorOfType(error, 'ENTERPRISE_KEY_BOUND_TO_ANOTHER_SERVER') + ) { + setIsBoundToAnotherServer(true); + await loadCurrentUser(); + enqueueErrorSnackBar({ + apolloError: error, + options: { duration: 10000 }, + }); + } else if ( + isGraphqlErrorOfType(error, 'ENTERPRISE_MISSING_SERVER_ID') || + isGraphqlErrorOfType( + error, + 'ENTERPRISE_DEV_REQUIRES_ACTIVE_PRODUCTION', + ) || + isGraphqlErrorOfType(error, 'ENTERPRISE_DEV_SLOT_IN_USE') || + isGraphqlErrorOfType(error, 'ENTERPRISE_VALIDITY_TOKEN_RATE_LIMITED') + ) { + enqueueErrorSnackBar({ + apolloError: error, + options: { duration: 10000 }, + }); + } else { + enqueueErrorSnackBar({ + message: t`Error refreshing validity token. Please contact support.`, + }); + } } finally { setIsRefreshingToken(false); } @@ -287,6 +395,117 @@ export const SettingsEnterprise = ({ t, ]); + const handleReleaseBinding = useCallback(async () => { + setIsReleasing(true); + + try { + const result = await releaseServerBindingMutation(); + + if (result.data?.releaseEnterpriseServerBinding.isValid === true) { + setIsBoundToAnotherServer(false); + enqueueSuccessSnackBar({ + message: t`Enterprise key transferred to this server`, + }); + const { data: statusData } = await fetchSubscriptionStatus(); + + setSubscriptionStatus(statusData?.enterpriseSubscriptionStatus ?? null); + await loadCurrentUser(); + } else { + enqueueErrorSnackBar({ + message: t`Could not transfer the enterprise key. Please contact support.`, + }); + } + } catch (error) { + if (isGraphqlErrorOfType(error, 'ENTERPRISE_RELEASE_RATE_LIMITED')) { + enqueueErrorSnackBar({ + message: t`You have reached the maximum number of server transfers allowed in the last 30 days for this enterprise key. Please try again later or contact support.`, + }); + } else { + enqueueErrorSnackBar({ + message: t`Error transferring the enterprise key`, + }); + } + } finally { + setIsReleasing(false); + } + }, [ + releaseServerBindingMutation, + enqueueSuccessSnackBar, + enqueueErrorSnackBar, + fetchSubscriptionStatus, + loadCurrentUser, + t, + ]); + + const handleSetInstanceType = useCallback( + async (nextInstanceType: EnterpriseInstanceType) => { + setIsUpdatingInstanceType(true); + const previousInstanceType = instanceType; + const previousIsInstanceTypeFromDb = isInstanceTypeFromDb; + let instanceUpdateSuccess = false; + let tokenRefreshSuccess = false; + + try { + await updateInstanceTypeVariable( + nextInstanceType, + isInstanceTypeFromDb, + ); + instanceUpdateSuccess = true; + setInstanceType(nextInstanceType); + setIsInstanceTypeFromDb(true); + await loadCurrentUser(); + + enqueueSuccessSnackBar({ + message: + nextInstanceType === ENTERPRISE_INSTANCE_TYPE.DEVELOPMENT + ? t`Registered as a development instance. This instance will not be billed.` + : t`Switched to a production instance.`, + }); + + await refreshValidityTokenMutation(); + tokenRefreshSuccess = true; + } catch { + if (!instanceUpdateSuccess) { + enqueueErrorSnackBar({ + message: t`Could not update the instance type`, + }); + } + } finally { + if (instanceUpdateSuccess && !tokenRefreshSuccess) { + try { + if (previousIsInstanceTypeFromDb) { + await updateInstanceTypeVariable(previousInstanceType, true); + } else { + await deleteInstanceTypeVariable(); + } + setInstanceType(previousInstanceType); + setIsInstanceTypeFromDb(previousIsInstanceTypeFromDb); + await loadCurrentUser(); + enqueueErrorSnackBar({ + message: t`Could not refresh validity token - reverted the instance type change.`, + }); + } catch { + enqueueErrorSnackBar({ + message: t`Could not refresh validity token and could not revert the instance type change.`, + }); + } + } + setIsUpdatingInstanceType(false); + } + }, + [ + instanceType, + updateInstanceTypeVariable, + deleteInstanceTypeVariable, + isInstanceTypeFromDb, + refreshValidityTokenMutation, + loadCurrentUser, + enqueueSuccessSnackBar, + enqueueErrorSnackBar, + t, + ], + ); + const activateKeySection = (
); + const transferSection = ( +
+ +
+ ); + + const instanceTypeSection = ( +
+ + {instanceType === ENTERPRISE_INSTANCE_TYPE.DEVELOPMENT ? ( +
+ ); + const renderContent = () => { if (!isStatusLoaded) { return null; @@ -412,6 +694,7 @@ export const SettingsEnterprise = ({ )}
+ {isBoundToAnotherServer && transferSection}
+ {renderContent()} + {hasEnterpriseLicense && instanceTypeSection} ); diff --git a/packages/twenty-server/.env.example b/packages/twenty-server/.env.example index 90c0fb7ea2..587730490e 100644 --- a/packages/twenty-server/.env.example +++ b/packages/twenty-server/.env.example @@ -74,6 +74,7 @@ FRONTEND_URL=http://localhost:3001 # MUTATION_MAXIMUM_AFFECTED_RECORDS=100 # PG_SSL_ALLOW_SELF_SIGNED=true # ENTERPRISE_KEY=replace_me_with_a_valid_enterprise_key +# SERVER_ID= # SSL_KEY_PATH="./certs/your-cert.key" # SSL_CERT_PATH="./certs/your-cert.crt" # CLOUDFLARE_API_KEY= diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts index 55dcaf3236..86dcfce567 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts @@ -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 diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts index f15c30668e..22f3e7113e 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts @@ -345,6 +345,9 @@ export class ClientConfig { @Field(() => Boolean) isWorkspaceSchemaDDLLocked: boolean; + @Field(() => String) + enterpriseInstanceType: string; + @Field(() => ClientConfigMaintenanceMode, { nullable: true }) maintenance?: ClientConfigMaintenanceMode; } diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts index d47c7cf63b..5ad07bd1d6 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts @@ -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, }); }); diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts index f6ccfd0cc4..ac735924b3 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts @@ -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 = diff --git a/packages/twenty-server/src/engine/core-modules/enterprise/cron/jobs/enterprise-key-validation.cron.job.ts b/packages/twenty-server/src/engine/core-modules/enterprise/cron/jobs/enterprise-key-validation.cron.job.ts index 42735c810f..6c99f375fa 100644 --- a/packages/twenty-server/src/engine/core-modules/enterprise/cron/jobs/enterprise-key-validation.cron.job.ts +++ b/packages/twenty-server/src/engine/core-modules/enterprise/cron/jobs/enterprise-key-validation.cron.job.ts @@ -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.', ); } diff --git a/packages/twenty-server/src/engine/core-modules/enterprise/enterprise-exception.filter.ts b/packages/twenty-server/src/engine/core-modules/enterprise/enterprise-exception.filter.ts index f8524aa6c1..3fecf1162b 100644 --- a/packages/twenty-server/src/engine/core-modules/enterprise/enterprise-exception.filter.ts +++ b/packages/twenty-server/src/engine/core-modules/enterprise/enterprise-exception.filter.ts @@ -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); } diff --git a/packages/twenty-server/src/engine/core-modules/enterprise/enterprise.exception.ts b/packages/twenty-server/src/engine/core-modules/enterprise/enterprise.exception.ts index 332e52446a..375b2128b0 100644 --- a/packages/twenty-server/src/engine/core-modules/enterprise/enterprise.exception.ts +++ b/packages/twenty-server/src/engine/core-modules/enterprise/enterprise.exception.ts @@ -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); } diff --git a/packages/twenty-server/src/engine/core-modules/enterprise/enterprise.resolver.ts b/packages/twenty-server/src/engine/core-modules/enterprise/enterprise.resolver.ts index 8d26b8929c..7127babced 100644 --- a/packages/twenty-server/src/engine/core-modules/enterprise/enterprise.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/enterprise/enterprise.resolver.ts @@ -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 { - return this.enterprisePlanService.refreshValidityToken(); + const refreshed = await this.enterprisePlanService.refreshValidityToken(); + + this.throwIfServerBindingRejected(); + + return refreshed; + } + + @Mutation(() => EnterpriseLicenseInfoDTO) + @UseGuards( + WorkspaceAuthGuard, + BillingDisabledGuard, + AdminPanelGuard, + NoPermissionGuard, + ) + async releaseEnterpriseServerBinding(): Promise { + 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); diff --git a/packages/twenty-server/src/engine/core-modules/enterprise/services/enterprise-plan.service.ts b/packages/twenty-server/src/engine/core-modules/enterprise/services/enterprise-plan.service.ts index 56f89f4c6c..a9c3da9f01 100644 --- a/packages/twenty-server/src/engine/core-modules/enterprise/services/enterprise-plan.service.ts +++ b/packages/twenty-server/src/engine/core-modules/enterprise/services/enterprise-plan.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { 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'); diff --git a/packages/twenty-server/src/engine/core-modules/enterprise/types/enterprise-key-payload.type.ts b/packages/twenty-server/src/engine/core-modules/enterprise/types/enterprise-key-payload.type.ts index f798aff24e..231081a712 100644 --- a/packages/twenty-server/src/engine/core-modules/enterprise/types/enterprise-key-payload.type.ts +++ b/packages/twenty-server/src/engine/core-modules/enterprise/types/enterprise-key-payload.type.ts @@ -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; diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index ab6092b449..f8430e9d8a 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -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', diff --git a/packages/twenty-shared/src/constants/EnterpriseInstanceType.ts b/packages/twenty-shared/src/constants/EnterpriseInstanceType.ts new file mode 100644 index 0000000000..6964a574d2 --- /dev/null +++ b/packages/twenty-shared/src/constants/EnterpriseInstanceType.ts @@ -0,0 +1,7 @@ +export const ENTERPRISE_INSTANCE_TYPE = { + PRODUCTION: 'production', + DEVELOPMENT: 'development', +} as const; + +export type EnterpriseInstanceType = + (typeof ENTERPRISE_INSTANCE_TYPE)[keyof typeof ENTERPRISE_INSTANCE_TYPE]; diff --git a/packages/twenty-shared/src/constants/index.ts b/packages/twenty-shared/src/constants/index.ts index ffe53cdb02..e1c1359b0a 100644 --- a/packages/twenty-shared/src/constants/index.ts +++ b/packages/twenty-shared/src/constants/index.ts @@ -29,6 +29,8 @@ export type { DocumentationPath } from './DocumentationPaths'; export { DOCUMENTATION_PATHS } from './DocumentationPaths'; export type { DocumentationSupportedLanguage } from './DocumentationSupportedLanguages'; export { DOCUMENTATION_SUPPORTED_LANGUAGES } from './DocumentationSupportedLanguages'; +export type { EnterpriseInstanceType } from './EnterpriseInstanceType'; +export { ENTERPRISE_INSTANCE_TYPE } from './EnterpriseInstanceType'; export { EXCLUDED_FIELD_NAMES_FROM_AGENT_TOOL_SCHEMA } from './ExcludedFieldNamesFromAgentToolSchema'; export { FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION } from './FieldForTotalCountAggregateOperation'; export { MAX_OPTIONS_TO_DISPLAY } from './FieldMetadataMaxOptionsToDisplay'; diff --git a/packages/twenty-website/package.json b/packages/twenty-website/package.json index 00e2ce7e10..b459a95a20 100644 --- a/packages/twenty-website/package.json +++ b/packages/twenty-website/package.json @@ -22,6 +22,7 @@ "@tabler/icons-react": "^3.41.1", "@wyw-in-js/babel-preset": "^0.8.1", "@wyw-in-js/transform": "^0.8.1", + "lodash.isempty": "^4.4.0", "next": "^16.2.6", "next-with-linaria": "^1.3.0", "react": "19.2.3", @@ -45,6 +46,7 @@ "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@types/lodash.isempty": "^4.4.7", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/packages/twenty-website/src/app/api/enterprise/reissue/route.ts b/packages/twenty-website/src/app/api/enterprise/reissue/route.ts index 442baecbee..845815cbcd 100644 --- a/packages/twenty-website/src/app/api/enterprise/reissue/route.ts +++ b/packages/twenty-website/src/app/api/enterprise/reissue/route.ts @@ -3,6 +3,7 @@ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; import { NextResponse } from 'next/server'; import { + getEnterpriseConfigError, getLicenseeFromStripeCustomer, getStripeClient, signEnterpriseKey, @@ -29,18 +30,18 @@ function isSecretValid(providedSecret: string): boolean { } export async function POST(request: Request) { - if ( - !process.env.STRIPE_SECRET_KEY || - !process.env.ENTERPRISE_JWT_PRIVATE_KEY || - !process.env.ENTERPRISE_ADMIN_API_SECRET - ) { - console.error( - '[enterprise-reissue] 503 — STRIPE_SECRET_KEY, ENTERPRISE_JWT_PRIVATE_KEY and/or ENTERPRISE_ADMIN_API_SECRET are not configured', - ); - return NextResponse.json( - { error: 'Enterprise key reissue is not configured.' }, - { status: 503 }, - ); + const configError = getEnterpriseConfigError({ + route: 'enterprise-reissue', + feature: 'Enterprise key reissue', + requiredEnvVars: [ + 'STRIPE_SECRET_KEY', + 'ENTERPRISE_JWT_PRIVATE_KEY', + 'ENTERPRISE_ADMIN_API_SECRET', + ], + }); + + if (configError) { + return configError; } try { diff --git a/packages/twenty-website/src/app/api/enterprise/release/route.ts b/packages/twenty-website/src/app/api/enterprise/release/route.ts new file mode 100644 index 0000000000..bab2b5a4bc --- /dev/null +++ b/packages/twenty-website/src/app/api/enterprise/release/route.ts @@ -0,0 +1,104 @@ +import { NextResponse } from 'next/server'; + +import { + ENTERPRISE_INSTANCE_TYPE, + ENTERPRISE_RATE_LIMIT_CODE, + evaluateReleaseRateLimit, + getEnterpriseConfigError, + getReleaseLimitPerWindow, + getStripeClient, + parseInstanceType, + STRIPE_METADATA_KEY, + verifyEnterpriseKey, +} from '@/platform/enterprise'; + +export const dynamic = 'force-dynamic'; + +type InstanceMetadata = { + instanceType?: string; +}; + +export async function POST(request: Request) { + const configError = getEnterpriseConfigError({ + route: 'enterprise-release', + feature: 'Enterprise binding release', + requiredEnvVars: ['STRIPE_SECRET_KEY', 'ENTERPRISE_JWT_PUBLIC_KEY'], + }); + + if (configError) { + return configError; + } + + try { + const body = (await request.json()) as { + enterpriseKey?: unknown; + instanceMetadata?: InstanceMetadata; + }; + const { enterpriseKey, instanceMetadata } = body; + + if (!enterpriseKey || typeof enterpriseKey !== 'string') { + return NextResponse.json( + { error: 'Missing enterpriseKey' }, + { status: 400 }, + ); + } + + const payload = verifyEnterpriseKey(enterpriseKey); + + if (!payload) { + return NextResponse.json( + { error: 'Invalid enterprise key' }, + { status: 403 }, + ); + } + + const stripe = getStripeClient(); + + const subscription = await stripe.subscriptions.retrieve(payload.sub); + + const rateLimit = evaluateReleaseRateLimit({ + stripeMetadata: subscription.metadata, + }); + + if (!rateLimit.allowed) { + return NextResponse.json( + { + error: `The release limit of ${getReleaseLimitPerWindow()} in the last 30 days has been reached for this enterprise key.`, + code: ENTERPRISE_RATE_LIMIT_CODE.RELEASE, + retryAfter: rateLimit.retryAfter.toISOString(), + }, + { status: 429 }, + ); + } + + const instanceType = parseInstanceType(instanceMetadata?.instanceType); + + const metadataPatch: Record = + instanceType === ENTERPRISE_INSTANCE_TYPE.DEVELOPMENT + ? { + [STRIPE_METADATA_KEY.DEV_SERVER_ID]: '', // removes the key on stripe metadata + [STRIPE_METADATA_KEY.DEV_SERVER_LAST_SEEN_AT]: '', + } + : { + [STRIPE_METADATA_KEY.BOUND_SERVER_ID]: '', + [STRIPE_METADATA_KEY.BOUND_SERVER_LAST_SEEN_AT]: '', + }; + + await stripe.subscriptions.update(payload.sub, { + metadata: { ...metadataPatch, ...rateLimit.metadataPatch }, + }); + + return NextResponse.json({ + success: true, + subscriptionId: payload.sub, + instanceType, + }); + } catch (error: unknown) { + console.error('Enterprise binding release failed', error); + + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 }, + ); + } +} diff --git a/packages/twenty-website/src/app/api/enterprise/seats/route.ts b/packages/twenty-website/src/app/api/enterprise/seats/route.ts index 12456305fa..f36de32a55 100644 --- a/packages/twenty-website/src/app/api/enterprise/seats/route.ts +++ b/packages/twenty-website/src/app/api/enterprise/seats/route.ts @@ -1,31 +1,38 @@ import { NextResponse } from 'next/server'; -import { getStripeClient, verifyEnterpriseKey } from '@/platform/enterprise'; +import { + getEnterpriseConfigError, + getStripeClient, + isBillableSeatReporter, + verifyEnterpriseKey, +} from '@/platform/enterprise'; export const dynamic = 'force-dynamic'; const NON_UPDATABLE_STATUSES = new Set(['canceled', 'incomplete_expired']); +type InstanceMetadata = { + serverId?: string; +}; + export async function POST(request: Request) { - if ( - !process.env.STRIPE_SECRET_KEY || - !process.env.ENTERPRISE_JWT_PUBLIC_KEY - ) { - console.error( - '[enterprise-seats] 503 — STRIPE_SECRET_KEY and/or ENTERPRISE_JWT_PUBLIC_KEY are not configured', - ); - return NextResponse.json( - { error: 'Enterprise seat management is not configured.' }, - { status: 503 }, - ); + const configError = getEnterpriseConfigError({ + route: 'enterprise-seats', + feature: 'Enterprise seat management', + requiredEnvVars: ['STRIPE_SECRET_KEY', 'ENTERPRISE_JWT_PUBLIC_KEY'], + }); + + if (configError) { + return configError; } try { const body = (await request.json()) as { enterpriseKey?: unknown; seatCount?: unknown; + instanceMetadata?: InstanceMetadata; }; - const { enterpriseKey, seatCount } = body; + const { enterpriseKey, seatCount, instanceMetadata } = body; if (!enterpriseKey || typeof enterpriseKey !== 'string') { return NextResponse.json( @@ -50,6 +57,22 @@ export async function POST(request: Request) { const stripe = getStripeClient(); const subscription = await stripe.subscriptions.retrieve(payload.sub); + const serverId = instanceMetadata?.serverId; + + if ( + !isBillableSeatReporter({ + stripeMetadata: subscription.metadata, + serverId, + }) + ) { + return NextResponse.json({ + success: false, + reason: 'This instance is not the billing instance for this key', + seatCount: subscription.items.data[0]?.quantity ?? 0, + subscriptionId: payload.sub, + }); + } + if ( NON_UPDATABLE_STATUSES.has(subscription.status) || subscription.cancel_at_period_end diff --git a/packages/twenty-website/src/app/api/enterprise/status/route.ts b/packages/twenty-website/src/app/api/enterprise/status/route.ts index 0e37c782cd..1a41b97b93 100644 --- a/packages/twenty-website/src/app/api/enterprise/status/route.ts +++ b/packages/twenty-website/src/app/api/enterprise/status/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from 'next/server'; import { + getEnterpriseConfigError, getStripeClient, getSubscriptionCurrentPeriodEnd, verifyEnterpriseKey, @@ -9,17 +10,14 @@ import { export const dynamic = 'force-dynamic'; export async function POST(request: Request) { - if ( - !process.env.STRIPE_SECRET_KEY || - !process.env.ENTERPRISE_JWT_PUBLIC_KEY - ) { - console.error( - '[enterprise-status] 503 — STRIPE_SECRET_KEY and/or ENTERPRISE_JWT_PUBLIC_KEY are not configured', - ); - return NextResponse.json( - { error: 'Enterprise status is not configured.' }, - { status: 503 }, - ); + const configError = getEnterpriseConfigError({ + route: 'enterprise-status', + feature: 'Enterprise status', + requiredEnvVars: ['STRIPE_SECRET_KEY', 'ENTERPRISE_JWT_PUBLIC_KEY'], + }); + + if (configError) { + return configError; } try { diff --git a/packages/twenty-website/src/app/api/enterprise/validate/route.ts b/packages/twenty-website/src/app/api/enterprise/validate/route.ts index 97ed5fe402..f0b26fb2ad 100644 --- a/packages/twenty-website/src/app/api/enterprise/validate/route.ts +++ b/packages/twenty-website/src/app/api/enterprise/validate/route.ts @@ -1,8 +1,16 @@ import { NextResponse } from 'next/server'; import { + ENTERPRISE_RATE_LIMIT_CODE, + EnterpriseInstanceType, + evaluateValidityTokenEmissionRateLimit, + getAutoReleaseDays, + getEnterpriseConfigError, getStripeClient, getSubscriptionCurrentPeriodEnd, + parseInstanceType, + resolveServerBinding, + SERVER_BINDING_OUTCOME, signValidityToken, verifyEnterpriseKey, } from '@/platform/enterprise'; @@ -11,24 +19,32 @@ export const dynamic = 'force-dynamic'; const ACTIVATABLE_STATUSES = new Set(['active', 'trialing']); +type InstanceMetadata = { + serverId?: string; + instanceType?: EnterpriseInstanceType; +}; + export async function POST(request: Request) { - if ( - !process.env.STRIPE_SECRET_KEY || - !process.env.ENTERPRISE_JWT_PUBLIC_KEY || - !process.env.ENTERPRISE_JWT_PRIVATE_KEY - ) { - console.error( - '[enterprise-validate] 503 — STRIPE_SECRET_KEY, ENTERPRISE_JWT_PUBLIC_KEY and/or ENTERPRISE_JWT_PRIVATE_KEY are not configured', - ); - return NextResponse.json( - { error: 'Enterprise validation is not configured.' }, - { status: 503 }, - ); + const configError = getEnterpriseConfigError({ + route: 'enterprise-validate', + feature: 'Enterprise validation', + requiredEnvVars: [ + 'STRIPE_SECRET_KEY', + 'ENTERPRISE_JWT_PUBLIC_KEY', + 'ENTERPRISE_JWT_PRIVATE_KEY', + ], + }); + + if (configError) { + return configError; } try { - const body = (await request.json()) as { enterpriseKey?: unknown }; - const { enterpriseKey } = body; + const body = (await request.json()) as { + enterpriseKey?: string; + instanceMetadata?: InstanceMetadata; + }; + const { enterpriseKey, instanceMetadata } = body; if (!enterpriseKey || typeof enterpriseKey !== 'string') { return NextResponse.json( @@ -56,6 +72,53 @@ export async function POST(request: Request) { ); } + const serverId = instanceMetadata?.serverId; + const instanceType = parseInstanceType(instanceMetadata?.instanceType); + + const binding = resolveServerBinding({ + stripeMetadata: subscription.metadata, + serverId, + instanceType, + autoReleaseDays: getAutoReleaseDays(), + }); + + if (binding.outcome === SERVER_BINDING_OUTCOME.REJECTED) { + return NextResponse.json( + { + error: binding.reason, + code: binding.code, + }, + { status: 403 }, + ); + } + + const emissionRateLimit = evaluateValidityTokenEmissionRateLimit({ + stripeMetadata: subscription.metadata, + instanceType, + }); + + if (!emissionRateLimit.allowed) { + return NextResponse.json( + { + error: 'Validity token emission rate limit exceeded', + code: ENTERPRISE_RATE_LIMIT_CODE.VALIDITY_TOKEN, + retryAfter: emissionRateLimit.retryAfter.toISOString(), + }, + { status: 429 }, + ); + } + + const metadataPatch = { + ...binding.metadataPatch, + ...emissionRateLimit.metadataPatch, + }; + + if (Object.keys(metadataPatch).length > 0) { + await stripe.subscriptions.update(payload.sub, { + metadata: metadataPatch, + }); + } + const rawCancelAt = subscription.cancel_at; const rawCancelAtPeriodEnd = subscription.cancel_at_period_end; const rawCurrentPeriodEnd = getSubscriptionCurrentPeriodEnd(subscription); @@ -74,12 +137,14 @@ export async function POST(request: Request) { licensee: payload.licensee, subscriptionId: payload.sub, subscriptionStatus: subscription.status, + instanceType, + isBillable: binding.isBillable, }); } catch (error: unknown) { - const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('Enterprise key validation failed', error); return NextResponse.json( - { error: `Validation error: ${message}` }, + { error: 'Internal server error' }, { status: 500 }, ); } diff --git a/packages/twenty-website/src/platform/enterprise/enterprise-instance-type.ts b/packages/twenty-website/src/platform/enterprise/enterprise-instance-type.ts new file mode 100644 index 0000000000..6964a574d2 --- /dev/null +++ b/packages/twenty-website/src/platform/enterprise/enterprise-instance-type.ts @@ -0,0 +1,7 @@ +export const ENTERPRISE_INSTANCE_TYPE = { + PRODUCTION: 'production', + DEVELOPMENT: 'development', +} as const; + +export type EnterpriseInstanceType = + (typeof ENTERPRISE_INSTANCE_TYPE)[keyof typeof ENTERPRISE_INSTANCE_TYPE]; diff --git a/packages/twenty-website/src/platform/enterprise/enterprise-rate-limit-code.ts b/packages/twenty-website/src/platform/enterprise/enterprise-rate-limit-code.ts new file mode 100644 index 0000000000..51ef87b193 --- /dev/null +++ b/packages/twenty-website/src/platform/enterprise/enterprise-rate-limit-code.ts @@ -0,0 +1,6 @@ +// Machine codes returned to clients when a rate limit is hit, so the +// self-hosted server and admin UI can surface the right message. +export const ENTERPRISE_RATE_LIMIT_CODE = { + RELEASE: 'ENTERPRISE_RELEASE_RATE_LIMITED', + VALIDITY_TOKEN: 'ENTERPRISE_VALIDITY_TOKEN_RATE_LIMITED', +} as const; diff --git a/packages/twenty-website/src/platform/enterprise/evaluate-release-rate-limit.ts b/packages/twenty-website/src/platform/enterprise/evaluate-release-rate-limit.ts new file mode 100644 index 0000000000..282b1a4af1 --- /dev/null +++ b/packages/twenty-website/src/platform/enterprise/evaluate-release-rate-limit.ts @@ -0,0 +1,32 @@ +import { + evaluateSlidingWindowRateLimit, + type RateLimitDecision, +} from './evaluate-sliding-window-rate-limit'; +import { getReleaseLimitPerWindow } from './get-release-limit-per-window'; +import { STRIPE_METADATA_KEY } from './stripe-metadata-key'; +import { type StripeMetadata } from './stripe-metadata'; + +export type ReleaseRateLimitDecision = RateLimitDecision; + +const SECONDS_PER_DAY = 24 * 60 * 60; +const RELEASE_RATE_WINDOW_DAYS = 30; + +export function evaluateReleaseRateLimit({ + stripeMetadata, + limit = getReleaseLimitPerWindow(), + windowDays = RELEASE_RATE_WINDOW_DAYS, + now = new Date(), +}: { + stripeMetadata: StripeMetadata; + limit?: number; + windowDays?: number; + now?: Date; +}): RateLimitDecision { + return evaluateSlidingWindowRateLimit({ + raw: stripeMetadata?.[STRIPE_METADATA_KEY.RELEASE_TIMESTAMPS], + metadataKey: STRIPE_METADATA_KEY.RELEASE_TIMESTAMPS, + limit, + windowMs: windowDays * SECONDS_PER_DAY * 1000, + now, + }); +} diff --git a/packages/twenty-website/src/platform/enterprise/evaluate-sliding-window-rate-limit.ts b/packages/twenty-website/src/platform/enterprise/evaluate-sliding-window-rate-limit.ts new file mode 100644 index 0000000000..936e5e5e3e --- /dev/null +++ b/packages/twenty-website/src/platform/enterprise/evaluate-sliding-window-rate-limit.ts @@ -0,0 +1,63 @@ +import { isDefined } from 'twenty-shared/utils'; + +export type RateLimitDecision = + | { allowed: true; metadataPatch: Record } + | { allowed: false; retryAfter: Date }; + +const parseRecentTimestamps = ( + raw: string | undefined, + now: Date, + windowMs: number, +): number[] => { + if (!isDefined(raw) || raw.length === 0) { + return []; + } + + const nowMs = now.getTime(); + const cutoffMs = nowMs - windowMs; + + return raw + .split(',') + .map((entry) => Number.parseInt(entry, 10)) + .filter( + (timestampMs) => + !Number.isNaN(timestampMs) && + timestampMs > cutoffMs && + timestampMs <= nowMs, + ) + .toSorted((a, b) => a - b); +}; + +export const evaluateSlidingWindowRateLimit = ({ + raw, + metadataKey, + limit, + windowMs, + now, +}: { + raw: string | undefined; + metadataKey: string; + limit: number; + windowMs: number; + now: Date; +}): RateLimitDecision => { + const recentTimestamps = parseRecentTimestamps(raw, now, windowMs); + + if (recentTimestamps.length >= limit) { + const oldestTimestampMs = recentTimestamps[0]; + + return { + allowed: false, + retryAfter: new Date(oldestTimestampMs + windowMs), + }; + } + + const updatedTimestamps = [...recentTimestamps, now.getTime()]; + + return { + allowed: true, + metadataPatch: { + [metadataKey]: updatedTimestamps.join(','), + }, + }; +}; diff --git a/packages/twenty-website/src/platform/enterprise/evaluate-validity-token-emission-rate-limit.ts b/packages/twenty-website/src/platform/enterprise/evaluate-validity-token-emission-rate-limit.ts new file mode 100644 index 0000000000..32e358391d --- /dev/null +++ b/packages/twenty-website/src/platform/enterprise/evaluate-validity-token-emission-rate-limit.ts @@ -0,0 +1,35 @@ +import { type EnterpriseInstanceType } from './enterprise-instance-type'; +import { + evaluateSlidingWindowRateLimit, + type RateLimitDecision, +} from './evaluate-sliding-window-rate-limit'; +import { getValidityTokenEmissionLimitPerWindow } from './get-validity-token-emission-limit-per-window'; +import { type StripeMetadata } from './stripe-metadata'; +import { VALIDITY_TOKEN_EMISSIONS_KEY_BY_INSTANCE_TYPE } from './validity-token-emissions-key'; + +const VALIDITY_TOKEN_EMISSION_WINDOW_HOURS = 24; + +export function evaluateValidityTokenEmissionRateLimit({ + stripeMetadata, + instanceType, + limit = getValidityTokenEmissionLimitPerWindow(), + windowHours = VALIDITY_TOKEN_EMISSION_WINDOW_HOURS, + now = new Date(), +}: { + stripeMetadata: StripeMetadata; + instanceType: EnterpriseInstanceType; + limit?: number; + windowHours?: number; + now?: Date; +}): RateLimitDecision { + const metadataKey = + VALIDITY_TOKEN_EMISSIONS_KEY_BY_INSTANCE_TYPE[instanceType]; + + return evaluateSlidingWindowRateLimit({ + raw: stripeMetadata?.[metadataKey], + metadataKey, + limit, + windowMs: windowHours * 60 * 60 * 1000, + now, + }); +} diff --git a/packages/twenty-website/src/platform/enterprise/get-auto-release-days.ts b/packages/twenty-website/src/platform/enterprise/get-auto-release-days.ts new file mode 100644 index 0000000000..78b84a3acf --- /dev/null +++ b/packages/twenty-website/src/platform/enterprise/get-auto-release-days.ts @@ -0,0 +1,17 @@ +const DEFAULT_AUTO_RELEASE_DAYS = 14; + +export function getAutoReleaseDays(): number { + const value = process.env.ENTERPRISE_AUTO_RELEASE_DAYS; + + if (value === undefined || value === '') { + return DEFAULT_AUTO_RELEASE_DAYS; + } + + const parsed = Number.parseInt(value, 10); + + if (Number.isNaN(parsed) || parsed < 1) { + return DEFAULT_AUTO_RELEASE_DAYS; + } + + return parsed; +} diff --git a/packages/twenty-website/src/platform/enterprise/get-enterprise-config-error.ts b/packages/twenty-website/src/platform/enterprise/get-enterprise-config-error.ts new file mode 100644 index 0000000000..2f0a67ddef --- /dev/null +++ b/packages/twenty-website/src/platform/enterprise/get-enterprise-config-error.ts @@ -0,0 +1,32 @@ +import { NextResponse } from 'next/server'; + +type EnterpriseConfigCheck = { + route: string; + feature: string; + requiredEnvVars: string[]; +}; + +export function getEnterpriseConfigError({ + route, + feature, + requiredEnvVars, +}: EnterpriseConfigCheck): NextResponse | null { + const missingEnvVars = requiredEnvVars.filter( + (envVarName) => !process.env[envVarName], + ); + + if (missingEnvVars.length === 0) { + return null; + } + + console.error( + `[${route}] 503 — ${missingEnvVars.join(', ')} ${ + missingEnvVars.length === 1 ? 'is' : 'are' + } not configured`, + ); + + return NextResponse.json( + { error: `${feature} is not configured.` }, + { status: 503 }, + ); +} diff --git a/packages/twenty-website/src/platform/enterprise/get-release-limit-per-window.ts b/packages/twenty-website/src/platform/enterprise/get-release-limit-per-window.ts new file mode 100644 index 0000000000..d93b3924f7 --- /dev/null +++ b/packages/twenty-website/src/platform/enterprise/get-release-limit-per-window.ts @@ -0,0 +1,17 @@ +const DEFAULT_RELEASE_LIMIT_PER_WINDOW = 10; + +export function getReleaseLimitPerWindow(): number { + const value = process.env.ENTERPRISE_RELEASE_LIMIT_PER_WINDOW; + + if (value === undefined || value === '') { + return DEFAULT_RELEASE_LIMIT_PER_WINDOW; + } + + const parsed = Number.parseInt(value, 10); + + if (Number.isNaN(parsed) || parsed < 1) { + return DEFAULT_RELEASE_LIMIT_PER_WINDOW; + } + + return parsed; +} diff --git a/packages/twenty-website/src/platform/enterprise/get-validity-token-emission-limit-per-window.ts b/packages/twenty-website/src/platform/enterprise/get-validity-token-emission-limit-per-window.ts new file mode 100644 index 0000000000..c9fbd2b5d5 --- /dev/null +++ b/packages/twenty-website/src/platform/enterprise/get-validity-token-emission-limit-per-window.ts @@ -0,0 +1,17 @@ +const DEFAULT_VALIDITY_TOKEN_EMISSIONS_PER_WINDOW = 2; + +export function getValidityTokenEmissionLimitPerWindow(): number { + const value = process.env.ENTERPRISE_VALIDITY_TOKEN_EMISSIONS_PER_DAY; + + if (value === undefined || value === '') { + return DEFAULT_VALIDITY_TOKEN_EMISSIONS_PER_WINDOW; + } + + const parsed = Number.parseInt(value, 10); + + if (Number.isNaN(parsed) || parsed < 1) { + return DEFAULT_VALIDITY_TOKEN_EMISSIONS_PER_WINDOW; + } + + return parsed; +} diff --git a/packages/twenty-website/src/platform/enterprise/index.ts b/packages/twenty-website/src/platform/enterprise/index.ts index 6f4a13585d..2a15f82e4c 100644 --- a/packages/twenty-website/src/platform/enterprise/index.ts +++ b/packages/twenty-website/src/platform/enterprise/index.ts @@ -1,7 +1,44 @@ +export { getEnterpriseConfigError } from './get-enterprise-config-error'; export { getEnterprisePriceId } from './enterprise-price-id'; export { getLicenseeFromStripeCustomer } from './get-licensee-from-stripe-customer'; export { getStripeClient } from './stripe-client'; export { getSubscriptionCurrentPeriodEnd } from './subscription-current-period-end'; +export { + ENTERPRISE_INSTANCE_TYPE, + type EnterpriseInstanceType, +} from './enterprise-instance-type'; +export { + SERVER_BINDING_OUTCOME, + type ServerBindingOutcome, +} from './server-binding-outcome'; +export { + SERVER_BINDING_REJECTION_CODE, + type ServerBindingRejectionCode, +} from './server-binding-rejection-code'; +export { STRIPE_METADATA_KEY } from './stripe-metadata-key'; +export { VALIDITY_TOKEN_EMISSIONS_KEY_BY_INSTANCE_TYPE } from './validity-token-emissions-key'; +export { ENTERPRISE_RATE_LIMIT_CODE } from './enterprise-rate-limit-code'; +export { type StripeMetadata } from './stripe-metadata'; +export { getAutoReleaseDays } from './get-auto-release-days'; +export { getReleaseLimitPerWindow } from './get-release-limit-per-window'; +export { getValidityTokenEmissionLimitPerWindow } from './get-validity-token-emission-limit-per-window'; +export { + evaluateSlidingWindowRateLimit, + type RateLimitDecision, +} from './evaluate-sliding-window-rate-limit'; +export { + evaluateReleaseRateLimit, + type ReleaseRateLimitDecision, +} from './evaluate-release-rate-limit'; +export { evaluateValidityTokenEmissionRateLimit } from './evaluate-validity-token-emission-rate-limit'; +export { normalizeServerId } from './normalize-server-id'; +export { isBillableSeatReporter } from './is-billable-seat-reporter'; +export { parseInstanceType } from './parse-instance-type'; +export { + resolveServerBinding, + type ResolveServerBindingInput, + type ServerBindingDecision, +} from './resolve-server-binding'; export { signEnterpriseKey } from './sign-enterprise-key'; export { signValidityToken } from './sign-validity-token'; export { verifyEnterpriseKey } from './verify-enterprise-key'; diff --git a/packages/twenty-website/src/platform/enterprise/is-billable-seat-reporter.ts b/packages/twenty-website/src/platform/enterprise/is-billable-seat-reporter.ts new file mode 100644 index 0000000000..731e31fb3d --- /dev/null +++ b/packages/twenty-website/src/platform/enterprise/is-billable-seat-reporter.ts @@ -0,0 +1,24 @@ +import isEmpty from 'lodash.isempty'; + +import { isDefined } from 'twenty-shared/utils'; + +import { normalizeServerId } from './normalize-server-id'; +import { STRIPE_METADATA_KEY } from './stripe-metadata-key'; +import { type StripeMetadata } from './stripe-metadata'; + +export function isBillableSeatReporter({ + stripeMetadata, + serverId, +}: { + stripeMetadata: StripeMetadata; + serverId?: string; +}): boolean { + const normalizedServerId = normalizeServerId(serverId); + const boundServerId = stripeMetadata?.[STRIPE_METADATA_KEY.BOUND_SERVER_ID]; + + if (isEmpty(boundServerId)) { + return !isDefined(normalizedServerId); + } + + return normalizedServerId === boundServerId; +} diff --git a/packages/twenty-website/src/platform/enterprise/normalize-server-id.ts b/packages/twenty-website/src/platform/enterprise/normalize-server-id.ts new file mode 100644 index 0000000000..2651c78ad0 --- /dev/null +++ b/packages/twenty-website/src/platform/enterprise/normalize-server-id.ts @@ -0,0 +1,12 @@ +// A server identifier only counts if it is a non-empty, non-whitespace string. +// Clients can send '' or non-string runtime values, which must not be allowed +// to claim or reuse a key (that would bypass single-server enforcement). +export const normalizeServerId = (value: unknown): string | undefined => { + if (typeof value !== 'string') { + return undefined; + } + + const trimmed = value.trim(); + + return trimmed.length === 0 ? undefined : trimmed; +}; diff --git a/packages/twenty-website/src/platform/enterprise/parse-instance-type.ts b/packages/twenty-website/src/platform/enterprise/parse-instance-type.ts new file mode 100644 index 0000000000..5ccf00d523 --- /dev/null +++ b/packages/twenty-website/src/platform/enterprise/parse-instance-type.ts @@ -0,0 +1,10 @@ +import { + ENTERPRISE_INSTANCE_TYPE, + type EnterpriseInstanceType, +} from './enterprise-instance-type'; + +export function parseInstanceType(value?: string): EnterpriseInstanceType { + return value === ENTERPRISE_INSTANCE_TYPE.DEVELOPMENT + ? ENTERPRISE_INSTANCE_TYPE.DEVELOPMENT + : ENTERPRISE_INSTANCE_TYPE.PRODUCTION; +} diff --git a/packages/twenty-website/src/platform/enterprise/resolve-server-binding.test.ts b/packages/twenty-website/src/platform/enterprise/resolve-server-binding.test.ts new file mode 100644 index 0000000000..bc6c46fdc2 --- /dev/null +++ b/packages/twenty-website/src/platform/enterprise/resolve-server-binding.test.ts @@ -0,0 +1,569 @@ +import { + evaluateReleaseRateLimit, + evaluateValidityTokenEmissionRateLimit, + isBillableSeatReporter, + parseInstanceType, + resolveServerBinding, + STRIPE_METADATA_KEY, + VALIDITY_TOKEN_EMISSIONS_KEY_BY_INSTANCE_TYPE, +} from '.'; + +const NOW = new Date('2026-06-30T12:00:00.000Z'); +const AUTO_RELEASE_DAYS = 14; + +const daysAgoIso = (days: number): string => + new Date(NOW.getTime() - days * 24 * 60 * 60 * 1000).toISOString(); + +describe('resolveServerBinding', () => { + it('claims a free production slot and persists the binding in stripe', () => { + const decision = resolveServerBinding({ + stripeMetadata: {}, + serverId: 'server-a', + instanceType: 'production', + autoReleaseDays: AUTO_RELEASE_DAYS, + now: NOW, + }); + + expect(decision).toEqual({ + outcome: 'allowed', + isBillable: true, + metadataPatch: { + [STRIPE_METADATA_KEY.BOUND_SERVER_ID]: 'server-a', + [STRIPE_METADATA_KEY.BOUND_SERVER_LAST_SEEN_AT]: NOW.toISOString(), + }, + }); + }); + + it('allows the already-bound production server (refreshes lastSeenAt)', () => { + const decision = resolveServerBinding({ + stripeMetadata: { + [STRIPE_METADATA_KEY.BOUND_SERVER_ID]: 'server-a', + [STRIPE_METADATA_KEY.BOUND_SERVER_LAST_SEEN_AT]: daysAgoIso(1), + }, + serverId: 'server-a', + instanceType: 'production', + autoReleaseDays: AUTO_RELEASE_DAYS, + now: NOW, + }); + + expect(decision.outcome).toBe('allowed'); + if (decision.outcome === 'allowed') { + expect(decision.isBillable).toBe(true); + expect( + decision.metadataPatch[STRIPE_METADATA_KEY.BOUND_SERVER_LAST_SEEN_AT], + ).toBe(NOW.toISOString()); + } + }); + + it('rejects a foreign production server while the binding is fresh', () => { + const decision = resolveServerBinding({ + stripeMetadata: { + [STRIPE_METADATA_KEY.BOUND_SERVER_ID]: 'server-a', + [STRIPE_METADATA_KEY.BOUND_SERVER_LAST_SEEN_AT]: daysAgoIso(1), + }, + serverId: 'server-b', + instanceType: 'production', + autoReleaseDays: AUTO_RELEASE_DAYS, + now: NOW, + }); + + expect(decision.outcome).toBe('rejected'); + if (decision.outcome === 'rejected') { + expect(decision.code).toBe('ENTERPRISE_KEY_BOUND_TO_ANOTHER_SERVER'); + } + }); + + it('auto-releases a stale binding to a new production server', () => { + const decision = resolveServerBinding({ + stripeMetadata: { + [STRIPE_METADATA_KEY.BOUND_SERVER_ID]: 'server-a', + [STRIPE_METADATA_KEY.BOUND_SERVER_LAST_SEEN_AT]: daysAgoIso( + AUTO_RELEASE_DAYS + 1, + ), + }, + serverId: 'server-b', + instanceType: 'production', + autoReleaseDays: AUTO_RELEASE_DAYS, + now: NOW, + }); + + expect(decision.outcome).toBe('allowed'); + if (decision.outcome === 'allowed') { + expect(decision.metadataPatch[STRIPE_METADATA_KEY.BOUND_SERVER_ID]).toBe( + 'server-b', + ); + } + }); + + it('binds a development instance into the dev slot as non-billable', () => { + const decision = resolveServerBinding({ + stripeMetadata: { + [STRIPE_METADATA_KEY.BOUND_SERVER_ID]: 'server-a', + [STRIPE_METADATA_KEY.BOUND_SERVER_LAST_SEEN_AT]: daysAgoIso(1), + }, + serverId: 'server-dev', + instanceType: 'development', + autoReleaseDays: AUTO_RELEASE_DAYS, + now: NOW, + }); + + expect(decision).toEqual({ + outcome: 'allowed', + isBillable: false, + metadataPatch: { + [STRIPE_METADATA_KEY.DEV_SERVER_ID]: 'server-dev', + [STRIPE_METADATA_KEY.DEV_SERVER_LAST_SEEN_AT]: NOW.toISOString(), + }, + }); + }); + + it('rejects a second development instance while the dev slot is fresh', () => { + const decision = resolveServerBinding({ + stripeMetadata: { + [STRIPE_METADATA_KEY.BOUND_SERVER_ID]: 'server-a', + [STRIPE_METADATA_KEY.BOUND_SERVER_LAST_SEEN_AT]: daysAgoIso(1), + [STRIPE_METADATA_KEY.DEV_SERVER_ID]: 'server-dev', + [STRIPE_METADATA_KEY.DEV_SERVER_LAST_SEEN_AT]: daysAgoIso(1), + }, + serverId: 'server-dev-2', + instanceType: 'development', + autoReleaseDays: AUTO_RELEASE_DAYS, + now: NOW, + }); + + expect(decision.outcome).toBe('rejected'); + if (decision.outcome === 'rejected') { + expect(decision.code).toBe('ENTERPRISE_DEV_SLOT_IN_USE'); + } + }); + + it('auto-releases a stale dev slot to a new development server', () => { + const decision = resolveServerBinding({ + stripeMetadata: { + [STRIPE_METADATA_KEY.BOUND_SERVER_ID]: 'server-a', + [STRIPE_METADATA_KEY.BOUND_SERVER_LAST_SEEN_AT]: daysAgoIso(1), + [STRIPE_METADATA_KEY.DEV_SERVER_ID]: 'server-dev', + [STRIPE_METADATA_KEY.DEV_SERVER_LAST_SEEN_AT]: daysAgoIso( + AUTO_RELEASE_DAYS + 1, + ), + }, + serverId: 'server-dev-2', + instanceType: 'development', + autoReleaseDays: AUTO_RELEASE_DAYS, + now: NOW, + }); + + expect(decision).toEqual({ + outcome: 'allowed', + isBillable: false, + metadataPatch: { + [STRIPE_METADATA_KEY.DEV_SERVER_ID]: 'server-dev-2', + [STRIPE_METADATA_KEY.DEV_SERVER_LAST_SEEN_AT]: NOW.toISOString(), + }, + }); + }); + + it('rejects a development instance that does not report a serverId', () => { + const decision = resolveServerBinding({ + stripeMetadata: { + [STRIPE_METADATA_KEY.BOUND_SERVER_ID]: 'server-a', + [STRIPE_METADATA_KEY.BOUND_SERVER_LAST_SEEN_AT]: daysAgoIso(1), + }, + serverId: null, + instanceType: 'development', + autoReleaseDays: AUTO_RELEASE_DAYS, + now: NOW, + }); + + expect(decision.outcome).toBe('rejected'); + if (decision.outcome === 'rejected') { + expect(decision.code).toBe('ENTERPRISE_MISSING_SERVER_ID'); + } + }); + + it('rejects a development instance when there is no production binding', () => { + const decision = resolveServerBinding({ + stripeMetadata: {}, + serverId: 'server-dev', + instanceType: 'development', + autoReleaseDays: AUTO_RELEASE_DAYS, + now: NOW, + }); + + expect(decision.outcome).toBe('rejected'); + if (decision.outcome === 'rejected') { + expect(decision.code).toBe('ENTERPRISE_DEV_REQUIRES_ACTIVE_PRODUCTION'); + } + }); + + it('rejects a development instance when the production binding is stale', () => { + const decision = resolveServerBinding({ + stripeMetadata: { + [STRIPE_METADATA_KEY.BOUND_SERVER_ID]: 'server-a', + [STRIPE_METADATA_KEY.BOUND_SERVER_LAST_SEEN_AT]: daysAgoIso( + AUTO_RELEASE_DAYS + 1, + ), + }, + serverId: 'server-dev', + instanceType: 'development', + autoReleaseDays: AUTO_RELEASE_DAYS, + now: NOW, + }); + + expect(decision.outcome).toBe('rejected'); + if (decision.outcome === 'rejected') { + expect(decision.code).toBe('ENTERPRISE_DEV_REQUIRES_ACTIVE_PRODUCTION'); + } + }); + + it('rejects a production instance that lost its serverId while a binding exists', () => { + const decision = resolveServerBinding({ + stripeMetadata: { + [STRIPE_METADATA_KEY.BOUND_SERVER_ID]: 'server-a', + [STRIPE_METADATA_KEY.BOUND_SERVER_LAST_SEEN_AT]: daysAgoIso(1), + }, + serverId: null, + instanceType: 'production', + autoReleaseDays: AUTO_RELEASE_DAYS, + now: NOW, + }); + + expect(decision.outcome).toBe('rejected'); + if (decision.outcome === 'rejected') { + expect(decision.code).toBe('ENTERPRISE_MISSING_SERVER_ID'); + } + }); + + it('allows legacy instances without a serverId without binding', () => { + const decision = resolveServerBinding({ + stripeMetadata: {}, + serverId: null, + instanceType: 'production', + autoReleaseDays: AUTO_RELEASE_DAYS, + now: NOW, + }); + + expect(decision).toEqual({ + outcome: 'allowed', + isBillable: true, + metadataPatch: {}, + }); + }); + + it('rejects a production instance sending an empty serverId while a binding exists', () => { + const decision = resolveServerBinding({ + stripeMetadata: { + [STRIPE_METADATA_KEY.BOUND_SERVER_ID]: 'server-a', + [STRIPE_METADATA_KEY.BOUND_SERVER_LAST_SEEN_AT]: daysAgoIso(1), + }, + serverId: ' ', + instanceType: 'production', + autoReleaseDays: AUTO_RELEASE_DAYS, + now: NOW, + }); + + expect(decision.outcome).toBe('rejected'); + if (decision.outcome === 'rejected') { + expect(decision.code).toBe('ENTERPRISE_MISSING_SERVER_ID'); + } + }); + + it('does not let an empty serverId claim a free key as the bound id', () => { + const decision = resolveServerBinding({ + stripeMetadata: {}, + serverId: '', + instanceType: 'production', + autoReleaseDays: AUTO_RELEASE_DAYS, + now: NOW, + }); + + expect(decision).toEqual({ + outcome: 'allowed', + isBillable: true, + metadataPatch: {}, + }); + }); + + it('rejects a development instance sending a whitespace serverId', () => { + const decision = resolveServerBinding({ + stripeMetadata: { + [STRIPE_METADATA_KEY.BOUND_SERVER_ID]: 'server-a', + [STRIPE_METADATA_KEY.BOUND_SERVER_LAST_SEEN_AT]: daysAgoIso(1), + }, + serverId: ' ', + instanceType: 'development', + autoReleaseDays: AUTO_RELEASE_DAYS, + now: NOW, + }); + + expect(decision.outcome).toBe('rejected'); + if (decision.outcome === 'rejected') { + expect(decision.code).toBe('ENTERPRISE_MISSING_SERVER_ID'); + } + }); +}); + +describe('isBillableSeatReporter', () => { + it('bills the bound production server', () => { + expect( + isBillableSeatReporter({ + stripeMetadata: { [STRIPE_METADATA_KEY.BOUND_SERVER_ID]: 'server-a' }, + serverId: 'server-a', + }), + ).toBe(true); + }); + + it('does not bill a foreign server', () => { + expect( + isBillableSeatReporter({ + stripeMetadata: { [STRIPE_METADATA_KEY.BOUND_SERVER_ID]: 'server-a' }, + serverId: 'server-b', + }), + ).toBe(false); + }); + + it('does not bill a development instance (no production binding)', () => { + expect( + isBillableSeatReporter({ + stripeMetadata: { [STRIPE_METADATA_KEY.DEV_SERVER_ID]: 'server-dev' }, + serverId: 'server-dev', + }), + ).toBe(false); + }); + + it('bills legacy instances with no serverId and no binding', () => { + expect( + isBillableSeatReporter({ stripeMetadata: {}, serverId: undefined }), + ).toBe(true); + }); + + it('does not bill an empty serverId against a bound production server', () => { + expect( + isBillableSeatReporter({ + stripeMetadata: { [STRIPE_METADATA_KEY.BOUND_SERVER_ID]: 'server-a' }, + serverId: ' ', + }), + ).toBe(false); + }); +}); + +describe('parseInstanceType', () => { + it('returns development only for the development literal', () => { + expect(parseInstanceType('development')).toBe('development'); + expect(parseInstanceType('production')).toBe('production'); + expect(parseInstanceType(undefined)).toBe('production'); + expect(parseInstanceType('something-else')).toBe('production'); + }); +}); + +describe('evaluateReleaseRateLimit', () => { + const RELEASE_WINDOW_DAYS = 30; + const msDaysAgo = (days: number): number => + NOW.getTime() - days * 24 * 60 * 60 * 1000; + + it('allows a release under the limit and records the new timestamp', () => { + const decision = evaluateReleaseRateLimit({ + stripeMetadata: { + [STRIPE_METADATA_KEY.RELEASE_TIMESTAMPS]: [ + msDaysAgo(1), + msDaysAgo(2), + ].join(','), + }, + limit: 10, + windowDays: RELEASE_WINDOW_DAYS, + now: NOW, + }); + + expect(decision.allowed).toBe(true); + if (decision.allowed) { + const recorded = decision.metadataPatch[ + STRIPE_METADATA_KEY.RELEASE_TIMESTAMPS + ] + .split(',') + .map(Number); + expect(recorded).toHaveLength(3); + expect(recorded).toContain(NOW.getTime()); + } + }); + + it('allows the first ever release (no prior timestamps)', () => { + const decision = evaluateReleaseRateLimit({ + stripeMetadata: {}, + limit: 10, + windowDays: RELEASE_WINDOW_DAYS, + now: NOW, + }); + + expect(decision.allowed).toBe(true); + if (decision.allowed) { + expect( + decision.metadataPatch[STRIPE_METADATA_KEY.RELEASE_TIMESTAMPS], + ).toBe(String(NOW.getTime())); + } + }); + + it('blocks a release when the limit is reached within the window', () => { + const timestamps = Array.from({ length: 10 }, (_, index) => + msDaysAgo(index + 1), + ); + + const decision = evaluateReleaseRateLimit({ + stripeMetadata: { + [STRIPE_METADATA_KEY.RELEASE_TIMESTAMPS]: timestamps.join(','), + }, + limit: 10, + windowDays: RELEASE_WINDOW_DAYS, + now: NOW, + }); + + expect(decision.allowed).toBe(false); + if (!decision.allowed) { + const expectedRetry = new Date( + msDaysAgo(10) + RELEASE_WINDOW_DAYS * 24 * 60 * 60 * 1000, + ); + expect(decision.retryAfter.toISOString()).toBe( + expectedRetry.toISOString(), + ); + } + }); + + it('ignores releases older than the window (rolling)', () => { + const timestamps = [ + ...Array.from({ length: 9 }, (_, index) => msDaysAgo(index + 1)), + msDaysAgo(40), + msDaysAgo(45), + ]; + + const decision = evaluateReleaseRateLimit({ + stripeMetadata: { + [STRIPE_METADATA_KEY.RELEASE_TIMESTAMPS]: timestamps.join(','), + }, + limit: 10, + windowDays: RELEASE_WINDOW_DAYS, + now: NOW, + }); + + expect(decision.allowed).toBe(true); + if (decision.allowed) { + const recorded = decision.metadataPatch[ + STRIPE_METADATA_KEY.RELEASE_TIMESTAMPS + ] + .split(',') + .map(Number); + expect(recorded).toHaveLength(10); + expect(recorded).not.toContain(msDaysAgo(40)); + expect(recorded).not.toContain(msDaysAgo(45)); + } + }); +}); + +describe('evaluateValidityTokenEmissionRateLimit', () => { + const EMISSION_WINDOW_HOURS = 24; + const PRODUCTION_KEY = + VALIDITY_TOKEN_EMISSIONS_KEY_BY_INSTANCE_TYPE.production; + const DEVELOPMENT_KEY = + VALIDITY_TOKEN_EMISSIONS_KEY_BY_INSTANCE_TYPE.development; + const msHoursAgo = (hours: number): number => + NOW.getTime() - hours * 60 * 60 * 1000; + + it('allows the first ever emission (no prior timestamps)', () => { + const decision = evaluateValidityTokenEmissionRateLimit({ + stripeMetadata: {}, + instanceType: 'production', + now: NOW, + }); + + expect(decision.allowed).toBe(true); + if (decision.allowed) { + expect(decision.metadataPatch[PRODUCTION_KEY]).toBe( + String(NOW.getTime()), + ); + } + }); + + it('allows a second emission within 24h and records it', () => { + const decision = evaluateValidityTokenEmissionRateLimit({ + stripeMetadata: { + [PRODUCTION_KEY]: String(msHoursAgo(3)), + }, + instanceType: 'production', + now: NOW, + }); + + expect(decision.allowed).toBe(true); + if (decision.allowed) { + const recorded = decision.metadataPatch[PRODUCTION_KEY] + .split(',') + .map(Number); + expect(recorded).toHaveLength(2); + expect(recorded).toContain(NOW.getTime()); + } + }); + + it('blocks a third emission within the 24h window', () => { + const decision = evaluateValidityTokenEmissionRateLimit({ + stripeMetadata: { + [PRODUCTION_KEY]: [msHoursAgo(2), msHoursAgo(5)].join(','), + }, + instanceType: 'production', + now: NOW, + }); + + expect(decision.allowed).toBe(false); + if (!decision.allowed) { + const expectedRetry = new Date( + msHoursAgo(5) + EMISSION_WINDOW_HOURS * 60 * 60 * 1000, + ); + expect(decision.retryAfter.toISOString()).toBe( + expectedRetry.toISOString(), + ); + } + }); + + it('prunes emissions older than the 24h window (rolling)', () => { + const decision = evaluateValidityTokenEmissionRateLimit({ + stripeMetadata: { + [PRODUCTION_KEY]: [msHoursAgo(25), msHoursAgo(48)].join(','), + }, + instanceType: 'production', + now: NOW, + }); + + expect(decision.allowed).toBe(true); + if (decision.allowed) { + const recorded = decision.metadataPatch[PRODUCTION_KEY] + .split(',') + .map(Number); + expect(recorded).toEqual([NOW.getTime()]); + } + }); + + it('tracks production and development budgets independently', () => { + // Production is already at its limit within the window... + const stripeMetadata = { + [PRODUCTION_KEY]: [msHoursAgo(1), msHoursAgo(2)].join(','), + }; + + const productionDecision = evaluateValidityTokenEmissionRateLimit({ + stripeMetadata, + instanceType: 'production', + now: NOW, + }); + + // ...but a development instance still has its full budget. + const developmentDecision = evaluateValidityTokenEmissionRateLimit({ + stripeMetadata, + instanceType: 'development', + now: NOW, + }); + + expect(productionDecision.allowed).toBe(false); + expect(developmentDecision.allowed).toBe(true); + if (developmentDecision.allowed) { + expect(developmentDecision.metadataPatch[DEVELOPMENT_KEY]).toBe( + String(NOW.getTime()), + ); + expect(developmentDecision.metadataPatch[PRODUCTION_KEY]).toBeUndefined(); + } + }); +}); diff --git a/packages/twenty-website/src/platform/enterprise/resolve-server-binding.ts b/packages/twenty-website/src/platform/enterprise/resolve-server-binding.ts new file mode 100644 index 0000000000..f624c452c3 --- /dev/null +++ b/packages/twenty-website/src/platform/enterprise/resolve-server-binding.ts @@ -0,0 +1,174 @@ +import isEmpty from 'lodash.isempty'; + +import { isDefined } from 'twenty-shared/utils'; + +import { + ENTERPRISE_INSTANCE_TYPE, + type EnterpriseInstanceType, +} from './enterprise-instance-type'; +import { normalizeServerId } from './normalize-server-id'; +import { SERVER_BINDING_OUTCOME } from './server-binding-outcome'; +import { + SERVER_BINDING_REJECTION_CODE, + type ServerBindingRejectionCode, +} from './server-binding-rejection-code'; +import { STRIPE_METADATA_KEY } from './stripe-metadata-key'; +import { type StripeMetadata } from './stripe-metadata'; + +const SECONDS_PER_DAY = 24 * 60 * 60; + +export type ResolveServerBindingInput = { + stripeMetadata: StripeMetadata; + serverId: string | null | undefined; + instanceType: EnterpriseInstanceType; + autoReleaseDays: number; + now?: Date; +}; + +export type ServerBindingDecision = + | { + outcome: typeof SERVER_BINDING_OUTCOME.ALLOWED; + isBillable: boolean; + metadataPatch: Record; + } + | { + outcome: typeof SERVER_BINDING_OUTCOME.REJECTED; + code: ServerBindingRejectionCode; + reason: string; + }; + +const isStale = ( + lastSeenAt: string | undefined, + autoReleaseDays: number, + now: Date, +): boolean => { + if (!isDefined(lastSeenAt) || isEmpty(lastSeenAt)) { + return false; + } + + const lastSeenMs = Date.parse(lastSeenAt); + + if (Number.isNaN(lastSeenMs)) { + return false; + } + + const ageSeconds = (now.getTime() - lastSeenMs) / 1000; + + return ageSeconds > autoReleaseDays * SECONDS_PER_DAY; +}; + +export function resolveServerBinding({ + stripeMetadata, + serverId, + instanceType, + autoReleaseDays, + now = new Date(), +}: ResolveServerBindingInput): ServerBindingDecision { + const normalizedServerId = normalizeServerId(serverId); + + if (!isDefined(normalizedServerId)) { + if (instanceType === ENTERPRISE_INSTANCE_TYPE.DEVELOPMENT) { + return { + outcome: SERVER_BINDING_OUTCOME.REJECTED, + code: SERVER_BINDING_REJECTION_CODE.MISSING_SERVER_ID, + reason: + 'A development instance must report a server identifier. Set SERVER_ID on this instance.', + }; + } + + const boundServerId = stripeMetadata?.[STRIPE_METADATA_KEY.BOUND_SERVER_ID]; + + if (isDefined(boundServerId)) { + return { + outcome: SERVER_BINDING_OUTCOME.REJECTED, + code: SERVER_BINDING_REJECTION_CODE.MISSING_SERVER_ID, + reason: + 'This enterprise key is bound to a server instance, but this instance did not report a server identifier. Set SERVER_ID on this instance or release the binding to rebind it.', + }; + } + + return { + outcome: SERVER_BINDING_OUTCOME.ALLOWED, + isBillable: true, + metadataPatch: {}, + }; + } + + const nowIso = now.toISOString(); + + if (instanceType === ENTERPRISE_INSTANCE_TYPE.DEVELOPMENT) { + const productionServerId = + stripeMetadata?.[STRIPE_METADATA_KEY.BOUND_SERVER_ID]; + const productionLastSeenAt = + stripeMetadata?.[STRIPE_METADATA_KEY.BOUND_SERVER_LAST_SEEN_AT]; + + const hasActiveProductionBinding = + !isEmpty(productionServerId) && + !isStale(productionLastSeenAt, autoReleaseDays, now); + + if (!hasActiveProductionBinding) { + return { + outcome: SERVER_BINDING_OUTCOME.REJECTED, + code: SERVER_BINDING_REJECTION_CODE.DEV_REQUIRES_ACTIVE_PRODUCTION, + reason: + 'A free development instance requires an active production instance on this enterprise subscription.', + }; + } + + const expectedDevServerId = normalizeServerId( + stripeMetadata?.[STRIPE_METADATA_KEY.DEV_SERVER_ID], + ); + const devLastSeenAt = + stripeMetadata?.[STRIPE_METADATA_KEY.DEV_SERVER_LAST_SEEN_AT]; + + if ( + isEmpty(expectedDevServerId) || + expectedDevServerId === normalizedServerId || + isStale(devLastSeenAt, autoReleaseDays, now) + ) { + return { + outcome: SERVER_BINDING_OUTCOME.ALLOWED, + isBillable: false, + metadataPatch: { + [STRIPE_METADATA_KEY.DEV_SERVER_ID]: normalizedServerId, + [STRIPE_METADATA_KEY.DEV_SERVER_LAST_SEEN_AT]: nowIso, + }, + }; + } + + return { + outcome: SERVER_BINDING_OUTCOME.REJECTED, + code: SERVER_BINDING_REJECTION_CODE.DEV_SLOT_IN_USE, + reason: + 'The development instance slot for this enterprise key is already in use on another server.', + }; + } + + const boundServerId = normalizeServerId( + stripeMetadata?.[STRIPE_METADATA_KEY.BOUND_SERVER_ID], + ); + const boundLastSeenAt = + stripeMetadata?.[STRIPE_METADATA_KEY.BOUND_SERVER_LAST_SEEN_AT]; + + if ( + isEmpty(boundServerId) || + boundServerId === normalizedServerId || + isStale(boundLastSeenAt, autoReleaseDays, now) + ) { + return { + outcome: SERVER_BINDING_OUTCOME.ALLOWED, + isBillable: true, + metadataPatch: { + [STRIPE_METADATA_KEY.BOUND_SERVER_ID]: normalizedServerId, + [STRIPE_METADATA_KEY.BOUND_SERVER_LAST_SEEN_AT]: nowIso, + }, + }; + } + + return { + outcome: SERVER_BINDING_OUTCOME.REJECTED, + code: SERVER_BINDING_REJECTION_CODE.BOUND_TO_ANOTHER_SERVER, + reason: + 'This enterprise key is already in use on another server instance. Release it from that server or transfer it to this one.', + }; +} diff --git a/packages/twenty-website/src/platform/enterprise/server-binding-outcome.ts b/packages/twenty-website/src/platform/enterprise/server-binding-outcome.ts new file mode 100644 index 0000000000..6b395dd878 --- /dev/null +++ b/packages/twenty-website/src/platform/enterprise/server-binding-outcome.ts @@ -0,0 +1,7 @@ +export const SERVER_BINDING_OUTCOME = { + ALLOWED: 'allowed', + REJECTED: 'rejected', +} as const; + +export type ServerBindingOutcome = + (typeof SERVER_BINDING_OUTCOME)[keyof typeof SERVER_BINDING_OUTCOME]; diff --git a/packages/twenty-website/src/platform/enterprise/server-binding-rejection-code.ts b/packages/twenty-website/src/platform/enterprise/server-binding-rejection-code.ts new file mode 100644 index 0000000000..b6f9166bb6 --- /dev/null +++ b/packages/twenty-website/src/platform/enterprise/server-binding-rejection-code.ts @@ -0,0 +1,11 @@ +// Distinct machine codes per rejection reason so clients can react correctly +// (only BOUND_TO_ANOTHER_SERVER means another server owns the key). +export const SERVER_BINDING_REJECTION_CODE = { + BOUND_TO_ANOTHER_SERVER: 'ENTERPRISE_KEY_BOUND_TO_ANOTHER_SERVER', + MISSING_SERVER_ID: 'ENTERPRISE_MISSING_SERVER_ID', + DEV_REQUIRES_ACTIVE_PRODUCTION: 'ENTERPRISE_DEV_REQUIRES_ACTIVE_PRODUCTION', + DEV_SLOT_IN_USE: 'ENTERPRISE_DEV_SLOT_IN_USE', +} as const; + +export type ServerBindingRejectionCode = + (typeof SERVER_BINDING_REJECTION_CODE)[keyof typeof SERVER_BINDING_REJECTION_CODE]; diff --git a/packages/twenty-website/src/platform/enterprise/sign-validity-token.test.ts b/packages/twenty-website/src/platform/enterprise/sign-validity-token.test.ts index 59d6a6cb81..8accc9a7f4 100644 --- a/packages/twenty-website/src/platform/enterprise/sign-validity-token.test.ts +++ b/packages/twenty-website/src/platform/enterprise/sign-validity-token.test.ts @@ -4,7 +4,7 @@ import { signValidityToken } from './sign-validity-token'; import { verifyJwt } from './verify-jwt'; const SECONDS_PER_DAY = 24 * 60 * 60; -const DEFAULT_DURATION_DAYS = 30; +const DEFAULT_DURATION_DAYS = 7; type ValidityClaims = { exp: number; @@ -51,7 +51,7 @@ describe('signValidityToken', () => { }); it('clamps exp down to a cancellation inside the window', () => { - const cancelAt = Math.floor(Date.now() / 1000) + 10 * SECONDS_PER_DAY; + const cancelAt = Math.floor(Date.now() / 1000) + 3 * SECONDS_PER_DAY; const claims = verifiedClaims( signValidityToken('sub_clamped', { subscriptionCancelAt: cancelAt }), ); diff --git a/packages/twenty-website/src/platform/enterprise/sign-validity-token.ts b/packages/twenty-website/src/platform/enterprise/sign-validity-token.ts index 5a2ce78b7b..f111bd2af3 100644 --- a/packages/twenty-website/src/platform/enterprise/sign-validity-token.ts +++ b/packages/twenty-website/src/platform/enterprise/sign-validity-token.ts @@ -1,6 +1,6 @@ import { signJwt } from './sign-jwt'; -const DEFAULT_VALIDITY_TOKEN_DURATION_DAYS = 30; +const DEFAULT_VALIDITY_TOKEN_DURATION_DAYS = 7; const SECONDS_PER_DAY = 24 * 60 * 60; type EnterpriseValidityPayload = { diff --git a/packages/twenty-website/src/platform/enterprise/stripe-metadata-key.ts b/packages/twenty-website/src/platform/enterprise/stripe-metadata-key.ts new file mode 100644 index 0000000000..78ca929587 --- /dev/null +++ b/packages/twenty-website/src/platform/enterprise/stripe-metadata-key.ts @@ -0,0 +1,9 @@ +// Keys used to persist the enterprise binding state on the Stripe subscription +// metadata (Stripe is the stateless store for twenty-website). +export const STRIPE_METADATA_KEY = { + BOUND_SERVER_ID: 'boundServerId', + BOUND_SERVER_LAST_SEEN_AT: 'boundServerLastSeenAt', + DEV_SERVER_ID: 'devServerId', + DEV_SERVER_LAST_SEEN_AT: 'devServerLastSeenAt', + RELEASE_TIMESTAMPS: 'releaseTimestamps', +} as const; diff --git a/packages/twenty-website/src/platform/enterprise/stripe-metadata.ts b/packages/twenty-website/src/platform/enterprise/stripe-metadata.ts new file mode 100644 index 0000000000..044464eb5e --- /dev/null +++ b/packages/twenty-website/src/platform/enterprise/stripe-metadata.ts @@ -0,0 +1 @@ +export type StripeMetadata = Record | null | undefined; diff --git a/packages/twenty-website/src/platform/enterprise/validity-token-emissions-key.ts b/packages/twenty-website/src/platform/enterprise/validity-token-emissions-key.ts new file mode 100644 index 0000000000..30dc635059 --- /dev/null +++ b/packages/twenty-website/src/platform/enterprise/validity-token-emissions-key.ts @@ -0,0 +1,11 @@ +import { + ENTERPRISE_INSTANCE_TYPE, + type EnterpriseInstanceType, +} from './enterprise-instance-type'; + +// Validity token emissions are rate-limited independently per instance type, +// so each type gets its own timestamps bucket in the Stripe metadata. +export const VALIDITY_TOKEN_EMISSIONS_KEY_BY_INSTANCE_TYPE = { + [ENTERPRISE_INSTANCE_TYPE.PRODUCTION]: 'validityTokenEmissionsProduction', + [ENTERPRISE_INSTANCE_TYPE.DEVELOPMENT]: 'validityTokenEmissionsDevelopment', +} as const satisfies Record; diff --git a/yarn.lock b/yarn.lock index 78f5792bb3..a50480f154 100644 --- a/yarn.lock +++ b/yarn.lock @@ -53446,6 +53446,7 @@ __metadata: "@testing-library/jest-dom": "npm:^6.6.3" "@testing-library/react": "npm:^16.3.0" "@testing-library/user-event": "npm:^14.6.1" + "@types/lodash.isempty": "npm:^4.4.7" "@types/node": "npm:^20" "@types/react": "npm:^19" "@types/react-dom": "npm:^19" @@ -53454,6 +53455,7 @@ __metadata: "@wyw-in-js/transform": "npm:^0.8.1" babel-plugin-react-compiler: "npm:1.0.0" jest-environment-jsdom: "npm:30.0.0-beta.3" + lodash.isempty: "npm:^4.4.0" next: "npm:^16.2.6" next-with-linaria: "npm:^1.3.0" react: "npm:19.2.3"