From 2fccd194f3dab7c702321227ea3e3ee1d899a92a Mon Sep 17 00:00:00 2001
From: Marie <51697796+ijreilly@users.noreply.github.com>
Date: Wed, 15 Apr 2026 15:26:38 +0200
Subject: [PATCH] [Billing for self host] End dummy enterprise key validity
(#19560)
Re-using hasValidEnterpriseKey to avoid breaking changes.
This will be entirely removed in the next versions.
---
.../components/InformationBannerWrapper.tsx | 4 +-
...InformationBannerInvalidEnterpriseKey.tsx} | 13 +++--
packages/twenty-server/.env.test | 3 +-
.../__tests__/enterprise-plan.service.spec.ts | 12 ++--
.../services/enterprise-plan.service.ts | 47 ++++-----------
.../metadata-modules/role/role.resolver.ts | 2 +
...-predicate-graphql-api-exception.filter.ts | 15 +++++
...graphql-api-exception-handler.util.spec.ts | 57 +++++++++++++++++++
...cate-graphql-api-exception-handler.util.ts | 37 ++++++++++++
9 files changed, 140 insertions(+), 50 deletions(-)
rename packages/twenty-front/src/modules/information-banner/components/enterprise/{InformationBannerLegacyEnterpriseKey.tsx => InformationBannerInvalidEnterpriseKey.tsx} (76%)
create mode 100644 packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/filters/row-level-permission-predicate-graphql-api-exception.filter.ts
create mode 100644 packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/utils/__tests__/row-level-permission-predicate-graphql-api-exception-handler.util.spec.ts
create mode 100644 packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/utils/row-level-permission-predicate-graphql-api-exception-handler.util.ts
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 0fed9a286e..3cd550c9eb 100644
--- a/packages/twenty-front/src/modules/information-banner/components/InformationBannerWrapper.tsx
+++ b/packages/twenty-front/src/modules/information-banner/components/InformationBannerWrapper.tsx
@@ -6,7 +6,7 @@ import { InformationBannerBillingSubscriptionPaused } from '@/information-banner
import { InformationBannerEndTrialPeriod } from '@/information-banner/components/billing/InformationBannerEndTrialPeriod';
import { InformationBannerFailPaymentInfo } from '@/information-banner/components/billing/InformationBannerFailPaymentInfo';
import { InformationBannerNoBillingSubscription } from '@/information-banner/components/billing/InformationBannerNoBillingSubscription';
-import { InformationBannerLegacyEnterpriseKey } from '@/information-banner/components/enterprise/InformationBannerLegacyEnterpriseKey';
+import { InformationBannerInvalidEnterpriseKey } from '@/information-banner/components/enterprise/InformationBannerInvalidEnterpriseKey';
import { InformationBannerMaintenance } from '@/information-banner/components/maintenance/InformationBannerMaintenance';
import { InformationBannerReconnectAccountEmailAliases } from '@/information-banner/components/reconnect-account/InformationBannerReconnectAccountEmailAliases';
import { InformationBannerReconnectAccountInsufficientPermissions } from '@/information-banner/components/reconnect-account/InformationBannerReconnectAccountInsufficientPermissions';
@@ -55,7 +55,7 @@ export const InformationBannerWrapper = () => {
return (
-
+
{isAccountSyncEnabled && (
)}
diff --git a/packages/twenty-front/src/modules/information-banner/components/enterprise/InformationBannerLegacyEnterpriseKey.tsx b/packages/twenty-front/src/modules/information-banner/components/enterprise/InformationBannerInvalidEnterpriseKey.tsx
similarity index 76%
rename from packages/twenty-front/src/modules/information-banner/components/enterprise/InformationBannerLegacyEnterpriseKey.tsx
rename to packages/twenty-front/src/modules/information-banner/components/enterprise/InformationBannerInvalidEnterpriseKey.tsx
index a5d6a67d9d..c8842666a9 100644
--- a/packages/twenty-front/src/modules/information-banner/components/enterprise/InformationBannerLegacyEnterpriseKey.tsx
+++ b/packages/twenty-front/src/modules/information-banner/components/enterprise/InformationBannerInvalidEnterpriseKey.tsx
@@ -9,9 +9,9 @@ import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { IconKey } from 'twenty-ui/display';
-const COMPONENT_INSTANCE_ID = 'information-banner-legacy-enterprise-key';
+const COMPONENT_INSTANCE_ID = 'information-banner-invalid-enterprise-key';
-export const InformationBannerLegacyEnterpriseKey = () => {
+export const InformationBannerInvalidEnterpriseKey = () => {
const { t } = useLingui();
const navigate = useNavigate();
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
@@ -21,11 +21,12 @@ export const InformationBannerLegacyEnterpriseKey = () => {
COMPONENT_INSTANCE_ID,
);
- const hasLegacyKey =
+ const hasInvalidKey =
currentWorkspace?.hasValidEnterpriseKey === true &&
- currentWorkspace?.hasValidSignedEnterpriseKey !== true;
+ currentWorkspace?.hasValidSignedEnterpriseKey !== true &&
+ currentWorkspace?.hasValidEnterpriseValidityToken !== true;
- if (!hasLegacyKey) {
+ if (!hasInvalidKey) {
return null;
}
@@ -33,7 +34,7 @@ export const InformationBannerLegacyEnterpriseKey = () => {
diff --git a/packages/twenty-server/.env.test b/packages/twenty-server/.env.test
index 6fdd1e8837..85a50cdd0d 100644
--- a/packages/twenty-server/.env.test
+++ b/packages/twenty-server/.env.test
@@ -11,7 +11,8 @@ MUTATION_MAXIMUM_RECORD_AFFECTED=100
IS_MULTIWORKSPACE_ENABLED=true
FRONTEND_URL=http://localhost:3001
IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS=false
-ENTERPRISE_KEY=replace_me_with_a_valid_enterprise_key
+
+ENTERPRISE_VALIDITY_TOKEN=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkZXYtc3Vic2NyaXB0aW9uLWlkIiwic3RhdHVzIjoidmFsaWQiLCJpYXQiOjE3NzMzMDg4MzMsImV4cCI6NDg5NzUxMTIzM30.qhfrW_SV2Y86fWtWXsALlAVhxmMxylUUIefN0fki10Q2NTGGqFVXZrNn2WacJY37yq3m5y4WgwZw34ua6E0ff_YUXsrlY5OHJWHT9DMqKCRn-JujHJnnYp3VHLncy5CvxH5r9mfPFp-5AWe1pYeR1T63sTiejH3sfDrNE357SB7KVti8LCcnsJxEtXB2tRnvyvdun7A-GKoKYEIam-16ZRKKFs6GaWo8ObHdfm8yBt6uK4DZSGPWb644QyWh9FtDxbzJ0ti54DuHSlErLgIp1NNEsMA0MK7zFY7StRaOdt72rxE1ZHwN7e6HhweTU4ORVUPfYkjDFLB2fF7Pa7Kvdg
AUTH_GOOGLE_ENABLED=false
MESSAGING_PROVIDER_GMAIL_ENABLED=false
diff --git a/packages/twenty-server/src/engine/core-modules/enterprise/services/__tests__/enterprise-plan.service.spec.ts b/packages/twenty-server/src/engine/core-modules/enterprise/services/__tests__/enterprise-plan.service.spec.ts
index 2ed3ffbdbf..e886a26b3b 100644
--- a/packages/twenty-server/src/engine/core-modules/enterprise/services/__tests__/enterprise-plan.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/enterprise/services/__tests__/enterprise-plan.service.spec.ts
@@ -286,6 +286,7 @@ describe('EnterprisePlanService', () => {
});
});
+ // hasValidEnterpriseKey now means "has any ENTERPRISE_KEY configured"
describe('hasValidEnterpriseKey', () => {
it('should return true when signed enterprise key is valid', async () => {
await setupValidState();
@@ -293,13 +294,12 @@ describe('EnterprisePlanService', () => {
expect(service.hasValidEnterpriseKey()).toBe(true);
});
- it('should return true with legacy unsigned key as fallback', async () => {
+ it('should return true with unsigned legacy key', async () => {
setupEnterpriseKey('some-legacy-key');
mockCryptoVerify.mockReturnValue(false);
appTokenFindOneMock.mockResolvedValue(null);
await service.onModuleInit();
- expect(service.hasValidSignedEnterpriseKey()).toBe(false);
expect(service.hasValidEnterpriseKey()).toBe(true);
});
@@ -318,13 +318,13 @@ describe('EnterprisePlanService', () => {
expect(service.isValid()).toBe(true);
});
- it('should return true with legacy key as fallback', async () => {
+ it('should return false with unsigned legacy key', async () => {
setupEnterpriseKey('some-legacy-key');
mockCryptoVerify.mockReturnValue(false);
appTokenFindOneMock.mockResolvedValue(null);
await service.onModuleInit();
- expect(service.isValid()).toBe(true);
+ expect(service.isValid()).toBe(false);
});
it('should return false when no key or token exists', async () => {
@@ -399,7 +399,7 @@ describe('EnterprisePlanService', () => {
});
});
- it('should return legacy license info when only legacy key exists', async () => {
+ it('should return invalid license info when only unsigned legacy key exists', async () => {
setupEnterpriseKey('some-legacy-key');
mockCryptoVerify.mockReturnValue(false);
appTokenFindOneMock.mockResolvedValue(null);
@@ -407,7 +407,7 @@ describe('EnterprisePlanService', () => {
const licenseInfo = await service.getLicenseInfo();
expect(licenseInfo).toEqual({
- isValid: true,
+ isValid: false,
licensee: null,
expiresAt: null,
subscriptionId: null,
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 a38f036194..750ac2f8f4 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
@@ -143,40 +143,22 @@ export class EnterprisePlanService implements OnModuleInit {
}
hasValidEnterpriseKey(): boolean {
- if (this.hasValidSignedEnterpriseKey()) {
- return true;
- }
-
- return this.checkLegacyKey();
+ return this.hasValidSignedEnterpriseKey() || this.checkLegacyKey();
}
isValid(): boolean {
- if (this.hasValidEnterpriseValidityToken()) {
- return true;
- }
+ return this.hasValidEnterpriseValidityToken();
+ }
- return this.checkLegacyKey(); // temporary
+ private checkLegacyKey(): boolean {
+ // temporary
+ return isDefined(this.twentyConfigService.get('ENTERPRISE_KEY'));
}
isValidEnterpriseKeyFormat(key: string): boolean {
return this.verifyJwt(key) !== null;
}
- private checkLegacyKey(): boolean {
- const enterpriseKey = this.twentyConfigService.get('ENTERPRISE_KEY');
-
- if (!isDefined(enterpriseKey)) {
- return false;
- }
-
- this.logger.warn(
- 'Unsigned enterprise keys are deprecated and will stop working ' +
- 'in a future version. Please obtain a signed key from twenty.com.',
- );
-
- return true;
- }
-
async getLicenseInfo(): Promise {
this.refreshKeyPayload();
await this.loadValidityToken();
@@ -192,15 +174,6 @@ export class EnterprisePlanService implements OnModuleInit {
};
}
- if (this.checkLegacyKey()) {
- return {
- isValid: true,
- licensee: null,
- expiresAt: null,
- subscriptionId: null,
- };
- }
-
return {
isValid: false,
licensee: null,
@@ -477,11 +450,15 @@ export class EnterprisePlanService implements OnModuleInit {
}
}
- // In development, try both keys so production keys work when testing locally
+ // In development and Jest integration tests, try both keys so production keys
+ // work locally
private getPublicKeysToTry(): string[] {
const nodeEnv = this.twentyConfigService.get('NODE_ENV');
- if (nodeEnv === NodeEnvironment.DEVELOPMENT) {
+ if (
+ nodeEnv === NodeEnvironment.DEVELOPMENT ||
+ nodeEnv === NodeEnvironment.TEST
+ ) {
return [ENTERPRISE_JWT_PUBLIC_KEY, ENTERPRISE_JWT_DEV_PUBLIC_KEY];
}
diff --git a/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts
index b8d5aa6500..19f43529e5 100644
--- a/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts
@@ -63,6 +63,7 @@ import { UpsertRowLevelPermissionPredicatesInput } from 'src/engine/metadata-mod
import { RowLevelPermissionPredicateGroupDTO } from 'src/engine/metadata-modules/row-level-permission-predicate/dtos/row-level-permission-predicate-group.dto';
import { RowLevelPermissionPredicateDTO } from 'src/engine/metadata-modules/row-level-permission-predicate/dtos/row-level-permission-predicate.dto';
import { UpsertRowLevelPermissionPredicatesResultDTO } from 'src/engine/metadata-modules/row-level-permission-predicate/dtos/upsert-row-level-permission-predicates-result.dto';
+import { RowLevelPermissionPredicateGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/row-level-permission-predicate/filters/row-level-permission-predicate-graphql-api-exception.filter';
import { RowLevelPermissionPredicateGroupService } from 'src/engine/metadata-modules/row-level-permission-predicate/services/row-level-permission-predicate-group.service';
import { RowLevelPermissionPredicateService } from 'src/engine/metadata-modules/row-level-permission-predicate/services/row-level-permission-predicate.service';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
@@ -77,6 +78,7 @@ import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/sta
SettingsPermissionGuard(PermissionFlagType.ROLES),
)
@UseFilters(
+ RowLevelPermissionPredicateGraphqlApiExceptionFilter,
PermissionsGraphqlApiExceptionFilter,
PreventNestToAutoLogGraphqlErrorsFilter,
)
diff --git a/packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/filters/row-level-permission-predicate-graphql-api-exception.filter.ts b/packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/filters/row-level-permission-predicate-graphql-api-exception.filter.ts
new file mode 100644
index 0000000000..c27ed9a8e6
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/filters/row-level-permission-predicate-graphql-api-exception.filter.ts
@@ -0,0 +1,15 @@
+/* @license Enterprise */
+
+import { Catch, type ExceptionFilter } from '@nestjs/common';
+
+import { RowLevelPermissionPredicateException } from 'src/engine/metadata-modules/row-level-permission-predicate/exceptions/row-level-permission-predicate.exception';
+import { rowLevelPermissionPredicateGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/row-level-permission-predicate/utils/row-level-permission-predicate-graphql-api-exception-handler.util';
+
+@Catch(RowLevelPermissionPredicateException)
+export class RowLevelPermissionPredicateGraphqlApiExceptionFilter
+ implements ExceptionFilter
+{
+ catch(exception: RowLevelPermissionPredicateException) {
+ return rowLevelPermissionPredicateGraphqlApiExceptionHandler(exception);
+ }
+}
diff --git a/packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/utils/__tests__/row-level-permission-predicate-graphql-api-exception-handler.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/utils/__tests__/row-level-permission-predicate-graphql-api-exception-handler.util.spec.ts
new file mode 100644
index 0000000000..e5d5f3d913
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/utils/__tests__/row-level-permission-predicate-graphql-api-exception-handler.util.spec.ts
@@ -0,0 +1,57 @@
+import {
+ ForbiddenError,
+ InternalServerError,
+ NotFoundError,
+ UserInputError,
+} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
+import {
+ RowLevelPermissionPredicateException,
+ RowLevelPermissionPredicateExceptionCode,
+} from 'src/engine/metadata-modules/row-level-permission-predicate/exceptions/row-level-permission-predicate.exception';
+import { rowLevelPermissionPredicateGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/row-level-permission-predicate/utils/row-level-permission-predicate-graphql-api-exception-handler.util';
+
+describe('rowLevelPermissionPredicateGraphqlApiExceptionHandler', () => {
+ it('should throw ForbiddenError for ROW_LEVEL_PERMISSION_FEATURE_DISABLED', () => {
+ const exception = new RowLevelPermissionPredicateException(
+ 'Row level permission predicate feature is disabled.',
+ RowLevelPermissionPredicateExceptionCode.ROW_LEVEL_PERMISSION_FEATURE_DISABLED,
+ );
+
+ expect(() =>
+ rowLevelPermissionPredicateGraphqlApiExceptionHandler(exception),
+ ).toThrow(ForbiddenError);
+ });
+
+ it('should throw NotFoundError for ROW_LEVEL_PERMISSION_PREDICATE_NOT_FOUND', () => {
+ const exception = new RowLevelPermissionPredicateException(
+ 'Predicate not found',
+ RowLevelPermissionPredicateExceptionCode.ROW_LEVEL_PERMISSION_PREDICATE_NOT_FOUND,
+ );
+
+ expect(() =>
+ rowLevelPermissionPredicateGraphqlApiExceptionHandler(exception),
+ ).toThrow(NotFoundError);
+ });
+
+ it('should throw UserInputError for INVALID_ROW_LEVEL_PERMISSION_PREDICATE_DATA', () => {
+ const exception = new RowLevelPermissionPredicateException(
+ 'Invalid data',
+ RowLevelPermissionPredicateExceptionCode.INVALID_ROW_LEVEL_PERMISSION_PREDICATE_DATA,
+ );
+
+ expect(() =>
+ rowLevelPermissionPredicateGraphqlApiExceptionHandler(exception),
+ ).toThrow(UserInputError);
+ });
+
+ it('should throw InternalServerError for INTERNAL_SERVER_ERROR', () => {
+ const exception = new RowLevelPermissionPredicateException(
+ 'Unexpected',
+ RowLevelPermissionPredicateExceptionCode.INTERNAL_SERVER_ERROR,
+ );
+
+ expect(() =>
+ rowLevelPermissionPredicateGraphqlApiExceptionHandler(exception),
+ ).toThrow(InternalServerError);
+ });
+});
diff --git a/packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/utils/row-level-permission-predicate-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/utils/row-level-permission-predicate-graphql-api-exception-handler.util.ts
new file mode 100644
index 0000000000..0e698020ea
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/row-level-permission-predicate/utils/row-level-permission-predicate-graphql-api-exception-handler.util.ts
@@ -0,0 +1,37 @@
+/* @license Enterprise */
+
+import { assertUnreachable } from 'twenty-shared/utils';
+
+import {
+ ForbiddenError,
+ InternalServerError,
+ NotFoundError,
+ UserInputError,
+} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
+import {
+ type RowLevelPermissionPredicateException,
+ RowLevelPermissionPredicateExceptionCode,
+} from 'src/engine/metadata-modules/row-level-permission-predicate/exceptions/row-level-permission-predicate.exception';
+
+export const rowLevelPermissionPredicateGraphqlApiExceptionHandler = (
+ error: RowLevelPermissionPredicateException,
+) => {
+ switch (error.code) {
+ case RowLevelPermissionPredicateExceptionCode.ROW_LEVEL_PERMISSION_FEATURE_DISABLED:
+ case RowLevelPermissionPredicateExceptionCode.UNAUTHORIZED_ROLE_MODIFICATION:
+ case RowLevelPermissionPredicateExceptionCode.UNAUTHORIZED_OBJECT_MODIFICATION:
+ throw new ForbiddenError(error);
+ case RowLevelPermissionPredicateExceptionCode.INVALID_ROW_LEVEL_PERMISSION_PREDICATE_DATA:
+ throw new UserInputError(error);
+ case RowLevelPermissionPredicateExceptionCode.ROW_LEVEL_PERMISSION_PREDICATE_NOT_FOUND:
+ case RowLevelPermissionPredicateExceptionCode.FIELD_METADATA_NOT_FOUND:
+ case RowLevelPermissionPredicateExceptionCode.OBJECT_METADATA_NOT_FOUND:
+ case RowLevelPermissionPredicateExceptionCode.ROLE_NOT_FOUND:
+ throw new NotFoundError(error);
+ case RowLevelPermissionPredicateExceptionCode.INTERNAL_SERVER_ERROR:
+ throw new InternalServerError(error);
+ default: {
+ return assertUnreachable(error.code);
+ }
+ }
+};