From 830404b215212be1423f5a6dfbc080c60a699f02 Mon Sep 17 00:00:00 2001
From: Remi Huigen <83277154+remihuigen@users.noreply.github.com>
Date: Thu, 30 Jul 2026 18:49:52 +0200
Subject: [PATCH] fix: Decrypt encrypted front component variables (#23494)
## Summary
Fixes #23492
Fixes front-component application variables returning their encrypted
at-rest value instead of their configured plaintext value.
Non-secret application variables (`isSecret: false`) are now decrypted
server-side before being injected into the front-component environment.
Secret variables remain excluded and are never decrypted or exposed to
the browser.
## Root cause
The front-component resolver filtered secret application variables
correctly, but forwarded the cached `encryptedValue` directly. As a
result, `getApplicationVariable()` returned an `enc:v2:...` envelope
rather than the configured value.
## Changes
- Decrypt recognized versioned envelopes for non-secret application
variables.
- Preserve empty and legacy/plain values unchanged for backwards
compatibility.
- Add `SecretEncryptionModule` to the front-component module.
- Add coverage for:
- decrypting public variables;
- retaining plaintext compatibility;
- excluding secret variables without attempting decryption.
---------
Co-authored-by: prastoin
---
.../application-variable.service.spec.ts | 202 +++++++++++++++++
.../application-variable.service.ts | 82 +++++++
.../logic-function-executor.module.ts | 2 +
.../logic-function-executor.service.ts | 37 ++-
.../utils/__tests__/build-env-var.spec.ts | 210 ------------------
.../utils/build-env-var.ts | 28 ---
.../front-component/front-component.module.ts | 2 +
.../front-component.resolver.ts | 31 +--
...-secret-from-application-variables.spec.ts | 127 -----------
...strip-secret-from-application-variables.ts | 20 --
...-application-variables.integration-spec.ts | 134 +++++++++++
...ind-front-components-query-factory.util.ts | 21 ++
.../utils/find-front-components.util.ts | 36 +++
13 files changed, 500 insertions(+), 432 deletions(-)
delete mode 100644 packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/utils/__tests__/build-env-var.spec.ts
delete mode 100644 packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/utils/build-env-var.ts
delete mode 100644 packages/twenty-server/src/engine/metadata-modules/front-component/utils/__tests__/strip-secret-from-application-variables.spec.ts
delete mode 100644 packages/twenty-server/src/engine/metadata-modules/front-component/utils/strip-secret-from-application-variables.ts
create mode 100644 packages/twenty-server/test/integration/metadata/suites/front-component/successful-front-component-application-variables.integration-spec.ts
create mode 100644 packages/twenty-server/test/integration/metadata/suites/front-component/utils/find-front-components-query-factory.util.ts
create mode 100644 packages/twenty-server/test/integration/metadata/suites/front-component/utils/find-front-components.util.ts
diff --git a/packages/twenty-server/src/engine/core-modules/application/application-variable/__tests__/application-variable.service.spec.ts b/packages/twenty-server/src/engine/core-modules/application/application-variable/__tests__/application-variable.service.spec.ts
index d4db112ca65..7490ca6e631 100644
--- a/packages/twenty-server/src/engine/core-modules/application/application-variable/__tests__/application-variable.service.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/application/application-variable/__tests__/application-variable.service.spec.ts
@@ -3,6 +3,8 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { type Repository } from 'typeorm';
+import { FieldMetadataType } from 'twenty-shared/types';
+
import { ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
import {
ApplicationVariableEntityException,
@@ -10,8 +12,11 @@ import {
} from 'src/engine/core-modules/application/application-variable/application-variable.exception';
import { ApplicationVariableEntityService } from 'src/engine/core-modules/application/application-variable/application-variable.service';
import { SECRET_APPLICATION_VARIABLE_MASK } from 'src/engine/core-modules/application/application-variable/constants/secret-application-variable-mask.constant';
+import { type ApplicationVariableCacheMaps } from 'src/engine/core-modules/application/application-variable/types/application-variable-cache-maps.type';
+import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type';
import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
+import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
describe('ApplicationVariableEntityService', () => {
@@ -65,6 +70,7 @@ describe('ApplicationVariableEntityService', () => {
provide: WorkspaceCacheService,
useValue: {
invalidateAndRecompute: jest.fn(),
+ getOrRecompute: jest.fn(),
},
},
],
@@ -78,10 +84,206 @@ describe('ApplicationVariableEntityService', () => {
workspaceCacheService = module.get(WorkspaceCacheService);
});
+ const workspaceA = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
+ const workspaceB = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
+
+ const makeFlatVariable = (
+ overrides: Partial,
+ ): FlatApplicationVariable =>
+ ({
+ id: '1',
+ key: 'KEY',
+ value: '' as EncryptedString | '',
+ description: '',
+ isSecret: false,
+ type: FieldMetadataType.TEXT,
+ options: null,
+ applicationId: mockApplicationId,
+ workspaceId: workspaceA,
+ universalIdentifier: '00000000-0000-0000-0000-000000000000',
+ applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000',
+ createdAt: '2024-01-01T00:00:00.000Z',
+ updatedAt: '2024-01-01T00:00:00.000Z',
+ ...overrides,
+ }) as FlatApplicationVariable;
+
+ const makeApplicationVariableMaps = (
+ flatApplicationVariables: FlatApplicationVariable[],
+ ) =>
+ ({
+ byUniversalIdentifier: Object.fromEntries(
+ flatApplicationVariables.map((flatApplicationVariable) => [
+ flatApplicationVariable.universalIdentifier,
+ flatApplicationVariable,
+ ]),
+ ),
+ universalIdentifiersByApplicationId: {
+ [mockApplicationId]: flatApplicationVariables.map(
+ ({ universalIdentifier }) => universalIdentifier,
+ ),
+ },
+ }) as unknown as ApplicationVariableCacheMaps;
+
+ const mockCachedApplicationVariables = (
+ flatApplicationVariables: FlatApplicationVariable[],
+ ) => {
+ workspaceCacheService.getOrRecompute.mockResolvedValue({
+ applicationVariableMaps: makeApplicationVariableMaps(
+ flatApplicationVariables,
+ ),
+ } as never);
+ };
+
it('should be defined', () => {
expect(service).toBeDefined();
});
+ describe('getServerEnvVariables', () => {
+ it('should return an empty object when the application has no variable', async () => {
+ mockCachedApplicationVariables([]);
+
+ await expect(
+ service.getServerEnvVariables({
+ workspaceId: workspaceA,
+ applicationId: mockApplicationId,
+ }),
+ ).resolves.toEqual({});
+ });
+
+ it('should decrypt all encrypted variables regardless of isSecret', async () => {
+ mockCachedApplicationVariables([
+ makeFlatVariable({
+ universalIdentifier: 'variable-1',
+ key: 'PUBLIC_URL',
+ value:
+ `enc:v2:deadbeef:https://example.com|${workspaceA}` as EncryptedString,
+ }),
+ makeFlatVariable({
+ universalIdentifier: 'variable-2',
+ key: 'API_SECRET',
+ value: `enc:v2:deadbeef:secret-123|${workspaceA}` as EncryptedString,
+ isSecret: true,
+ }),
+ ]);
+
+ const result = await service.getServerEnvVariables({
+ workspaceId: workspaceA,
+ applicationId: mockApplicationId,
+ });
+
+ expect(result).toEqual({
+ PUBLIC_URL: 'https://example.com',
+ API_SECRET: 'secret-123',
+ });
+ expect(
+ secretEncryptionService.decryptVersionedOrThrow,
+ ).toHaveBeenCalledTimes(2);
+ });
+
+ it('should route each variable to its own workspace HKDF context', async () => {
+ mockCachedApplicationVariables([
+ makeFlatVariable({
+ universalIdentifier: 'variable-1',
+ key: 'A_SECRET',
+ value: `enc:v2:deadbeef:value-a|${workspaceA}` as EncryptedString,
+ isSecret: true,
+ }),
+ makeFlatVariable({
+ universalIdentifier: 'variable-2',
+ key: 'B_SECRET',
+ value: `enc:v2:deadbeef:value-b|${workspaceB}` as EncryptedString,
+ isSecret: true,
+ workspaceId: workspaceB,
+ }),
+ ]);
+
+ await service.getServerEnvVariables({
+ workspaceId: workspaceA,
+ applicationId: mockApplicationId,
+ });
+
+ expect(
+ secretEncryptionService.decryptVersionedOrThrow,
+ ).toHaveBeenCalledWith(`enc:v2:deadbeef:value-a|${workspaceA}`, {
+ workspaceId: workspaceA,
+ });
+ expect(
+ secretEncryptionService.decryptVersionedOrThrow,
+ ).toHaveBeenCalledWith(`enc:v2:deadbeef:value-b|${workspaceB}`, {
+ workspaceId: workspaceB,
+ });
+ });
+
+ it('should return an empty string for uninitialised variables without decrypting', async () => {
+ mockCachedApplicationVariables([
+ makeFlatVariable({
+ universalIdentifier: 'variable-1',
+ key: 'EMPTY_VALUE',
+ value: '',
+ }),
+ ]);
+
+ const result = await service.getServerEnvVariables({
+ workspaceId: workspaceA,
+ applicationId: mockApplicationId,
+ });
+
+ expect(result).toEqual({ EMPTY_VALUE: '' });
+ expect(
+ secretEncryptionService.decryptVersionedOrThrow,
+ ).not.toHaveBeenCalled();
+ });
+
+ it('should reuse provided application variable maps instead of reading the cache', async () => {
+ const applicationVariableMaps = makeApplicationVariableMaps([
+ makeFlatVariable({
+ universalIdentifier: 'variable-1',
+ key: 'PUBLIC_URL',
+ value:
+ `enc:v2:deadbeef:https://example.com|${workspaceA}` as EncryptedString,
+ }),
+ ]);
+
+ const result = await service.getServerEnvVariables({
+ workspaceId: workspaceA,
+ applicationId: mockApplicationId,
+ applicationVariableMaps,
+ });
+
+ expect(result).toEqual({ PUBLIC_URL: 'https://example.com' });
+ expect(workspaceCacheService.getOrRecompute).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('getPublicEnvVariables', () => {
+ it('should exclude secret variables without decrypting them', async () => {
+ mockCachedApplicationVariables([
+ makeFlatVariable({
+ universalIdentifier: 'variable-1',
+ key: 'PUBLIC_URL',
+ value:
+ `enc:v2:deadbeef:https://example.com|${workspaceA}` as EncryptedString,
+ }),
+ makeFlatVariable({
+ universalIdentifier: 'variable-2',
+ key: 'API_SECRET',
+ value: `enc:v2:deadbeef:secret-123|${workspaceA}` as EncryptedString,
+ isSecret: true,
+ }),
+ ]);
+
+ const result = await service.getPublicEnvVariables({
+ workspaceId: workspaceA,
+ applicationId: mockApplicationId,
+ });
+
+ expect(result).toEqual({ PUBLIC_URL: 'https://example.com' });
+ expect(
+ secretEncryptionService.decryptVersionedOrThrow,
+ ).toHaveBeenCalledTimes(1);
+ });
+ });
+
describe('update', () => {
it('should encrypt value with workspaceId-scoped envelope when variable is secret', async () => {
const existingVariable = {
diff --git a/packages/twenty-server/src/engine/core-modules/application/application-variable/application-variable.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-variable/application-variable.service.ts
index 28c30d4b0dc..7d1b59ec53b 100644
--- a/packages/twenty-server/src/engine/core-modules/application/application-variable/application-variable.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/application/application-variable/application-variable.service.ts
@@ -10,10 +10,18 @@ import {
ApplicationVariableEntityExceptionCode,
} from 'src/engine/core-modules/application/application-variable/application-variable.exception';
import { SECRET_APPLICATION_VARIABLE_MASK } from 'src/engine/core-modules/application/application-variable/constants/secret-application-variable-mask.constant';
+import { type ApplicationVariableCacheMaps } from 'src/engine/core-modules/application/application-variable/types/application-variable-cache-maps.type';
import { type PlaintextString } from 'src/engine/core-modules/secret-encryption/branded-strings/plaintext-string.type';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
+import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
+type GetEnvVariablesArgs = {
+ workspaceId: string;
+ applicationId: string;
+ applicationVariableMaps?: ApplicationVariableCacheMaps;
+};
+
@Injectable()
export class ApplicationVariableEntityService {
constructor(
@@ -42,6 +50,80 @@ export class ApplicationVariableEntityService {
);
}
+ async getServerEnvVariables(
+ args: GetEnvVariablesArgs,
+ ): Promise> {
+ const flatApplicationVariables =
+ await this.findFlatApplicationVariables(args);
+
+ return this.toEnvVariables(flatApplicationVariables);
+ }
+
+ async getPublicEnvVariables(
+ args: GetEnvVariablesArgs,
+ ): Promise> {
+ const flatApplicationVariables =
+ await this.findFlatApplicationVariables(args);
+
+ return this.toEnvVariables(
+ flatApplicationVariables.filter(({ isSecret }) => !isSecret),
+ );
+ }
+
+ private async findFlatApplicationVariables({
+ workspaceId,
+ applicationId,
+ applicationVariableMaps: preloadedApplicationVariableMaps,
+ }: GetEnvVariablesArgs): Promise {
+ const applicationVariableMaps =
+ preloadedApplicationVariableMaps ??
+ (
+ await this.workspaceCacheService.getOrRecompute(workspaceId, [
+ 'applicationVariableMaps',
+ ])
+ ).applicationVariableMaps;
+
+ const universalIdentifiers =
+ applicationVariableMaps.universalIdentifiersByApplicationId[
+ applicationId
+ ] ?? [];
+
+ return universalIdentifiers
+ .map(
+ (universalIdentifier) =>
+ applicationVariableMaps.byUniversalIdentifier[universalIdentifier],
+ )
+ .filter(isDefined);
+ }
+
+ private toEnvVariables(
+ flatApplicationVariables: FlatApplicationVariable[],
+ ): Record {
+ return flatApplicationVariables.reduce>(
+ (acc, flatApplicationVariable) => {
+ acc[flatApplicationVariable.key] = this.decryptValue(
+ flatApplicationVariable,
+ );
+
+ return acc;
+ },
+ {},
+ );
+ }
+
+ private decryptValue({
+ value,
+ workspaceId,
+ }: FlatApplicationVariable): string {
+ if (value === '') {
+ return '';
+ }
+
+ return this.secretEncryptionService.decryptVersionedOrThrow(value, {
+ workspaceId,
+ });
+ }
+
async update({
key,
plainTextValue,
diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.module.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.module.ts
index 760e42df13f..45461347327 100644
--- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.module.ts
+++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.module.ts
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
+import { ApplicationVariableEntityModule } from 'src/engine/core-modules/application/application-variable/application-variable.module';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { EventLogEmitterModule } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.module';
import { EventLogLiveModule } from 'src/engine/core-modules/event-logs/live/event-log-live.module';
@@ -29,6 +30,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
FeatureFlagModule,
WorkspaceDomainsModule,
ApplicationModule,
+ ApplicationVariableEntityModule,
TypeOrmModule.forFeature([
ApplicationRegistrationVariableEntity,
WorkspaceEntity,
diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service.ts
index e119ddee170..d3a69b5d559 100644
--- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service.ts
@@ -23,10 +23,11 @@ import { parseApplicationLogLines } from 'src/engine/core-modules/event-logs/pro
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
import { ApplicationStopService } from 'src/engine/core-modules/application/application-stop/application-stop.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
-import type { FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type';
import { FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { EventLogEmitterService } from 'src/engine/core-modules/event-logs/emit/event-log-emitter.service';
import { LOGIC_FUNCTION_EXECUTED_EVENT } from 'src/engine/core-modules/event-logs/emit/events/workspace-event/logic-function/logic-function-executed';
+import { ApplicationVariableEntityService } from 'src/engine/core-modules/application/application-variable/application-variable.service';
+import { type ApplicationVariableCacheMaps } from 'src/engine/core-modules/application/application-variable/types/application-variable-cache-maps.type';
import { isBillingExemptApplication } from 'src/engine/core-modules/application/application-marketplace/utils/is-billing-exempt-application.util';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { NO_BILLING_SUBSCRIPTION } from 'src/engine/core-modules/billing/constants/no-billing-subscription.constant';
@@ -35,7 +36,6 @@ import { BillingService } from 'src/engine/core-modules/billing/services/billing
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { LogicFunctionDriverFactory } from 'src/engine/core-modules/logic-function/logic-function-drivers/logic-function-driver.factory';
-import { buildEnvVar } from 'src/engine/core-modules/logic-function/logic-function-executor/utils/build-env-var';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
import { ThrottlerService } from 'src/engine/core-modules/throttler/throttler.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -85,6 +85,7 @@ export class LogicFunctionExecutorService {
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly applicationTokenService: ApplicationTokenService,
private readonly secretEncryptionService: SecretEncryptionService,
+ private readonly applicationVariableService: ApplicationVariableEntityService,
private readonly subscriptionService: SubscriptionService,
private readonly eventLogLiveService: EventLogLiveService,
private readonly eventLogEmitterService: EventLogEmitterService,
@@ -116,7 +117,7 @@ export class LogicFunctionExecutorService {
userWorkspaceId?: string;
executionMode?: LogicFunctionExecutionMode;
}): Promise {
- const { flatApplication, flatLogicFunction, flatApplicationVariables } =
+ const { flatApplication, flatLogicFunction, applicationVariableMaps } =
await this.getFlatEntitiesOrThrow({
workspaceId,
logicFunctionId,
@@ -131,7 +132,7 @@ export class LogicFunctionExecutorService {
const envVariables = await this.getExecutionEnvVariables({
workspaceId,
flatApplication,
- flatApplicationVariables,
+ applicationVariableMaps,
userId,
userWorkspaceId,
});
@@ -314,31 +315,19 @@ export class LogicFunctionExecutorService {
);
}
- const flatApplicationVariableUniversalIdentifiers =
- applicationVariableMaps.universalIdentifiersByApplicationId[
- flatApplication.id
- ] ?? [];
-
- const flatApplicationVariables = flatApplicationVariableUniversalIdentifiers
- .map(
- (universalIdentifier) =>
- applicationVariableMaps.byUniversalIdentifier[universalIdentifier],
- )
- .filter(isDefined);
-
- return { flatApplication, flatLogicFunction, flatApplicationVariables };
+ return { flatApplication, flatLogicFunction, applicationVariableMaps };
}
private async getExecutionEnvVariables({
workspaceId,
flatApplication,
- flatApplicationVariables,
+ applicationVariableMaps,
userId,
userWorkspaceId,
}: {
workspaceId: string;
flatApplication: FlatApplication;
- flatApplicationVariables: FlatApplicationVariable[];
+ applicationVariableMaps: ApplicationVariableCacheMaps;
userId?: string;
userWorkspaceId?: string;
}) {
@@ -359,10 +348,12 @@ export class LogicFunctionExecutorService {
const serverVariables = await this.buildServerVariableEnvMap(
flatApplication.applicationRegistrationId,
);
- const workspaceVariables = buildEnvVar(
- flatApplicationVariables,
- this.secretEncryptionService,
- );
+ const workspaceVariables =
+ await this.applicationVariableService.getServerEnvVariables({
+ workspaceId,
+ applicationId: flatApplication.id,
+ applicationVariableMaps,
+ });
return {
[DEFAULT_API_URL_NAME]: baseUrl ?? '',
diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/utils/__tests__/build-env-var.spec.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/utils/__tests__/build-env-var.spec.ts
deleted file mode 100644
index f01e9e1c901..00000000000
--- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/utils/__tests__/build-env-var.spec.ts
+++ /dev/null
@@ -1,210 +0,0 @@
-import { FieldMetadataType } from 'twenty-shared/types';
-
-import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type';
-import { buildEnvVar } from 'src/engine/core-modules/logic-function/logic-function-executor/utils/build-env-var';
-import { type SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
-import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type';
-
-describe('buildEnvVar', () => {
- const workspaceA = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
- const workspaceB = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
-
- const mockSecretEncryptionService = {
- encryptVersioned: jest.fn(
- (value: string, opts?: { workspaceId?: string }) =>
- `enc:v2:deadbeef:${value}|${opts?.workspaceId ?? 'instance'}`,
- ),
- decryptVersionedOrThrow: jest.fn(
- (value: string, _opts?: { workspaceId?: string }) =>
- value.replace(/^enc:v2:[0-9a-f]+:/, '').replace(/\|.*$/, ''),
- ),
- } as unknown as SecretEncryptionService;
-
- beforeEach(() => {
- jest.clearAllMocks();
- });
-
- it('should return empty object for empty array', () => {
- const result = buildEnvVar([], mockSecretEncryptionService);
-
- expect(result).toEqual({});
- });
-
- it('should decrypt all encrypted variables regardless of isSecret', () => {
- const flatVariables: FlatApplicationVariable[] = [
- {
- id: '1',
- key: 'PUBLIC_URL',
- value:
- `enc:v2:deadbeef:https://example.com|${workspaceA}` as EncryptedString,
- description: 'Public URL',
- isSecret: false,
- type: FieldMetadataType.TEXT,
- options: null,
- applicationId: 'app-1',
- workspaceId: workspaceA,
- universalIdentifier: '00000000-0000-0000-0000-000000000000',
- applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000',
- createdAt: '2024-01-01T00:00:00.000Z',
- updatedAt: '2024-01-01T00:00:00.000Z',
- },
- {
- id: '2',
- key: 'API_SECRET',
- value: `enc:v2:deadbeef:secret-123|${workspaceA}` as EncryptedString,
- description: 'API secret',
- isSecret: true,
- type: FieldMetadataType.TEXT,
- options: null,
- applicationId: 'app-1',
- workspaceId: workspaceA,
- universalIdentifier: '00000000-0000-0000-0000-000000000000',
- applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000',
- createdAt: '2024-01-01T00:00:00.000Z',
- updatedAt: '2024-01-01T00:00:00.000Z',
- },
- {
- id: '3',
- key: 'DEBUG',
- value: `enc:v2:deadbeef:true|${workspaceA}` as EncryptedString,
- description: 'Debug flag',
- isSecret: false,
- type: FieldMetadataType.TEXT,
- options: null,
- applicationId: 'app-1',
- workspaceId: workspaceA,
- universalIdentifier: '00000000-0000-0000-0000-000000000000',
- applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000',
- createdAt: '2024-01-01T00:00:00.000Z',
- updatedAt: '2024-01-01T00:00:00.000Z',
- },
- ];
-
- const result = buildEnvVar(flatVariables, mockSecretEncryptionService);
-
- expect(result).toEqual({
- PUBLIC_URL: 'https://example.com',
- API_SECRET: 'secret-123',
- DEBUG: 'true',
- });
- expect(
- mockSecretEncryptionService.decryptVersionedOrThrow,
- ).toHaveBeenCalledTimes(3);
- });
-
- it('routes each secret variable to its own workspace HKDF context', () => {
- const flatVariables: FlatApplicationVariable[] = [
- {
- id: '1',
- key: 'A_SECRET',
- value: `enc:v2:deadbeef:value-a|${workspaceA}` as EncryptedString,
- description: '',
- isSecret: true,
- type: FieldMetadataType.TEXT,
- options: null,
- applicationId: 'app-1',
- workspaceId: workspaceA,
- universalIdentifier: '00000000-0000-0000-0000-000000000000',
- applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000',
- createdAt: '2024-01-01T00:00:00.000Z',
- updatedAt: '2024-01-01T00:00:00.000Z',
- },
- {
- id: '2',
- key: 'B_SECRET',
- value: `enc:v2:deadbeef:value-b|${workspaceB}` as EncryptedString,
- description: '',
- isSecret: true,
- type: FieldMetadataType.TEXT,
- options: null,
- applicationId: 'app-1',
- workspaceId: workspaceB,
- universalIdentifier: '00000000-0000-0000-0000-000000000000',
- applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000',
- createdAt: '2024-01-01T00:00:00.000Z',
- updatedAt: '2024-01-01T00:00:00.000Z',
- },
- ];
-
- buildEnvVar(flatVariables, mockSecretEncryptionService);
-
- expect(
- mockSecretEncryptionService.decryptVersionedOrThrow,
- ).toHaveBeenCalledWith(`enc:v2:deadbeef:value-a|${workspaceA}`, {
- workspaceId: workspaceA,
- });
- expect(
- mockSecretEncryptionService.decryptVersionedOrThrow,
- ).toHaveBeenCalledWith(`enc:v2:deadbeef:value-b|${workspaceB}`, {
- workspaceId: workspaceB,
- });
- });
-
- it('should handle null or undefined values', () => {
- const flatVariables: FlatApplicationVariable[] = [
- {
- id: '1',
- key: 'NULL_VALUE',
- value: null as unknown as EncryptedString | '',
- description: '',
- isSecret: false,
- type: FieldMetadataType.TEXT,
- options: null,
- applicationId: 'app-1',
- workspaceId: workspaceA,
- universalIdentifier: '00000000-0000-0000-0000-000000000000',
- applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000',
- createdAt: '2024-01-01T00:00:00.000Z',
- updatedAt: '2024-01-01T00:00:00.000Z',
- },
- {
- id: '2',
- key: 'UNDEFINED_VALUE',
- value: undefined as unknown as EncryptedString | '',
- description: '',
- isSecret: false,
- type: FieldMetadataType.TEXT,
- options: null,
- applicationId: 'app-1',
- workspaceId: workspaceA,
- universalIdentifier: '00000000-0000-0000-0000-000000000000',
- applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000',
- createdAt: '2024-01-01T00:00:00.000Z',
- updatedAt: '2024-01-01T00:00:00.000Z',
- },
- ];
-
- const result = buildEnvVar(flatVariables, mockSecretEncryptionService);
-
- expect(result).toEqual({
- NULL_VALUE: '',
- UNDEFINED_VALUE: '',
- });
- });
-
- it('should convert non-string values to strings', () => {
- const flatVariables: FlatApplicationVariable[] = [
- {
- id: '1',
- key: 'NUMBER_VALUE',
- value: 123 as unknown as EncryptedString | '',
- description: '',
- isSecret: false,
- type: FieldMetadataType.TEXT,
- options: null,
- applicationId: 'app-1',
- workspaceId: workspaceA,
- universalIdentifier: '00000000-0000-0000-0000-000000000000',
- applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000',
- createdAt: '2024-01-01T00:00:00.000Z',
- updatedAt: '2024-01-01T00:00:00.000Z',
- },
- ];
-
- const result = buildEnvVar(flatVariables, mockSecretEncryptionService);
-
- expect(result).toEqual({
- NUMBER_VALUE: '123',
- });
- });
-});
diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/utils/build-env-var.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/utils/build-env-var.ts
deleted file mode 100644
index 58a862471c1..00000000000
--- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/utils/build-env-var.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { isNonEmptyString } from '@sniptt/guards';
-
-import { isEncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/is-encrypted-string.util';
-import { type SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
-import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type';
-
-export const buildEnvVar = (
- flatApplicationVariables: FlatApplicationVariable[],
- secretEncryptionService: SecretEncryptionService,
-): Record => {
- return flatApplicationVariables.reduce>(
- (acc, flatApplicationVariable) => {
- const value = String(flatApplicationVariable.value ?? '');
-
- // TODO: After 2-9 slow instance command has run everywhere, turn
- // the else branch into an invariant violation for non-empty values.
- acc[flatApplicationVariable.key] =
- isNonEmptyString(value) && isEncryptedString(value)
- ? secretEncryptionService.decryptVersionedOrThrow(value, {
- workspaceId: flatApplicationVariable.workspaceId,
- })
- : value;
-
- return acc;
- },
- {},
- );
-};
diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.module.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.module.ts
index 52969ec5704..bf840518f15 100644
--- a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.module.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.module.ts
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
+import { ApplicationVariableEntityModule } from 'src/engine/core-modules/application/application-variable/application-variable.module';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { FlatFrontComponentModule } from 'src/engine/metadata-modules/flat-front-component/flat-front-component.module';
@@ -21,6 +22,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
WorkspaceManyOrAllFlatEntityMapsCacheModule,
WorkspaceMigrationModule,
ApplicationModule,
+ ApplicationVariableEntityModule,
TokenModule,
PermissionsModule,
FlatFrontComponentModule,
diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.resolver.ts
index 42376ad8e34..b5f889f590b 100644
--- a/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.resolver.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/front-component/front-component.resolver.ts
@@ -2,10 +2,10 @@ import { Inject, UseGuards, UseInterceptors } from '@nestjs/common';
import { Args, Mutation, Query } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
-import { isDefined } from 'twenty-shared/utils';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
+import { ApplicationVariableEntityService } from 'src/engine/core-modules/application/application-variable/application-variable.service';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -22,8 +22,6 @@ import { FrontComponentDTO } from 'src/engine/metadata-modules/front-component/d
import { UpdateFrontComponentInput } from 'src/engine/metadata-modules/front-component/dtos/update-front-component.input';
import { FrontComponentService } from 'src/engine/metadata-modules/front-component/front-component.service';
import { FrontComponentGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/front-component/interceptors/front-component-graphql-api-exception.interceptor';
-import { stripSecretFromApplicationVariables } from 'src/engine/metadata-modules/front-component/utils/strip-secret-from-application-variables';
-import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
@UseGuards(WorkspaceAuthGuard)
@@ -38,7 +36,7 @@ export class FrontComponentResolver {
private readonly frontComponentService: FrontComponentService,
@Inject(ApplicationTokenService)
private readonly applicationTokenService: ApplicationTokenService,
- private readonly workspaceCacheService: WorkspaceCacheService,
+ private readonly applicationVariableService: ApplicationVariableEntityService,
) {}
@Query(() => [FrontComponentDTO])
@@ -71,26 +69,11 @@ export class FrontComponentResolver {
userId: user.id,
});
- const { applicationVariableMaps } =
- await this.workspaceCacheService.getOrRecompute(workspace.id, [
- 'applicationVariableMaps',
- ]);
-
- const variableUniversalIdentifiers =
- applicationVariableMaps.universalIdentifiersByApplicationId[
- dto.applicationId
- ] ?? [];
-
- const flatApplicationVariables = variableUniversalIdentifiers
- .map(
- (universalIdentifier) =>
- applicationVariableMaps.byUniversalIdentifier[universalIdentifier],
- )
- .filter(isDefined);
-
- const applicationVariables = stripSecretFromApplicationVariables(
- flatApplicationVariables,
- );
+ const applicationVariables =
+ await this.applicationVariableService.getPublicEnvVariables({
+ workspaceId: workspace.id,
+ applicationId: dto.applicationId,
+ });
return {
...dto,
diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/utils/__tests__/strip-secret-from-application-variables.spec.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/utils/__tests__/strip-secret-from-application-variables.spec.ts
deleted file mode 100644
index 927e1711a98..00000000000
--- a/packages/twenty-server/src/engine/metadata-modules/front-component/utils/__tests__/strip-secret-from-application-variables.spec.ts
+++ /dev/null
@@ -1,127 +0,0 @@
-import { FieldMetadataType } from 'twenty-shared/types';
-
-import { type EncryptedString } from 'src/engine/core-modules/secret-encryption/branded-strings/encrypted-string.type';
-import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type';
-import { stripSecretFromApplicationVariables } from 'src/engine/metadata-modules/front-component/utils/strip-secret-from-application-variables';
-
-const makeFlatVariable = (
- overrides: Partial,
-): FlatApplicationVariable => ({
- id: '1',
- key: 'KEY',
- value: 'value' as EncryptedString,
- description: '',
- isSecret: false,
- type: FieldMetadataType.TEXT,
- options: null,
- applicationId: 'app-1',
- workspaceId: '00000000-0000-0000-0000-000000000000',
- universalIdentifier: '00000000-0000-0000-0000-000000000000',
- applicationUniversalIdentifier: '00000000-0000-0000-0000-000000000000',
- createdAt: '2024-01-01T00:00:00.000Z',
- updatedAt: '2024-01-01T00:00:00.000Z',
- ...overrides,
-});
-
-describe('stripSecretFromApplicationVariables', () => {
- it('should return empty object for empty array', () => {
- expect(stripSecretFromApplicationVariables([])).toEqual({});
- });
-
- it('should include non-secret variables', () => {
- const variables = [
- makeFlatVariable({
- key: 'PUBLIC_URL',
- value: 'https://example.com' as EncryptedString,
- }),
- makeFlatVariable({
- id: '2',
- key: 'DEBUG',
- value: 'true' as EncryptedString,
- }),
- ];
-
- expect(stripSecretFromApplicationVariables(variables)).toEqual({
- PUBLIC_URL: 'https://example.com',
- DEBUG: 'true',
- });
- });
-
- it('should exclude secret variables', () => {
- const variables = [
- makeFlatVariable({
- key: 'PUBLIC_URL',
- value: 'https://example.com' as EncryptedString,
- }),
- makeFlatVariable({
- id: '2',
- key: 'API_SECRET',
- value: 'encrypted_secret' as EncryptedString,
- isSecret: true,
- }),
- makeFlatVariable({
- id: '3',
- key: 'DEBUG',
- value: 'true' as EncryptedString,
- }),
- ];
-
- const result = stripSecretFromApplicationVariables(variables);
-
- expect(result).toEqual({
- PUBLIC_URL: 'https://example.com',
- DEBUG: 'true',
- });
- expect(result).not.toHaveProperty('API_SECRET');
- });
-
- it('should handle null and undefined values', () => {
- const variables = [
- makeFlatVariable({
- key: 'NULL_VALUE',
- value: null as unknown as EncryptedString | '',
- }),
- makeFlatVariable({
- id: '2',
- key: 'UNDEFINED_VALUE',
- value: undefined as unknown as EncryptedString | '',
- }),
- ];
-
- expect(stripSecretFromApplicationVariables(variables)).toEqual({
- NULL_VALUE: '',
- UNDEFINED_VALUE: '',
- });
- });
-
- it('should convert non-string values to strings', () => {
- const variables = [
- makeFlatVariable({
- key: 'NUMBER_VALUE',
- value: 123 as unknown as EncryptedString | '',
- }),
- ];
-
- expect(stripSecretFromApplicationVariables(variables)).toEqual({
- NUMBER_VALUE: '123',
- });
- });
-
- it('should return empty object when all variables are secret', () => {
- const variables = [
- makeFlatVariable({
- key: 'SECRET_1',
- value: 'val1' as EncryptedString,
- isSecret: true,
- }),
- makeFlatVariable({
- id: '2',
- key: 'SECRET_2',
- value: 'val2' as EncryptedString,
- isSecret: true,
- }),
- ];
-
- expect(stripSecretFromApplicationVariables(variables)).toEqual({});
- });
-});
diff --git a/packages/twenty-server/src/engine/metadata-modules/front-component/utils/strip-secret-from-application-variables.ts b/packages/twenty-server/src/engine/metadata-modules/front-component/utils/strip-secret-from-application-variables.ts
deleted file mode 100644
index 0bf9a4c6078..00000000000
--- a/packages/twenty-server/src/engine/metadata-modules/front-component/utils/strip-secret-from-application-variables.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { type FlatApplicationVariable } from 'src/engine/metadata-modules/flat-application-variable/types/flat-application-variable.type';
-
-export const stripSecretFromApplicationVariables = (
- flatApplicationVariables: FlatApplicationVariable[],
-): Record => {
- return flatApplicationVariables.reduce>(
- (acc, flatApplicationVariable) => {
- if (flatApplicationVariable.isSecret) {
- return acc;
- }
-
- acc[flatApplicationVariable.key] = String(
- flatApplicationVariable.value ?? '',
- );
-
- return acc;
- },
- {},
- );
-};
diff --git a/packages/twenty-server/test/integration/metadata/suites/front-component/successful-front-component-application-variables.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/front-component/successful-front-component-application-variables.integration-spec.ts
new file mode 100644
index 00000000000..f296c6f12c2
--- /dev/null
+++ b/packages/twenty-server/test/integration/metadata/suites/front-component/successful-front-component-application-variables.integration-spec.ts
@@ -0,0 +1,134 @@
+import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
+import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
+import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
+import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
+import { uploadApplicationFile } from 'test/integration/metadata/suites/application/utils/upload-application-file.util';
+import { findFrontComponent } from 'test/integration/metadata/suites/front-component/utils/find-front-component.util';
+import { findFrontComponents } from 'test/integration/metadata/suites/front-component/utils/find-front-components.util';
+import { type Manifest } from 'twenty-shared/application';
+import { isDefined } from 'twenty-shared/utils';
+import { v4 as uuidv4 } from 'uuid';
+
+const TEST_APP_ID = uuidv4();
+const TEST_ROLE_ID = uuidv4();
+const FRONT_COMPONENT_ID = uuidv4();
+const PUBLIC_VARIABLE_ID = uuidv4();
+const SECRET_VARIABLE_ID = uuidv4();
+
+const BUILT_COMPONENT_PATH = 'src/front-components/variables.mjs';
+const PUBLIC_VARIABLE_VALUE = 'pk.public-access-token';
+
+const buildManifest = (): Manifest => {
+ const baseManifest = buildBaseManifest({
+ appId: TEST_APP_ID,
+ roleId: TEST_ROLE_ID,
+ });
+
+ return {
+ ...baseManifest,
+ application: {
+ ...baseManifest.application,
+ applicationVariables: {
+ PUBLIC_ACCESS_TOKEN: {
+ universalIdentifier: PUBLIC_VARIABLE_ID,
+ value: PUBLIC_VARIABLE_VALUE,
+ },
+ API_SECRET: {
+ universalIdentifier: SECRET_VARIABLE_ID,
+ isSecret: true,
+ },
+ },
+ },
+ frontComponents: [
+ {
+ universalIdentifier: FRONT_COMPONENT_ID,
+ name: 'VariablesComponent',
+ description: 'A front component reading application variables',
+ sourceComponentPath: 'src/front-components/variables.tsx',
+ builtComponentPath: BUILT_COMPONENT_PATH,
+ builtComponentChecksum: 'variables-checksum',
+ componentName: 'VariablesComponent',
+ isHeadless: false,
+ },
+ ],
+ };
+};
+
+describe('Front component application variables', () => {
+ let frontComponentId: string;
+
+ beforeAll(async () => {
+ await setupApplicationForSync({
+ applicationUniversalIdentifier: TEST_APP_ID,
+ name: 'Test Application Variables App',
+ description: 'App for testing front component application variables',
+ sourcePath: 'test-application-variables',
+ });
+
+ jest.useRealTimers();
+
+ await uploadApplicationFile({
+ applicationUniversalIdentifier: TEST_APP_ID,
+ fileFolder: 'BuiltFrontComponent',
+ filePath: BUILT_COMPONENT_PATH,
+ fileBuffer: Buffer.from('dummy built component content'),
+ filename: 'variables.mjs',
+ contentType: 'application/javascript',
+ expectToFail: false,
+ });
+
+ jest.useFakeTimers();
+
+ await syncApplication({
+ manifest: buildManifest(),
+ expectToFail: false,
+ });
+
+ const { data } = await findFrontComponents({});
+
+ const syncedFrontComponent = data.frontComponents.find(
+ ({ universalIdentifier }) => universalIdentifier === FRONT_COMPONENT_ID,
+ );
+
+ if (!isDefined(syncedFrontComponent)) {
+ throw new Error('Synced front component was not found');
+ }
+
+ frontComponentId = syncedFrontComponent.id;
+ }, 60000);
+
+ afterAll(async () => {
+ await cleanupApplicationAndAppRegistration({
+ applicationUniversalIdentifier: TEST_APP_ID,
+ });
+ });
+
+ it('should store application variable values encrypted at rest', async () => {
+ const rows = await globalThis.testDataSource.query(
+ `SELECT key, value FROM core."applicationVariable"
+ WHERE "universalIdentifier" = ANY($1)`,
+ [[PUBLIC_VARIABLE_ID, SECRET_VARIABLE_ID]],
+ );
+
+ const publicVariable = rows.find(
+ ({ key }: { key: string }) => key === 'PUBLIC_ACCESS_TOKEN',
+ );
+
+ expect(publicVariable.value).toMatch(/^enc:v2:/);
+ expect(publicVariable.value).not.toContain(PUBLIC_VARIABLE_VALUE);
+ });
+
+ it('should expose non-secret application variables decrypted and exclude secret ones', async () => {
+ const { data } = await findFrontComponent({
+ input: { id: frontComponentId },
+ gqlFields: `
+ id
+ applicationVariables
+ `,
+ });
+
+ expect(data.frontComponent.applicationVariables).toEqual({
+ PUBLIC_ACCESS_TOKEN: PUBLIC_VARIABLE_VALUE,
+ });
+ });
+});
diff --git a/packages/twenty-server/test/integration/metadata/suites/front-component/utils/find-front-components-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/front-component/utils/find-front-components-query-factory.util.ts
new file mode 100644
index 00000000000..623f2a012b7
--- /dev/null
+++ b/packages/twenty-server/test/integration/metadata/suites/front-component/utils/find-front-components-query-factory.util.ts
@@ -0,0 +1,21 @@
+import gql from 'graphql-tag';
+import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
+
+const DEFAULT_FRONT_COMPONENTS_GQL_FIELDS = `
+ id
+ name
+ universalIdentifier
+ applicationId
+`;
+
+export const findFrontComponentsQueryFactory = ({
+ gqlFields = DEFAULT_FRONT_COMPONENTS_GQL_FIELDS,
+}: Partial>) => ({
+ query: gql`
+ query FrontComponents {
+ frontComponents {
+ ${gqlFields}
+ }
+ }
+ `,
+});
diff --git a/packages/twenty-server/test/integration/metadata/suites/front-component/utils/find-front-components.util.ts b/packages/twenty-server/test/integration/metadata/suites/front-component/utils/find-front-components.util.ts
new file mode 100644
index 00000000000..f9c625a78fb
--- /dev/null
+++ b/packages/twenty-server/test/integration/metadata/suites/front-component/utils/find-front-components.util.ts
@@ -0,0 +1,36 @@
+import { findFrontComponentsQueryFactory } from 'test/integration/metadata/suites/front-component/utils/find-front-components-query-factory.util';
+import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
+import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type';
+import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type';
+import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util';
+import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util';
+
+import { type FrontComponentDTO } from 'src/engine/metadata-modules/front-component/dtos/front-component.dto';
+
+export const findFrontComponents = async ({
+ gqlFields,
+ expectToFail = false,
+ token,
+}: Partial>): CommonResponseBody<{
+ frontComponents: FrontComponentDTO[];
+}> => {
+ const graphqlOperation = findFrontComponentsQueryFactory({ gqlFields });
+
+ const response = await makeMetadataAPIRequest(graphqlOperation, token);
+
+ if (expectToFail === true) {
+ warnIfNoErrorButExpectedToFail({
+ response,
+ errorMessage: 'Finding front components should have failed but did not',
+ });
+ }
+
+ if (expectToFail === false) {
+ warnIfErrorButNotExpectedToFail({
+ response,
+ errorMessage: 'Finding front components has failed but should not',
+ });
+ }
+
+ return { data: response.body.data, errors: response.body.errors };
+};