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"