Twenty config admin panel integration (#11755)

closes https://github.com/twentyhq/core-team-issues/issues/761
closes https://github.com/twentyhq/core-team-issues/issues/762

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
nitin
2025-04-30 12:42:59 +05:30
committed by GitHub
parent 842367f7bb
commit e957b1acd6
73 changed files with 2958 additions and 853 deletions

View File

@ -16,6 +16,7 @@ import { User } from 'src/engine/core-modules/user/user.entity';
const UserFindOneMock = jest.fn();
const LoginTokenServiceGenerateLoginTokenMock = jest.fn();
const TwentyConfigServiceGetAllMock = jest.fn();
const TwentyConfigServiceGetVariableWithMetadataMock = jest.fn();
jest.mock(
'src/engine/core-modules/twenty-config/constants/config-variables-group-metadata',
@ -72,6 +73,8 @@ describe('AdminPanelService', () => {
provide: TwentyConfigService,
useValue: {
getAll: TwentyConfigServiceGetAllMock,
getVariableWithMetadata:
TwentyConfigServiceGetVariableWithMetadataMock,
},
},
],
@ -165,14 +168,20 @@ describe('AdminPanelService', () => {
metadata: {
group: 'SERVER_CONFIG',
description: 'Server URL',
type: 'string',
options: undefined,
},
source: 'env',
},
RATE_LIMIT_TTL: {
value: '60',
value: 60,
metadata: {
group: 'RATE_LIMITING',
description: 'Rate limit TTL',
type: 'number',
options: undefined,
},
source: 'env',
},
API_KEY: {
value: 'secret-key',
@ -180,14 +189,20 @@ describe('AdminPanelService', () => {
group: 'SERVER_CONFIG',
description: 'API Key',
isSensitive: true,
type: 'string',
options: undefined,
},
source: 'env',
},
OTHER_VAR: {
value: 'other',
metadata: {
group: 'OTHER',
description: 'Other var',
type: 'string',
options: undefined,
},
source: 'env',
},
});
@ -205,12 +220,20 @@ describe('AdminPanelService', () => {
value: 'secret-key',
description: 'API Key',
isSensitive: true,
isEnvOnly: false,
type: 'string',
options: undefined,
source: 'env',
},
{
name: 'SERVER_URL',
value: 'http://localhost',
description: 'Server URL',
isSensitive: false,
isEnvOnly: false,
type: 'string',
options: undefined,
source: 'env',
},
],
},
@ -221,9 +244,13 @@ describe('AdminPanelService', () => {
variables: [
{
name: 'RATE_LIMIT_TTL',
value: '60',
value: 60,
description: 'Rate limit TTL',
isSensitive: false,
isEnvOnly: false,
type: 'number',
options: undefined,
source: 'env',
},
],
},
@ -237,6 +264,10 @@ describe('AdminPanelService', () => {
value: 'other',
description: 'Other var',
isSensitive: false,
isEnvOnly: false,
type: 'string',
options: undefined,
source: 'env',
},
],
},
@ -264,7 +295,10 @@ describe('AdminPanelService', () => {
value: 'test',
metadata: {
group: 'SERVER_CONFIG',
type: 'string',
options: undefined,
},
source: 'env',
},
});
@ -275,6 +309,10 @@ describe('AdminPanelService', () => {
value: 'test',
description: undefined,
isSensitive: false,
isEnvOnly: false,
options: undefined,
source: 'env',
type: 'string',
});
});
});
@ -376,4 +414,42 @@ describe('AdminPanelService', () => {
});
});
});
describe('getConfigVariable', () => {
it('should return config variable with all fields', () => {
TwentyConfigServiceGetVariableWithMetadataMock.mockReturnValue({
value: 'test-value',
metadata: {
group: 'SERVER_CONFIG',
description: 'Test description',
isSensitive: true,
isEnvOnly: true,
type: 'string',
options: ['option1', 'option2'],
},
source: 'env',
});
const result = service.getConfigVariable('SERVER_URL');
expect(result).toEqual({
name: 'SERVER_URL',
value: 'test-value',
description: 'Test description',
isSensitive: true,
isEnvOnly: true,
type: 'string',
options: ['option1', 'option2'],
source: 'env',
});
});
it('should throw error when variable not found', () => {
TwentyConfigServiceGetVariableWithMetadataMock.mockReturnValue(undefined);
expect(() => service.getConfigVariable('INVALID_VAR')).toThrow(
'Config variable INVALID_VAR not found',
);
});
});
});

View File

@ -1,8 +1,11 @@
import { UseFilters, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import GraphQLJSON from 'graphql-type-json';
import { AdminPanelHealthService } from 'src/engine/core-modules/admin-panel/admin-panel-health.service';
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service';
import { ConfigVariable } from 'src/engine/core-modules/admin-panel/dtos/config-variable.dto';
import { ConfigVariablesOutput } from 'src/engine/core-modules/admin-panel/dtos/config-variables.output';
import { ImpersonateInput } from 'src/engine/core-modules/admin-panel/dtos/impersonate.input';
import { ImpersonateOutput } from 'src/engine/core-modules/admin-panel/dtos/impersonate.output';
@ -18,6 +21,9 @@ import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/service
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { HealthIndicatorId } from 'src/engine/core-modules/health/enums/health-indicator-id.enum';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
import { ConfigVariableGraphqlApiExceptionFilter } from 'src/engine/core-modules/twenty-config/filters/config-variable-graphql-api-exception.filter';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { AdminPanelGuard } from 'src/engine/guards/admin-panel-guard';
import { ImpersonateGuard } from 'src/engine/guards/impersonate-guard';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
@ -27,12 +33,16 @@ import { AdminPanelHealthServiceData } from './dtos/admin-panel-health-service-d
import { QueueMetricsData } from './dtos/queue-metrics-data.dto';
@Resolver()
@UseFilters(AuthGraphqlApiExceptionFilter)
@UseFilters(
AuthGraphqlApiExceptionFilter,
ConfigVariableGraphqlApiExceptionFilter,
)
export class AdminPanelResolver {
constructor(
private adminService: AdminPanelService,
private adminPanelHealthService: AdminPanelHealthService,
private featureFlagService: FeatureFlagService,
private readonly twentyConfigService: TwentyConfigService,
) {}
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, ImpersonateGuard)
@ -119,4 +129,48 @@ export class AdminPanelResolver {
async versionInfo(): Promise<VersionInfo> {
return this.adminService.getVersionInfo();
}
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, AdminPanelGuard)
@Query(() => ConfigVariable)
async getDatabaseConfigVariable(
@Args('key', { type: () => String }) key: keyof ConfigVariables,
): Promise<ConfigVariable> {
this.twentyConfigService.validateConfigVariableExists(key as string);
return this.adminService.getConfigVariable(key);
}
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, AdminPanelGuard)
@Mutation(() => Boolean)
async createDatabaseConfigVariable(
@Args('key', { type: () => String }) key: keyof ConfigVariables,
@Args('value', { type: () => GraphQLJSON })
value: ConfigVariables[keyof ConfigVariables],
): Promise<boolean> {
await this.twentyConfigService.set(key, value);
return true;
}
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, AdminPanelGuard)
@Mutation(() => Boolean)
async updateDatabaseConfigVariable(
@Args('key', { type: () => String }) key: keyof ConfigVariables,
@Args('value', { type: () => GraphQLJSON })
value: ConfigVariables[keyof ConfigVariables],
): Promise<boolean> {
await this.twentyConfigService.update(key, value);
return true;
}
@UseGuards(WorkspaceAuthGuard, UserAuthGuard, AdminPanelGuard)
@Mutation(() => Boolean)
async deleteDatabaseConfigVariable(
@Args('key', { type: () => String }) key: keyof ConfigVariables,
): Promise<boolean> {
await this.twentyConfigService.delete(key);
return true;
}
}

View File

@ -18,6 +18,7 @@ import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/l
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
import { CONFIG_VARIABLES_GROUP_METADATA } from 'src/engine/core-modules/twenty-config/constants/config-variables-group-metadata';
import { ConfigVariablesGroup } from 'src/engine/core-modules/twenty-config/enums/config-variables-group.enum';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@ -127,14 +128,20 @@ export class AdminPanelService {
const rawEnvVars = this.twentyConfigService.getAll();
const groupedData = new Map<ConfigVariablesGroup, ConfigVariable[]>();
for (const [varName, { value, metadata }] of Object.entries(rawEnvVars)) {
for (const [varName, { value, metadata, source }] of Object.entries(
rawEnvVars,
)) {
const { group, description } = metadata;
const envVar: ConfigVariable = {
name: varName,
description,
value: String(value),
value: value ?? null,
isSensitive: metadata.isSensitive ?? false,
isEnvOnly: metadata.isEnvOnly ?? false,
type: metadata.type,
options: metadata.options,
source,
};
if (!groupedData.has(group)) {
@ -161,6 +168,30 @@ export class AdminPanelService {
return { groups };
}
getConfigVariable(key: string): ConfigVariable {
const variableWithMetadata =
this.twentyConfigService.getVariableWithMetadata(
key as keyof ConfigVariables,
);
if (!variableWithMetadata) {
throw new Error(`Config variable ${key} not found`);
}
const { value, metadata, source } = variableWithMetadata;
return {
name: key,
description: metadata.description ?? '',
value: value ?? null,
isSensitive: metadata.isSensitive ?? false,
isEnvOnly: metadata.isEnvOnly ?? false,
type: metadata.type,
options: metadata.options,
source,
};
}
async getVersionInfo(): Promise<VersionInfo> {
const currentVersion = this.twentyConfigService.get('APP_VERSION');

View File

@ -1,4 +1,19 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import GraphQLJSON from 'graphql-type-json';
import { ConfigVariableValue } from 'twenty-shared/types';
import { ConfigSource } from 'src/engine/core-modules/twenty-config/enums/config-source.enum';
import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/enums/config-variable-type.enum';
import { ConfigVariableOptions } from 'src/engine/core-modules/twenty-config/types/config-variable-options.type';
registerEnumType(ConfigSource, {
name: 'ConfigSource',
});
registerEnumType(ConfigVariableType, {
name: 'ConfigVariableType',
});
@ObjectType()
export class ConfigVariable {
@ -8,9 +23,21 @@ export class ConfigVariable {
@Field()
description: string;
@Field()
value: string;
@Field(() => GraphQLJSON, { nullable: true })
value: ConfigVariableValue;
@Field()
isSensitive: boolean;
@Field()
source: ConfigSource;
@Field()
isEnvOnly: boolean;
@Field(() => ConfigVariableType)
type: ConfigVariableType;
@Field(() => GraphQLJSON, { nullable: true })
options?: ConfigVariableOptions;
}

View File

@ -142,4 +142,7 @@ export class ClientConfig {
@Field(() => Boolean)
isGoogleCalendarEnabled: boolean;
@Field(() => Boolean)
isConfigVariablesInDbEnabled: boolean;
}

View File

@ -97,6 +97,9 @@ export class ClientConfigResolver {
isGoogleCalendarEnabled: this.twentyConfigService.get(
'CALENDAR_PROVIDER_GOOGLE_ENABLED',
),
isConfigVariablesInDbEnabled: this.twentyConfigService.get(
'IS_CONFIG_VARIABLES_IN_DB_ENABLED',
),
};
return Promise.resolve(clientConfig);

View File

@ -1,4 +1,4 @@
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import {
ConfigCacheEntry,
@ -8,7 +8,6 @@ import {
@Injectable()
export class ConfigCacheService implements OnModuleDestroy {
private readonly logger = new Logger(ConfigCacheService.name);
private readonly foundConfigValuesCache: Map<
ConfigKey,
ConfigCacheEntry<ConfigKey>

View File

@ -33,13 +33,18 @@ import { IsDuration } from 'src/engine/core-modules/twenty-config/decorators/is-
import { IsOptionalOrEmptyString } from 'src/engine/core-modules/twenty-config/decorators/is-optional-or-empty-string.decorator';
import { IsStrictlyLowerThan } from 'src/engine/core-modules/twenty-config/decorators/is-strictly-lower-than.decorator';
import { IsTwentySemVer } from 'src/engine/core-modules/twenty-config/decorators/is-twenty-semver.decorator';
import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/enums/config-variable-type.enum';
import { ConfigVariablesGroup } from 'src/engine/core-modules/twenty-config/enums/config-variables-group.enum';
import {
ConfigVariableException,
ConfigVariableExceptionCode,
} from 'src/engine/core-modules/twenty-config/twenty-config.exception';
export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.Other,
description: 'Enable or disable password authentication for users',
type: 'boolean',
type: ConfigVariableType.BOOLEAN,
})
@IsOptional()
AUTH_PASSWORD_ENABLED = true;
@ -48,7 +53,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.Other,
description:
'Prefills tim@apple.dev in the login form, used in local development for quicker sign-in',
type: 'boolean',
type: ConfigVariableType.BOOLEAN,
})
@IsOptional()
@ValidateIf((env) => env.AUTH_PASSWORD_ENABLED)
@ -57,7 +62,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.Other,
description: 'Require email verification for user accounts',
type: 'boolean',
type: ConfigVariableType.BOOLEAN,
})
@IsOptional()
IS_EMAIL_VERIFICATION_REQUIRED = false;
@ -65,7 +70,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.TokensDuration,
description: 'Duration for which the email verification token is valid',
type: 'string',
type: ConfigVariableType.STRING,
})
@IsDuration()
@IsOptional()
@ -74,7 +79,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.TokensDuration,
description: 'Duration for which the password reset token is valid',
type: 'string',
type: ConfigVariableType.STRING,
})
@IsDuration()
@IsOptional()
@ -83,30 +88,31 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.GoogleAuth,
description: 'Enable or disable the Google Calendar integration',
type: 'boolean',
type: ConfigVariableType.BOOLEAN,
})
CALENDAR_PROVIDER_GOOGLE_ENABLED = false;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.GoogleAuth,
description: 'Callback URL for Google Auth APIs',
type: 'string',
type: ConfigVariableType.STRING,
isSensitive: false,
})
AUTH_GOOGLE_APIS_CALLBACK_URL: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.GoogleAuth,
description: 'Enable or disable Google Single Sign-On (SSO)',
type: 'boolean',
type: ConfigVariableType.BOOLEAN,
})
@IsOptional()
AUTH_GOOGLE_ENABLED = false;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.GoogleAuth,
isSensitive: true,
isSensitive: false,
description: 'Client ID for Google authentication',
type: 'string',
type: ConfigVariableType.STRING,
})
@ValidateIf((env) => env.AUTH_GOOGLE_ENABLED)
AUTH_GOOGLE_CLIENT_ID: string;
@ -115,16 +121,16 @@ export class ConfigVariables {
group: ConfigVariablesGroup.GoogleAuth,
isSensitive: true,
description: 'Client secret for Google authentication',
type: 'string',
type: ConfigVariableType.STRING,
})
@ValidateIf((env) => env.AUTH_GOOGLE_ENABLED)
AUTH_GOOGLE_CLIENT_SECRET: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.GoogleAuth,
isSensitive: true,
isSensitive: false,
description: 'Callback URL for Google authentication',
type: 'string',
type: ConfigVariableType.STRING,
})
@IsUrl({ require_tld: false, require_protocol: true })
@ValidateIf((env) => env.AUTH_GOOGLE_ENABLED)
@ -133,23 +139,23 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.GoogleAuth,
description: 'Enable or disable the Gmail messaging integration',
type: 'boolean',
type: ConfigVariableType.BOOLEAN,
})
MESSAGING_PROVIDER_GMAIL_ENABLED = false;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.MicrosoftAuth,
description: 'Enable or disable Microsoft authentication',
type: 'boolean',
type: ConfigVariableType.BOOLEAN,
})
@IsOptional()
AUTH_MICROSOFT_ENABLED = false;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.MicrosoftAuth,
isSensitive: true,
isSensitive: false,
description: 'Client ID for Microsoft authentication',
type: 'string',
type: ConfigVariableType.STRING,
})
@ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED)
AUTH_MICROSOFT_CLIENT_ID: string;
@ -158,16 +164,16 @@ export class ConfigVariables {
group: ConfigVariablesGroup.MicrosoftAuth,
isSensitive: true,
description: 'Client secret for Microsoft authentication',
type: 'string',
type: ConfigVariableType.STRING,
})
@ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED)
AUTH_MICROSOFT_CLIENT_SECRET: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.MicrosoftAuth,
isSensitive: true,
isSensitive: false,
description: 'Callback URL for Microsoft authentication',
type: 'string',
type: ConfigVariableType.STRING,
})
@IsUrl({ require_tld: false, require_protocol: true })
@ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED)
@ -175,9 +181,9 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.MicrosoftAuth,
isSensitive: true,
isSensitive: false,
description: 'Callback URL for Microsoft APIs',
type: 'string',
type: ConfigVariableType.STRING,
})
@IsUrl({ require_tld: false, require_protocol: true })
@ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED)
@ -186,14 +192,14 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.MicrosoftAuth,
description: 'Enable or disable the Microsoft messaging integration',
type: 'boolean',
type: ConfigVariableType.BOOLEAN,
})
MESSAGING_PROVIDER_MICROSOFT_ENABLED = false;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.MicrosoftAuth,
description: 'Enable or disable the Microsoft Calendar integration',
type: 'boolean',
type: ConfigVariableType.BOOLEAN,
})
CALENDAR_PROVIDER_MICROSOFT_ENABLED = false;
@ -202,7 +208,7 @@ export class ConfigVariables {
isSensitive: true,
description:
'Legacy variable to be deprecated when all API Keys expire. Replaced by APP_KEY',
type: 'string',
type: ConfigVariableType.STRING,
})
@IsOptional()
ACCESS_TOKEN_SECRET: string;
@ -210,7 +216,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.TokensDuration,
description: 'Duration for which the access token is valid',
type: 'string',
type: ConfigVariableType.STRING,
})
@IsDuration()
@IsOptional()
@ -219,7 +225,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.TokensDuration,
description: 'Duration for which the refresh token is valid',
type: 'string',
type: ConfigVariableType.STRING,
})
@IsOptional()
REFRESH_TOKEN_EXPIRES_IN = '60d';
@ -227,7 +233,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.TokensDuration,
description: 'Cooldown period for refreshing tokens',
type: 'string',
type: ConfigVariableType.STRING,
})
@IsDuration()
@IsOptional()
@ -236,7 +242,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.TokensDuration,
description: 'Duration for which the login token is valid',
type: 'string',
type: ConfigVariableType.STRING,
})
@IsDuration()
@IsOptional()
@ -245,7 +251,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.TokensDuration,
description: 'Duration for which the file token is valid',
type: 'string',
type: ConfigVariableType.STRING,
})
@IsDuration()
@IsOptional()
@ -254,7 +260,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.TokensDuration,
description: 'Duration for which the invitation token is valid',
type: 'string',
type: ConfigVariableType.STRING,
})
@IsDuration()
@IsOptional()
@ -263,35 +269,35 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.TokensDuration,
description: 'Duration for which the short-term token is valid',
type: 'string',
type: ConfigVariableType.STRING,
})
SHORT_TERM_TOKEN_EXPIRES_IN = '5m';
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.EmailSettings,
description: 'Email address used as the sender for outgoing emails',
type: 'string',
type: ConfigVariableType.STRING,
})
EMAIL_FROM_ADDRESS = 'noreply@yourdomain.com';
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.EmailSettings,
description: 'Email address used for system notifications',
type: 'string',
type: ConfigVariableType.STRING,
})
EMAIL_SYSTEM_ADDRESS = 'system@yourdomain.com';
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.EmailSettings,
description: 'Name used in the From header for outgoing emails',
type: 'string',
type: ConfigVariableType.STRING,
})
EMAIL_FROM_NAME = 'Felix from Twenty';
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.EmailSettings,
description: 'Email driver to use for sending emails',
type: 'enum',
type: ConfigVariableType.ENUM,
options: Object.values(EmailDriver),
})
EMAIL_DRIVER: EmailDriver = EmailDriver.Logger;
@ -299,14 +305,14 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.EmailSettings,
description: 'SMTP host for sending emails',
type: 'string',
type: ConfigVariableType.STRING,
})
EMAIL_SMTP_HOST: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.EmailSettings,
description: 'Use unsecure connection for SMTP',
type: 'boolean',
type: ConfigVariableType.BOOLEAN,
})
@IsOptional()
EMAIL_SMTP_NO_TLS = false;
@ -314,7 +320,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.EmailSettings,
description: 'SMTP port for sending emails',
type: 'number',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
EMAIL_SMTP_PORT = 587;
@ -322,7 +328,8 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.EmailSettings,
description: 'SMTP user for authentication',
type: 'string',
type: ConfigVariableType.STRING,
isSensitive: true,
})
EMAIL_SMTP_USER: string;
@ -330,14 +337,14 @@ export class ConfigVariables {
group: ConfigVariablesGroup.EmailSettings,
isSensitive: true,
description: 'SMTP password for authentication',
type: 'string',
type: ConfigVariableType.STRING,
})
EMAIL_SMTP_PASSWORD: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.StorageConfig,
description: 'Type of storage to use (local or S3)',
type: 'enum',
type: ConfigVariableType.ENUM,
options: Object.values(StorageDriverType),
})
@IsOptional()
@ -346,7 +353,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.StorageConfig,
description: 'Local path for storage when using local storage type',
type: 'string',
type: ConfigVariableType.STRING,
})
@ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.Local)
STORAGE_LOCAL_PATH = '.local-storage';
@ -354,7 +361,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.StorageConfig,
description: 'S3 region for storage when using S3 storage type',
type: 'string',
type: ConfigVariableType.STRING,
})
@ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S3)
@IsAWSRegion()
@ -363,7 +370,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.StorageConfig,
description: 'S3 bucket name for storage when using S3 storage type',
type: 'string',
type: ConfigVariableType.STRING,
})
@ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S3)
STORAGE_S3_NAME: string;
@ -371,7 +378,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.StorageConfig,
description: 'S3 endpoint for storage when using S3 storage type',
type: 'string',
type: ConfigVariableType.STRING,
})
@ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S3)
@IsOptional()
@ -382,7 +389,7 @@ export class ConfigVariables {
isSensitive: true,
description:
'S3 access key ID for authentication when using S3 storage type',
type: 'string',
type: ConfigVariableType.STRING,
})
@ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S3)
@IsOptional()
@ -393,7 +400,7 @@ export class ConfigVariables {
isSensitive: true,
description:
'S3 secret access key for authentication when using S3 storage type',
type: 'string',
type: ConfigVariableType.STRING,
})
@ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S3)
@IsOptional()
@ -402,7 +409,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ServerlessConfig,
description: 'Type of serverless execution (local or Lambda)',
type: 'enum',
type: ConfigVariableType.ENUM,
options: Object.values(ServerlessDriverType),
})
@IsOptional()
@ -411,7 +418,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ServerlessConfig,
description: 'Throttle limit for serverless function execution',
type: 'number',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
SERVERLESS_FUNCTION_EXEC_THROTTLE_LIMIT = 10;
@ -420,7 +427,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ServerlessConfig,
description: 'Time-to-live for serverless function execution throttle',
type: 'number',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
SERVERLESS_FUNCTION_EXEC_THROTTLE_TTL = 1000;
@ -428,7 +435,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ServerlessConfig,
description: 'Region for AWS Lambda functions',
type: 'string',
type: ConfigVariableType.STRING,
})
@ValidateIf((env) => env.SERVERLESS_TYPE === ServerlessDriverType.Lambda)
@IsAWSRegion()
@ -437,7 +444,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ServerlessConfig,
description: 'IAM role for AWS Lambda functions',
type: 'string',
type: ConfigVariableType.STRING,
})
@ValidateIf((env) => env.SERVERLESS_TYPE === ServerlessDriverType.Lambda)
SERVERLESS_LAMBDA_ROLE: string;
@ -445,7 +452,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ServerlessConfig,
description: 'Role to assume when hosting lambdas in dedicated AWS account',
type: 'string',
type: ConfigVariableType.STRING,
})
@ValidateIf((env) => env.SERVERLESS_TYPE === ServerlessDriverType.Lambda)
@IsOptional()
@ -455,7 +462,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.ServerlessConfig,
isSensitive: true,
description: 'Access key ID for AWS Lambda functions',
type: 'string',
type: ConfigVariableType.STRING,
})
@ValidateIf((env) => env.SERVERLESS_TYPE === ServerlessDriverType.Lambda)
@IsOptional()
@ -465,7 +472,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.ServerlessConfig,
isSensitive: true,
description: 'Secret access key for AWS Lambda functions',
type: 'string',
type: ConfigVariableType.STRING,
})
@ValidateIf((env) => env.SERVERLESS_TYPE === ServerlessDriverType.Lambda)
@IsOptional()
@ -474,7 +481,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.AnalyticsConfig,
description: 'Enable or disable analytics for telemetry',
type: 'boolean',
type: ConfigVariableType.BOOLEAN,
})
@IsOptional()
ANALYTICS_ENABLED = false;
@ -482,7 +489,8 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.AnalyticsConfig,
description: 'Clickhouse host for analytics',
type: 'string',
type: ConfigVariableType.STRING,
isSensitive: true,
})
@IsOptional()
@IsUrl({
@ -495,7 +503,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.Logging,
description: 'Enable or disable telemetry logging',
type: 'boolean',
type: ConfigVariableType.BOOLEAN,
})
@IsOptional()
TELEMETRY_ENABLED = true;
@ -503,7 +511,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.BillingConfig,
description: 'Enable or disable billing features',
type: 'boolean',
type: ConfigVariableType.BOOLEAN,
})
@IsOptional()
IS_BILLING_ENABLED = false;
@ -511,7 +519,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.BillingConfig,
description: 'Link required for billing plan',
type: 'string',
type: ConfigVariableType.STRING,
})
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
BILLING_PLAN_REQUIRED_LINK: string;
@ -519,7 +527,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.BillingConfig,
description: 'Duration of free trial with credit card in days',
type: 'number',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
@IsOptional()
@ -529,7 +537,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.BillingConfig,
description: 'Duration of free trial without credit card in days',
type: 'number',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
@IsOptional()
@ -539,7 +547,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.BillingConfig,
description: 'Amount of money in cents to trigger a billing threshold',
type: 'number',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
@ -548,7 +556,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.BillingConfig,
description: 'Amount of credits for the free trial without credit card',
type: 'number',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
@ -557,7 +565,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.BillingConfig,
description: 'Amount of credits for the free trial with credit card',
type: 'number',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
@ -567,7 +575,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.BillingConfig,
isSensitive: true,
description: 'Stripe API key for billing',
type: 'string',
type: ConfigVariableType.STRING,
})
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
BILLING_STRIPE_API_KEY: string;
@ -576,7 +584,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.BillingConfig,
isSensitive: true,
description: 'Stripe webhook secret for billing',
type: 'string',
type: ConfigVariableType.STRING,
})
@ValidateIf((env) => env.IS_BILLING_ENABLED === true)
BILLING_STRIPE_WEBHOOK_SECRET: string;
@ -584,7 +592,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ServerConfig,
description: 'Url for the frontend application',
type: 'string',
type: ConfigVariableType.STRING,
})
@IsUrl({ require_tld: false, require_protocol: true })
@IsOptional()
@ -594,7 +602,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.ServerConfig,
description:
'Default subdomain for the frontend when multi-workspace is enabled',
type: 'string',
type: ConfigVariableType.STRING,
})
@ValidateIf((env) => env.IS_MULTIWORKSPACE_ENABLED)
DEFAULT_SUBDOMAIN = 'app';
@ -602,7 +610,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.Other,
description: 'ID for the Chrome extension',
type: 'string',
type: ConfigVariableType.STRING,
})
@IsOptional()
CHROME_EXTENSION_ID: string;
@ -610,7 +618,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.Logging,
description: 'Enable or disable buffering for logs before sending',
type: 'boolean',
type: ConfigVariableType.BOOLEAN,
})
@IsOptional()
LOGGER_IS_BUFFER_ENABLED = true;
@ -618,7 +626,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.Logging,
description: 'Driver used for handling exceptions (Console or Sentry)',
type: 'enum',
type: ConfigVariableType.ENUM,
options: Object.values(ExceptionHandlerDriver),
})
@IsOptional()
@ -628,8 +636,8 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.Logging,
description: 'Levels of logging to be captured',
type: 'array',
options: ['log', 'error', 'warn'],
type: ConfigVariableType.ARRAY,
options: ['log', 'error', 'warn', 'debug', 'verbose'],
})
@CastToLogLevelArray()
@IsOptional()
@ -638,7 +646,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.Metering,
description: 'Driver used for collect metrics (OpenTelemetry or Console)',
type: 'array',
type: ConfigVariableType.ARRAY,
options: ['OpenTelemetry', 'Console'],
})
@CastToMeterDriverArray()
@ -648,7 +656,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.Metering,
description: 'Endpoint URL for the OpenTelemetry collector',
type: 'string',
type: ConfigVariableType.STRING,
})
@IsOptional()
OTLP_COLLECTOR_ENDPOINT_URL: string;
@ -656,7 +664,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ExceptionHandler,
description: 'Driver used for logging (only console for now)',
type: 'enum',
type: ConfigVariableType.ENUM,
options: Object.values(LoggerDriverType),
})
@IsOptional()
@ -665,7 +673,8 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ExceptionHandler,
description: 'Data Source Name (DSN) for Sentry logging',
type: 'string',
type: ConfigVariableType.STRING,
isSensitive: true,
})
@ValidateIf(
(env) => env.EXCEPTION_HANDLER_DRIVER === ExceptionHandlerDriver.Sentry,
@ -675,7 +684,8 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ExceptionHandler,
description: 'Front-end DSN for Sentry logging',
type: 'string',
type: ConfigVariableType.STRING,
isSensitive: true,
})
@ValidateIf(
(env) => env.EXCEPTION_HANDLER_DRIVER === ExceptionHandlerDriver.Sentry,
@ -684,7 +694,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ExceptionHandler,
description: 'Environment name for Sentry logging',
type: 'string',
type: ConfigVariableType.STRING,
})
@ValidateIf(
(env) => env.EXCEPTION_HANDLER_DRIVER === ExceptionHandlerDriver.Sentry,
@ -695,7 +705,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.SupportChatConfig,
description: 'Driver used for support chat integration',
type: 'enum',
type: ConfigVariableType.ENUM,
options: Object.values(SupportDriver),
})
@IsOptional()
@ -705,7 +715,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.SupportChatConfig,
isSensitive: true,
description: 'Chat ID for the support front integration',
type: 'string',
type: ConfigVariableType.STRING,
})
@ValidateIf((env) => env.SUPPORT_DRIVER === SupportDriver.Front)
SUPPORT_FRONT_CHAT_ID: string;
@ -714,7 +724,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.SupportChatConfig,
isSensitive: true,
description: 'HMAC key for the support front integration',
type: 'string',
type: ConfigVariableType.STRING,
})
@ValidateIf((env) => env.SUPPORT_DRIVER === SupportDriver.Front)
SUPPORT_FRONT_HMAC_KEY: string;
@ -723,7 +733,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.ServerConfig,
isSensitive: true,
description: 'Database connection URL',
type: 'string',
type: ConfigVariableType.STRING,
isEnvOnly: true,
})
@IsDefined()
@ -740,7 +750,7 @@ export class ConfigVariables {
description:
'Allow connections to a database with self-signed certificates',
isEnvOnly: true,
type: 'boolean',
type: ConfigVariableType.BOOLEAN,
})
@IsOptional()
PG_SSL_ALLOW_SELF_SIGNED = false;
@ -749,7 +759,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.ServerConfig,
description: 'Enable configuration variables to be stored in the database',
isEnvOnly: true,
type: 'boolean',
type: ConfigVariableType.BOOLEAN,
})
@IsOptional()
IS_CONFIG_VARIABLES_IN_DB_ENABLED = false;
@ -757,7 +767,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.TokensDuration,
description: 'Time-to-live for cache storage in seconds',
type: 'number',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
CACHE_STORAGE_TTL: number = 3600 * 24 * 7;
@ -767,7 +777,7 @@ export class ConfigVariables {
isSensitive: true,
description: 'URL for cache storage (e.g., Redis connection URL)',
isEnvOnly: true,
type: 'string',
type: ConfigVariableType.STRING,
})
@IsOptional()
@IsUrl({
@ -780,7 +790,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ServerConfig,
description: 'Node environment (development, production, etc.)',
type: 'enum',
type: ConfigVariableType.ENUM,
options: Object.values(NodeEnvironment),
})
NODE_ENV: NodeEnvironment = NodeEnvironment.production;
@ -788,7 +798,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ServerConfig,
description: 'Port for the node server',
type: 'number',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
@IsOptional()
@ -797,7 +807,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ServerConfig,
description: 'Base URL for the server',
type: 'string',
type: ConfigVariableType.STRING,
})
@IsUrl({ require_tld: false, require_protocol: true })
@IsOptional()
@ -808,14 +818,14 @@ export class ConfigVariables {
isSensitive: true,
description: 'Secret key for the application',
isEnvOnly: true,
type: 'string',
type: ConfigVariableType.STRING,
})
APP_SECRET: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.RateLimiting,
description: 'Maximum number of records affected by mutations',
type: 'number',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
@IsOptional()
@ -824,7 +834,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.RateLimiting,
description: 'Time-to-live for API rate limiting in milliseconds',
type: 'number',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
API_RATE_LIMITING_TTL = 100;
@ -833,7 +843,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.RateLimiting,
description:
'Maximum number of requests allowed in the rate limiting window',
type: 'number',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
API_RATE_LIMITING_LIMIT = 500;
@ -841,7 +851,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.SSL,
description: 'Path to the SSL key for enabling HTTPS in local development',
type: 'string',
type: ConfigVariableType.STRING,
})
@IsOptional()
SSL_KEY_PATH: string;
@ -850,7 +860,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.SSL,
description:
'Path to the SSL certificate for enabling HTTPS in local development',
type: 'string',
type: ConfigVariableType.STRING,
})
@IsOptional()
SSL_CERT_PATH: string;
@ -859,7 +869,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.CloudflareConfig,
isSensitive: true,
description: 'API key for Cloudflare integration',
type: 'string',
type: ConfigVariableType.STRING,
})
@ValidateIf((env) => env.CLOUDFLARE_ZONE_ID)
CLOUDFLARE_API_KEY: string;
@ -867,7 +877,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.CloudflareConfig,
description: 'Zone ID for Cloudflare integration',
type: 'string',
type: ConfigVariableType.STRING,
})
@ValidateIf((env) => env.CLOUDFLARE_API_KEY)
CLOUDFLARE_ZONE_ID: string;
@ -875,7 +885,8 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.Other,
description: 'Random string to validate queries from Cloudflare',
type: 'string',
type: ConfigVariableType.STRING,
isSensitive: true,
})
@IsOptional()
CLOUDFLARE_WEBHOOK_SECRET: string;
@ -883,7 +894,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM,
description: 'Driver for the LLM chat model',
type: 'enum',
type: ConfigVariableType.ENUM,
options: Object.values(LLMChatModelDriver),
})
LLM_CHAT_MODEL_DRIVER: LLMChatModelDriver;
@ -892,7 +903,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.LLM,
isSensitive: true,
description: 'API key for OpenAI integration',
type: 'string',
type: ConfigVariableType.STRING,
})
OPENAI_API_KEY: string;
@ -900,21 +911,21 @@ export class ConfigVariables {
group: ConfigVariablesGroup.LLM,
isSensitive: true,
description: 'Secret key for Langfuse integration',
type: 'string',
type: ConfigVariableType.STRING,
})
LANGFUSE_SECRET_KEY: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM,
description: 'Public key for Langfuse integration',
type: 'string',
type: ConfigVariableType.STRING,
})
LANGFUSE_PUBLIC_KEY: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM,
description: 'Driver for LLM tracing',
type: 'enum',
type: ConfigVariableType.ENUM,
options: Object.values(LLMTracingDriver),
})
LLM_TRACING_DRIVER: LLMTracingDriver = LLMTracingDriver.Console;
@ -922,7 +933,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ServerConfig,
description: 'Enable or disable multi-workspace support',
type: 'boolean',
type: ConfigVariableType.BOOLEAN,
})
@IsOptional()
IS_MULTIWORKSPACE_ENABLED = false;
@ -931,7 +942,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.Other,
description:
'Number of inactive days before sending a deletion warning for workspaces. Used in the workspace deletion cron job to determine when to send warning emails.',
type: 'number',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
@IsStrictlyLowerThan('WORKSPACE_INACTIVE_DAYS_BEFORE_SOFT_DELETION', {
@ -943,7 +954,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.Other,
description: 'Number of inactive days before soft deleting workspaces',
type: 'number',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
@IsStrictlyLowerThan('WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION', {
@ -955,7 +966,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.Other,
description: 'Number of inactive days before deleting workspaces',
type: 'number',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION = 21;
@ -964,7 +975,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.Other,
description:
'Maximum number of workspaces that can be deleted in a single execution',
type: 'number',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
@ValidateIf((env) => env.MAX_NUMBER_OF_WORKSPACES_DELETED_PER_EXECUTION > 0)
@ -973,7 +984,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.RateLimiting,
description: 'Throttle limit for workflow execution',
type: 'number',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
WORKFLOW_EXEC_THROTTLE_LIMIT = 500;
@ -981,7 +992,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.RateLimiting,
description: 'Time-to-live for workflow execution throttle in milliseconds',
type: 'number',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
WORKFLOW_EXEC_THROTTLE_TTL = 1000;
@ -989,7 +1000,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.CaptchaConfig,
description: 'Driver for captcha integration',
type: 'enum',
type: ConfigVariableType.ENUM,
options: Object.values(CaptchaDriverType),
})
@IsOptional()
@ -999,7 +1010,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.CaptchaConfig,
isSensitive: true,
description: 'Site key for captcha integration',
type: 'string',
type: ConfigVariableType.STRING,
})
@IsOptional()
CAPTCHA_SITE_KEY?: string;
@ -1008,7 +1019,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.CaptchaConfig,
isSensitive: true,
description: 'Secret key for captcha integration',
type: 'string',
type: ConfigVariableType.STRING,
})
@IsOptional()
CAPTCHA_SECRET_KEY?: string;
@ -1017,7 +1028,7 @@ export class ConfigVariables {
group: ConfigVariablesGroup.ServerConfig,
isSensitive: true,
description: 'License key for the Enterprise version',
type: 'string',
type: ConfigVariableType.STRING,
})
@IsOptional()
ENTERPRISE_KEY: string;
@ -1025,7 +1036,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.Other,
description: 'Health monitoring time window in minutes',
type: 'number',
type: ConfigVariableType.NUMBER,
})
@CastToPositiveNumber()
@IsOptional()
@ -1034,7 +1045,7 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.Other,
description: 'Enable or disable the attachment preview feature',
type: 'boolean',
type: ConfigVariableType.BOOLEAN,
})
@IsOptional()
IS_ATTACHMENT_PREVIEW_ENABLED = true;
@ -1042,7 +1053,8 @@ export class ConfigVariables {
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ServerConfig,
description: 'Twenty server version',
type: 'string',
type: ConfigVariableType.STRING,
isEnvOnly: true,
})
@IsOptionalOrEmptyString()
@IsTwentySemVer()
@ -1076,7 +1088,10 @@ export const validate = (config: Record<string, unknown>): ConfigVariables => {
if (validationErrors.length > 0) {
logValidatonErrors(validationErrors, 'error');
throw new Error('Config variables validation failed');
throw new ConfigVariableException(
'Config variables validation failed',
ConfigVariableExceptionCode.VALIDATION_FAILED,
);
}
return validatedConfig;

View File

@ -4,11 +4,11 @@ import { Test, TestingModule } from '@nestjs/testing';
import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
import { CONFIG_VARIABLES_INSTANCE_TOKEN } from 'src/engine/core-modules/twenty-config/constants/config-variables-instance-tokens.constants';
import { ConfigValueConverterService } from 'src/engine/core-modules/twenty-config/conversion/config-value-converter.service';
import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/enums/config-variable-type.enum';
import { ConfigVariablesGroup } from 'src/engine/core-modules/twenty-config/enums/config-variables-group.enum';
import { configTransformers } from 'src/engine/core-modules/twenty-config/utils/config-transformers.util';
import { TypedReflect } from 'src/utils/typed-reflect';
// Mock configTransformers for type validation tests
jest.mock(
'src/engine/core-modules/twenty-config/utils/config-transformers.util',
() => {
@ -19,7 +19,6 @@ jest.mock(
return {
configTransformers: {
...originalModule.configTransformers,
// These mocked versions can be overridden in specific tests
_mockedBoolean: jest.fn(),
_mockedNumber: jest.fn(),
_mockedString: jest.fn(),
@ -56,7 +55,7 @@ describe('ConfigValueConverterService', () => {
// Mock the metadata
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({
AUTH_PASSWORD_ENABLED: {
type: 'boolean',
type: ConfigVariableType.BOOLEAN,
group: ConfigVariablesGroup.Other,
description: 'Enable or disable password authentication for users',
},
@ -116,7 +115,7 @@ describe('ConfigValueConverterService', () => {
it('should convert string to number based on metadata', () => {
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({
NODE_PORT: {
type: 'number',
type: ConfigVariableType.NUMBER,
group: ConfigVariablesGroup.ServerConfig,
description: 'Port for the node server',
},
@ -146,7 +145,7 @@ describe('ConfigValueConverterService', () => {
it('should convert string to array based on metadata', () => {
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({
LOG_LEVELS: {
type: 'array',
type: ConfigVariableType.ARRAY,
group: ConfigVariablesGroup.Logging,
description: 'Levels of logging to be captured',
},
@ -161,7 +160,7 @@ describe('ConfigValueConverterService', () => {
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({
LOG_LEVELS: {
type: 'array',
type: ConfigVariableType.ARRAY,
group: ConfigVariablesGroup.Logging,
description: 'Levels of logging to be captured',
},
@ -188,7 +187,7 @@ describe('ConfigValueConverterService', () => {
it('should handle various input types', () => {
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({
AUTH_PASSWORD_ENABLED: {
type: 'boolean',
type: ConfigVariableType.BOOLEAN,
group: ConfigVariablesGroup.Other,
description: 'Enable or disable password authentication for users',
},
@ -202,7 +201,7 @@ describe('ConfigValueConverterService', () => {
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({
NODE_PORT: {
type: 'number',
type: ConfigVariableType.NUMBER,
group: ConfigVariablesGroup.ServerConfig,
description: 'Port for the node server',
},
@ -216,7 +215,7 @@ describe('ConfigValueConverterService', () => {
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({
LOG_LEVELS: {
type: 'array',
type: ConfigVariableType.ARRAY,
group: ConfigVariablesGroup.Logging,
description: 'Levels of logging to be captured',
},
@ -259,7 +258,7 @@ describe('ConfigValueConverterService', () => {
it('should throw error if boolean converter returns non-boolean', () => {
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({
AUTH_PASSWORD_ENABLED: {
type: 'boolean',
type: ConfigVariableType.BOOLEAN,
group: ConfigVariablesGroup.Other,
description: 'Test boolean',
},
@ -284,7 +283,7 @@ describe('ConfigValueConverterService', () => {
it('should throw error if number converter returns non-number', () => {
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({
NODE_PORT: {
type: 'number',
type: ConfigVariableType.NUMBER,
group: ConfigVariablesGroup.ServerConfig,
description: 'Test number',
},
@ -309,7 +308,7 @@ describe('ConfigValueConverterService', () => {
it('should throw error if string converter returns non-string', () => {
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({
EMAIL_FROM_ADDRESS: {
type: 'string',
type: ConfigVariableType.STRING,
group: ConfigVariablesGroup.EmailSettings,
description: 'Test string',
},
@ -332,7 +331,7 @@ describe('ConfigValueConverterService', () => {
it('should throw error if array conversion produces non-array', () => {
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({
LOG_LEVELS: {
type: 'array',
type: ConfigVariableType.ARRAY,
group: ConfigVariablesGroup.Logging,
description: 'Test array',
},
@ -358,7 +357,7 @@ describe('ConfigValueConverterService', () => {
it('should handle array with option validation', () => {
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({
LOG_LEVELS: {
type: 'array',
type: ConfigVariableType.ARRAY,
group: ConfigVariablesGroup.Logging,
description: 'Test array with options',
options: ['log', 'error', 'warn', 'debug'],
@ -374,7 +373,7 @@ describe('ConfigValueConverterService', () => {
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({
LOG_LEVELS: {
type: 'array',
type: ConfigVariableType.ARRAY,
group: ConfigVariablesGroup.Logging,
description: 'Test array with options',
options: ['log', 'error', 'warn', 'debug'],
@ -392,7 +391,7 @@ describe('ConfigValueConverterService', () => {
it('should properly handle enum with options', () => {
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({
LOG_LEVEL: {
type: 'enum',
type: ConfigVariableType.ENUM,
group: ConfigVariablesGroup.Logging,
description: 'Test enum',
options: ['log', 'error', 'warn', 'debug'],
@ -408,7 +407,7 @@ describe('ConfigValueConverterService', () => {
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValueOnce({
LOG_LEVEL: {
type: 'enum',
type: ConfigVariableType.ENUM,
group: ConfigVariablesGroup.Logging,
description: 'Test enum',
options: ['log', 'error', 'warn', 'debug'],

View File

@ -3,8 +3,8 @@ import { Inject, Injectable, Logger } from '@nestjs/common';
import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
import { CONFIG_VARIABLES_INSTANCE_TOKEN } from 'src/engine/core-modules/twenty-config/constants/config-variables-instance-tokens.constants';
import { ConfigVariablesMetadataMap } from 'src/engine/core-modules/twenty-config/decorators/config-variables-metadata.decorator';
import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/enums/config-variable-type.enum';
import { ConfigVariableOptions } from 'src/engine/core-modules/twenty-config/types/config-variable-options.type';
import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/types/config-variable-type.type';
import { configTransformers } from 'src/engine/core-modules/twenty-config/utils/config-transformers.util';
import { TypedReflect } from 'src/utils/typed-reflect';
@ -31,7 +31,7 @@ export class ConfigValueConverterService {
try {
switch (configType) {
case 'boolean': {
case ConfigVariableType.BOOLEAN: {
const result = configTransformers.boolean(dbValue);
if (result !== undefined && typeof result !== 'boolean') {
@ -43,7 +43,7 @@ export class ConfigValueConverterService {
return result as ConfigVariables[T];
}
case 'number': {
case ConfigVariableType.NUMBER: {
const result = configTransformers.number(dbValue);
if (result !== undefined && typeof result !== 'number') {
@ -55,7 +55,7 @@ export class ConfigValueConverterService {
return result as ConfigVariables[T];
}
case 'string': {
case ConfigVariableType.STRING: {
const result = configTransformers.string(dbValue);
if (result !== undefined && typeof result !== 'string') {
@ -67,7 +67,7 @@ export class ConfigValueConverterService {
return result as ConfigVariables[T];
}
case 'array': {
case ConfigVariableType.ARRAY: {
const result = this.convertToArray(dbValue, options);
if (result !== undefined && !Array.isArray(result)) {
@ -79,7 +79,7 @@ export class ConfigValueConverterService {
return result as ConfigVariables[T];
}
case 'enum': {
case ConfigVariableType.ENUM: {
const result = this.convertToEnum(dbValue, options);
return result as ConfigVariables[T];
@ -204,10 +204,10 @@ export class ConfigValueConverterService {
): ConfigVariableType {
const defaultValue = this.configVariables[key];
if (typeof defaultValue === 'boolean') return 'boolean';
if (typeof defaultValue === 'number') return 'number';
if (Array.isArray(defaultValue)) return 'array';
if (typeof defaultValue === 'boolean') return ConfigVariableType.BOOLEAN;
if (typeof defaultValue === 'number') return ConfigVariableType.NUMBER;
if (Array.isArray(defaultValue)) return ConfigVariableType.ARRAY;
return 'string';
return ConfigVariableType.STRING;
}
}

View File

@ -4,9 +4,9 @@ import {
ValidationOptions,
} from 'class-validator';
import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/enums/config-variable-type.enum';
import { ConfigVariablesGroup } from 'src/engine/core-modules/twenty-config/enums/config-variables-group.enum';
import { ConfigVariableOptions } from 'src/engine/core-modules/twenty-config/types/config-variable-options.type';
import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/types/config-variable-type.type';
import { applyBasicValidators } from 'src/engine/core-modules/twenty-config/utils/apply-basic-validators.util';
import { TypedReflect } from 'src/utils/typed-reflect';
@ -15,7 +15,7 @@ export interface ConfigVariablesMetadataOptions {
description: string;
isSensitive?: boolean;
isEnvOnly?: boolean;
type?: ConfigVariableType;
type: ConfigVariableType;
options?: ConfigVariableOptions;
}
@ -51,14 +51,12 @@ export function ConfigVariablesMetadata(
IsOptional()(target, propertyKey);
}
if (options.type) {
applyBasicValidators(
options.type,
target,
propertyKey.toString(),
options.options,
);
}
applyBasicValidators(
options.type,
target,
propertyKey.toString(),
options.options,
);
registerDecorator({
name: propertyKey.toString(),

View File

@ -19,12 +19,10 @@ const CONFIG_ENV_ONLY_KEY = 'ENV_ONLY_VAR';
const CONFIG_PORT_KEY = 'NODE_PORT';
class TestDatabaseConfigDriver extends DatabaseConfigDriver {
// Expose the protected/private property for testing
public get testAllPossibleConfigKeys(): Array<keyof ConfigVariables> {
return this['allPossibleConfigKeys'];
}
// Override Object.keys usage in constructor with our test keys
constructor(
configCache: ConfigCacheService,
configStorage: ConfigStorageService,
@ -213,51 +211,6 @@ describe('DatabaseConfigDriver', () => {
});
});
describe('fetchAndCacheConfigVariable', () => {
it('should refresh config variable from storage', async () => {
const value = true;
jest.spyOn(configStorage, 'get').mockResolvedValue(value);
await driver.fetchAndCacheConfigVariable(CONFIG_PASSWORD_KEY);
expect(configStorage.get).toHaveBeenCalledWith(CONFIG_PASSWORD_KEY);
expect(configCache.set).toHaveBeenCalledWith(CONFIG_PASSWORD_KEY, value);
});
it('should mark key as missing when value is undefined', async () => {
jest.spyOn(configStorage, 'get').mockResolvedValue(undefined);
await driver.fetchAndCacheConfigVariable(CONFIG_PASSWORD_KEY);
expect(configStorage.get).toHaveBeenCalledWith(CONFIG_PASSWORD_KEY);
expect(configCache.markKeyAsMissing).toHaveBeenCalledWith(
CONFIG_PASSWORD_KEY,
);
expect(configCache.set).not.toHaveBeenCalled();
});
it('should mark key as missing when storage fetch fails', async () => {
const error = new Error('Storage error');
jest.spyOn(configStorage, 'get').mockRejectedValue(error);
const loggerSpy = jest
.spyOn(driver['logger'], 'error')
.mockImplementation();
await driver.fetchAndCacheConfigVariable(CONFIG_PASSWORD_KEY);
expect(configStorage.get).toHaveBeenCalledWith(CONFIG_PASSWORD_KEY);
expect(configCache.markKeyAsMissing).toHaveBeenCalledWith(
CONFIG_PASSWORD_KEY,
);
expect(loggerSpy).toHaveBeenCalledWith(
expect.stringContaining('Failed to fetch config'),
error,
);
});
});
describe('cache operations', () => {
it('should return cache info', () => {
const cacheInfo = {

View File

@ -56,6 +56,18 @@ export class DatabaseConfigDriver
return this.configCache.get(key);
}
async set<T extends keyof ConfigVariables>(
key: T,
value: ConfigVariables[T],
): Promise<void> {
if (isEnvOnlyConfigVar(key)) {
throw new Error(`Cannot set environment-only variable: ${key as string}`);
}
await this.configStorage.set(key, value);
this.configCache.set(key, value);
}
async update<T extends keyof ConfigVariables>(
key: T,
value: ConfigVariables[T],
@ -66,43 +78,8 @@ export class DatabaseConfigDriver
);
}
try {
await this.configStorage.set(key, value);
this.configCache.set(key, value);
this.logger.debug(
`[UPDATE] Config variable ${key as string} updated successfully`,
);
} catch (error) {
this.logger.error(
`[UPDATE] Failed to update config variable ${key as string}`,
error,
);
throw error;
}
}
async fetchAndCacheConfigVariable(key: keyof ConfigVariables): Promise<void> {
try {
const value = await this.configStorage.get(key);
if (value !== undefined) {
this.configCache.set(key, value);
this.logger.debug(
`[FETCH] Config variable ${key as string} loaded from database`,
);
} else {
this.configCache.markKeyAsMissing(key);
this.logger.debug(
`[FETCH] Config variable ${key as string} not found in database, marked as missing`,
);
}
} catch (error) {
this.logger.error(
`[FETCH] Failed to fetch config variable ${key as string} from database`,
error,
);
this.configCache.markKeyAsMissing(key);
}
await this.configStorage.set(key, value);
this.configCache.set(key, value);
}
getCacheInfo(): {
@ -114,39 +91,29 @@ export class DatabaseConfigDriver
}
private async loadAllConfigVarsFromDb(): Promise<number> {
try {
this.logger.debug('[LOAD] Fetching all config variables from database');
const configVars = await this.configStorage.loadAll();
const configVars = await this.configStorage.loadAll();
this.logger.debug(
`[LOAD] Processing ${this.allPossibleConfigKeys.length} possible config variables`,
);
for (const [key, value] of configVars.entries()) {
this.configCache.set(key, value);
}
for (const key of this.allPossibleConfigKeys) {
if (!configVars.has(key)) {
this.configCache.markKeyAsMissing(key);
}
}
const missingKeysCount =
this.allPossibleConfigKeys.length - configVars.size;
this.logger.debug(
`[LOAD] Cached ${configVars.size} config variables, marked ${missingKeysCount} keys as missing`,
);
return configVars.size;
} catch (error) {
this.logger.error(
'[LOAD] Failed to load config variables from database',
error,
);
throw error;
for (const [key, value] of configVars.entries()) {
this.configCache.set(key, value);
}
for (const key of this.allPossibleConfigKeys) {
if (!configVars.has(key)) {
this.configCache.markKeyAsMissing(key);
}
}
return configVars.size;
}
async delete(key: keyof ConfigVariables): Promise<void> {
if (isEnvOnlyConfigVar(key)) {
throw new Error(
`Cannot delete environment-only variable: ${key as string}`,
);
}
await this.configStorage.delete(key);
this.configCache.markKeyAsMissing(key);
}
/**
@ -157,16 +124,8 @@ export class DatabaseConfigDriver
@Cron(CONFIG_VARIABLES_REFRESH_CRON_INTERVAL)
async refreshAllCache(): Promise<void> {
try {
this.logger.debug(
'[REFRESH] Starting scheduled refresh of config variables',
);
const dbValues = await this.configStorage.loadAll();
this.logger.debug(
`[REFRESH] Processing ${this.allPossibleConfigKeys.length} possible config variables`,
);
for (const [key, value] of dbValues.entries()) {
if (!isEnvOnlyConfigVar(key)) {
this.configCache.set(key, value);
@ -178,16 +137,12 @@ export class DatabaseConfigDriver
this.configCache.markKeyAsMissing(key);
}
}
const missingKeysCount =
this.allPossibleConfigKeys.length - dbValues.size;
this.logger.log(
`[REFRESH] Config variables refreshed: ${dbValues.size} values updated, ${missingKeysCount} marked as missing`,
);
} catch (error) {
this.logger.error('[REFRESH] Failed to refresh config variables', error);
// Error is caught and logged but not rethrown to prevent the cron job from crashing
this.logger.error(
'Failed to refresh config variables from database',
error instanceof Error ? error.stack : error,
);
}
}
}

View File

@ -8,6 +8,7 @@ import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-va
import { CONFIG_VARIABLES_INSTANCE_TOKEN } from 'src/engine/core-modules/twenty-config/constants/config-variables-instance-tokens.constants';
import { ConfigValueConverterService } from 'src/engine/core-modules/twenty-config/conversion/config-value-converter.service';
import { DatabaseConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/database-config.driver';
import { EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver';
import { ConfigStorageService } from 'src/engine/core-modules/twenty-config/storage/config-storage.service';
@Module({})
@ -24,6 +25,7 @@ export class DatabaseConfigModule {
ConfigCacheService,
ConfigStorageService,
ConfigValueConverterService,
EnvironmentConfigDriver,
{
provide: CONFIG_VARIABLES_INSTANCE_TOKEN,
useValue: new ConfigVariables(),

View File

@ -19,11 +19,6 @@ export interface DatabaseConfigDriverInterface {
value: ConfigVariables[T],
): Promise<void>;
/**
* Fetch and cache a specific configuration from its source
*/
fetchAndCacheConfigVariable(key: keyof ConfigVariables): Promise<void>;
/**
* Refreshes all entries in the config cache
*/

View File

@ -0,0 +1,7 @@
export enum ConfigVariableType {
BOOLEAN = 'boolean',
NUMBER = 'number',
ARRAY = 'array',
STRING = 'string',
ENUM = 'enum',
}

View File

@ -0,0 +1,31 @@
import { Catch, ExceptionFilter } from '@nestjs/common';
import {
ForbiddenError,
InternalServerError,
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
ConfigVariableException,
ConfigVariableExceptionCode,
} from 'src/engine/core-modules/twenty-config/twenty-config.exception';
@Catch(ConfigVariableException)
export class ConfigVariableGraphqlApiExceptionFilter
implements ExceptionFilter
{
catch(exception: ConfigVariableException) {
switch (exception.code) {
case ConfigVariableExceptionCode.VARIABLE_NOT_FOUND:
throw new NotFoundError(exception.message);
case ConfigVariableExceptionCode.ENVIRONMENT_ONLY_VARIABLE:
throw new ForbiddenError(exception.message);
case ConfigVariableExceptionCode.DATABASE_CONFIG_DISABLED:
throw new UserInputError(exception.message);
case ConfigVariableExceptionCode.INTERNAL_ERROR:
default:
throw new InternalServerError(exception.message);
}
}
}

View File

@ -3,20 +3,31 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { DeleteResult, IsNull, Repository } from 'typeorm';
import * as authUtils from 'src/engine/core-modules/auth/auth.util';
import {
KeyValuePair,
KeyValuePairType,
} from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
import { ConfigValueConverterService } from 'src/engine/core-modules/twenty-config/conversion/config-value-converter.service';
import { EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver';
import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/enums/config-variable-type.enum';
import { ConfigVariablesGroup } from 'src/engine/core-modules/twenty-config/enums/config-variables-group.enum';
import { ConfigStorageService } from 'src/engine/core-modules/twenty-config/storage/config-storage.service';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { TypedReflect } from 'src/utils/typed-reflect';
jest.mock('src/engine/core-modules/auth/auth.util', () => ({
encryptText: jest.fn((text) => `encrypted:${text}`),
decryptText: jest.fn((text) => text.replace('encrypted:', '')),
}));
describe('ConfigStorageService', () => {
let service: ConfigStorageService;
let keyValuePairRepository: Repository<KeyValuePair>;
let configValueConverter: ConfigValueConverterService;
let environmentConfigDriver: EnvironmentConfigDriver;
const createMockKeyValuePair = (
key: string,
@ -47,6 +58,12 @@ describe('ConfigStorageService', () => {
convertAppValueToDbValue: jest.fn(),
},
},
{
provide: EnvironmentConfigDriver,
useValue: {
get: jest.fn().mockReturnValue('test-secret'),
},
},
ConfigVariables,
{
provide: getRepositoryToken(KeyValuePair, 'core'),
@ -68,6 +85,9 @@ describe('ConfigStorageService', () => {
configValueConverter = module.get<ConfigValueConverterService>(
ConfigValueConverterService,
);
environmentConfigDriver = module.get<EnvironmentConfigDriver>(
EnvironmentConfigDriver,
);
jest.clearAllMocks();
});
@ -136,6 +156,188 @@ describe('ConfigStorageService', () => {
await expect(service.get(key)).rejects.toThrow('Conversion error');
});
it('should decrypt sensitive string values', async () => {
const key = 'SENSITIVE_CONFIG' as keyof ConfigVariables;
const originalValue = 'sensitive-value';
const encryptedValue = 'encrypted:sensitive-value';
const mockRecord = createMockKeyValuePair(key as string, encryptedValue);
jest
.spyOn(keyValuePairRepository, 'findOne')
.mockResolvedValue(mockRecord);
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValue({
[key]: {
isSensitive: true,
type: ConfigVariableType.STRING,
group: ConfigVariablesGroup.ServerConfig,
description: 'Test sensitive config',
},
});
(
configValueConverter.convertDbValueToAppValue as jest.Mock
).mockReturnValue(encryptedValue);
const result = await service.get(key);
expect(result).toBe(originalValue);
expect(environmentConfigDriver.get).toHaveBeenCalledWith('APP_SECRET');
expect(authUtils.decryptText).toHaveBeenCalledWith(
encryptedValue,
'test-secret',
);
});
it('should handle decryption errors gracefully', async () => {
const key = 'SENSITIVE_CONFIG' as keyof ConfigVariables;
const encryptedValue = 'encrypted-value';
const convertedValue = 'converted-value';
const mockRecord = createMockKeyValuePair(key as string, encryptedValue);
jest
.spyOn(keyValuePairRepository, 'findOne')
.mockResolvedValue(mockRecord);
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValue({
[key]: {
isSensitive: true,
type: ConfigVariableType.STRING,
group: ConfigVariablesGroup.ServerConfig,
description: 'Test sensitive config',
},
});
(
configValueConverter.convertDbValueToAppValue as jest.Mock
).mockReturnValue(convertedValue);
const result = await service.get(key);
expect(result).toBe(convertedValue);
});
it('should handle decryption failure in get() by returning original value', async () => {
const key = 'SENSITIVE_CONFIG' as keyof ConfigVariables;
const encryptedValue = 'encrypted:sensitive-value';
const mockRecord = createMockKeyValuePair(key as string, encryptedValue);
jest
.spyOn(keyValuePairRepository, 'findOne')
.mockResolvedValue(mockRecord);
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValue({
[key]: {
isSensitive: true,
type: ConfigVariableType.STRING,
group: ConfigVariablesGroup.ServerConfig,
description: 'Test sensitive config',
},
});
(
configValueConverter.convertDbValueToAppValue as jest.Mock
).mockReturnValue(encryptedValue);
// Mock decryption to throw an error
(authUtils.decryptText as jest.Mock).mockImplementationOnce(() => {
throw new Error('Decryption failed');
});
const result = await service.get(key);
expect(result).toBe(encryptedValue); // Should fall back to encrypted value
expect(environmentConfigDriver.get).toHaveBeenCalledWith('APP_SECRET');
});
it('should skip decryption for non-string sensitive values', async () => {
const key = 'SENSITIVE_CONFIG' as keyof ConfigVariables;
const value = { someKey: 'someValue' };
const mockRecord = createMockKeyValuePair(
key as string,
JSON.stringify(value),
);
jest
.spyOn(keyValuePairRepository, 'findOne')
.mockResolvedValue(mockRecord);
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValue({
[key]: {
isSensitive: true,
type: ConfigVariableType.ARRAY,
group: ConfigVariablesGroup.ServerConfig,
description: 'Test sensitive config',
},
});
(
configValueConverter.convertDbValueToAppValue as jest.Mock
).mockReturnValue(value);
const result = await service.get(key);
expect(result).toBe(value);
expect(authUtils.decryptText).not.toHaveBeenCalled();
});
it('should handle decryption failure in loadAll() by skipping failed values', async () => {
const configVars: KeyValuePair[] = [
createMockKeyValuePair('SENSITIVE_CONFIG_1', 'encrypted:value1'),
createMockKeyValuePair('SENSITIVE_CONFIG_2', 'encrypted:value2'),
createMockKeyValuePair('NORMAL_CONFIG', 'normal-value'),
];
jest.spyOn(keyValuePairRepository, 'find').mockResolvedValue(configVars);
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValue({
SENSITIVE_CONFIG_1: {
isSensitive: true,
type: ConfigVariableType.STRING,
group: ConfigVariablesGroup.ServerConfig,
description: 'Test sensitive config 1',
},
SENSITIVE_CONFIG_2: {
isSensitive: true,
type: ConfigVariableType.STRING,
group: ConfigVariablesGroup.ServerConfig,
description: 'Test sensitive config 2',
},
NORMAL_CONFIG: {
type: ConfigVariableType.STRING,
group: ConfigVariablesGroup.ServerConfig,
description: 'Test normal config',
},
});
(
configValueConverter.convertDbValueToAppValue as jest.Mock
).mockImplementation((value) => value);
// Mock decryption to fail for the second sensitive value
(authUtils.decryptText as jest.Mock)
.mockImplementationOnce((text) => text.replace('encrypted:', ''))
.mockImplementationOnce(() => {
throw new Error('Decryption failed');
});
const result = await service.loadAll();
expect(result.size).toBe(3);
expect(result.get('SENSITIVE_CONFIG_1' as keyof ConfigVariables)).toBe(
'value1',
);
expect(result.get('SENSITIVE_CONFIG_2' as keyof ConfigVariables)).toBe(
'encrypted:value2',
); // Original encrypted value
expect(result.get('NORMAL_CONFIG' as keyof ConfigVariables)).toBe(
'normal-value',
);
});
});
describe('set', () => {
@ -197,6 +399,77 @@ describe('ConfigStorageService', () => {
await expect(service.set(key, value)).rejects.toThrow('Conversion error');
});
it('should encrypt sensitive string values', async () => {
const key = 'SENSITIVE_CONFIG' as keyof ConfigVariables;
const value = 'sensitive-value';
const convertedValue = 'sensitive-value';
const encryptedValue = 'encrypted:sensitive-value';
jest.spyOn(keyValuePairRepository, 'findOne').mockResolvedValue(null);
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValue({
[key]: {
isSensitive: true,
type: ConfigVariableType.STRING,
group: ConfigVariablesGroup.ServerConfig,
description: 'Test sensitive config',
},
});
(
configValueConverter.convertAppValueToDbValue as jest.Mock
).mockReturnValue(convertedValue);
await service.set(key, value);
expect(keyValuePairRepository.insert).toHaveBeenCalledWith({
key: key as string,
value: encryptedValue,
userId: null,
workspaceId: null,
type: KeyValuePairType.CONFIG_VARIABLE,
});
expect(environmentConfigDriver.get).toHaveBeenCalledWith('APP_SECRET');
expect(authUtils.encryptText).toHaveBeenCalledWith(
convertedValue,
'test-secret',
);
});
it('should handle encryption errors gracefully', async () => {
const key = 'SENSITIVE_CONFIG' as keyof ConfigVariables;
const value = 'sensitive-value';
const convertedValue = 'sensitive-value';
jest.spyOn(keyValuePairRepository, 'findOne').mockResolvedValue(null);
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValue({
[key]: {
isSensitive: true,
type: ConfigVariableType.STRING,
group: ConfigVariablesGroup.ServerConfig,
description: 'Test sensitive config',
},
});
(
configValueConverter.convertAppValueToDbValue as jest.Mock
).mockReturnValue(convertedValue);
// Mock encryption to throw an error
(authUtils.encryptText as jest.Mock).mockImplementationOnce(() => {
throw new Error('Encryption failed');
});
await service.set(key, value);
expect(keyValuePairRepository.insert).toHaveBeenCalledWith({
key: key as string,
value: convertedValue, // Should fall back to unconverted value
userId: null,
workspaceId: null,
type: KeyValuePairType.CONFIG_VARIABLE,
});
});
});
describe('delete', () => {
@ -315,6 +588,47 @@ describe('ConfigStorageService', () => {
).toHaveBeenCalledTimes(1); // Only called for non-null value
});
});
it('should decrypt sensitive string values in loadAll', async () => {
const configVars: KeyValuePair[] = [
createMockKeyValuePair('SENSITIVE_CONFIG', 'encrypted:sensitive-value'),
createMockKeyValuePair('NORMAL_CONFIG', 'normal-value'),
];
jest.spyOn(keyValuePairRepository, 'find').mockResolvedValue(configVars);
jest.spyOn(TypedReflect, 'getMetadata').mockReturnValue({
SENSITIVE_CONFIG: {
isSensitive: true,
type: ConfigVariableType.STRING,
group: ConfigVariablesGroup.ServerConfig,
description: 'Test sensitive config',
},
NORMAL_CONFIG: {
type: ConfigVariableType.STRING,
group: ConfigVariablesGroup.ServerConfig,
description: 'Test normal config',
},
});
(
configValueConverter.convertDbValueToAppValue as jest.Mock
).mockImplementation((value) => value);
const result = await service.loadAll();
expect(result.size).toBe(2);
expect(result.get('SENSITIVE_CONFIG' as keyof ConfigVariables)).toBe(
'sensitive-value',
);
expect(result.get('NORMAL_CONFIG' as keyof ConfigVariables)).toBe(
'normal-value',
);
expect(environmentConfigDriver.get).toHaveBeenCalledWith('APP_SECRET');
expect(authUtils.decryptText).toHaveBeenCalledWith(
'encrypted:sensitive-value',
'test-secret',
);
});
});
describe('Edge Cases and Additional Scenarios', () => {

View File

@ -3,12 +3,19 @@ import { InjectRepository } from '@nestjs/typeorm';
import { FindOptionsWhere, IsNull, Repository } from 'typeorm';
import {
decryptText,
encryptText,
} from 'src/engine/core-modules/auth/auth.util';
import {
KeyValuePair,
KeyValuePairType,
} from 'src/engine/core-modules/key-value-pair/key-value-pair.entity';
import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
import { ConfigValueConverterService } from 'src/engine/core-modules/twenty-config/conversion/config-value-converter.service';
import { EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver';
import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/enums/config-variable-type.enum';
import { TypedReflect } from 'src/utils/typed-reflect';
import { ConfigStorageInterface } from './interfaces/config-storage.interface';
@ -20,6 +27,7 @@ export class ConfigStorageService implements ConfigStorageInterface {
@InjectRepository(KeyValuePair, 'core')
private readonly keyValuePairRepository: Repository<KeyValuePair>,
private readonly configValueConverter: ConfigValueConverterService,
private readonly environmentConfigDriver: EnvironmentConfigDriver,
) {}
private getConfigVariableWhereClause(
@ -33,6 +41,67 @@ export class ConfigStorageService implements ConfigStorageInterface {
};
}
private getAppSecret(): string {
return this.environmentConfigDriver.get('APP_SECRET');
}
private getConfigMetadata<T extends keyof ConfigVariables>(key: T) {
return TypedReflect.getMetadata('config-variables', ConfigVariables)?.[
key as string
];
}
private logAndRethrow(message: string, error: any): never {
this.logger.error(message, error);
throw error;
}
private async convertAndSecureValue<T extends keyof ConfigVariables>(
value: any,
key: T,
isDecrypt = false,
): Promise<any> {
try {
const convertedValue = isDecrypt
? this.configValueConverter.convertDbValueToAppValue(value, key)
: this.configValueConverter.convertAppValueToDbValue(value);
const metadata = this.getConfigMetadata(key);
const isSensitiveString =
metadata?.isSensitive &&
metadata.type === ConfigVariableType.STRING &&
typeof convertedValue === 'string';
if (!isSensitiveString) {
return convertedValue;
}
const appSecret = this.getAppSecret();
try {
return isDecrypt
? decryptText(convertedValue, appSecret)
: encryptText(convertedValue, appSecret);
} catch (error) {
this.logger.error(
`Failed to ${isDecrypt ? 'decrypt' : 'encrypt'} value for key ${
key as string
}`,
error,
);
return convertedValue;
}
} catch (error) {
this.logAndRethrow(
`Failed to convert value ${
isDecrypt ? 'from DB' : 'to DB'
} for key ${key as string}`,
error,
);
}
}
async get<T extends keyof ConfigVariables>(
key: T,
): Promise<ConfigVariables[T] | undefined> {
@ -45,25 +114,13 @@ export class ConfigStorageService implements ConfigStorageInterface {
return undefined;
}
try {
this.logger.debug(
`Fetching config for ${key as string} in database: ${result?.value}`,
);
this.logger.debug(
`Fetching config for ${key as string} in database: ${result?.value}`,
);
return this.configValueConverter.convertDbValueToAppValue(
result.value,
key,
);
} catch (error) {
this.logger.error(
`Failed to convert value to app type for key ${key as string}`,
error,
);
throw error;
}
return await this.convertAndSecureValue(result.value, key, true);
} catch (error) {
this.logger.error(`Failed to get config for ${key as string}`, error);
throw error;
this.logAndRethrow(`Failed to get config for ${key as string}`, error);
}
}
@ -72,18 +129,7 @@ export class ConfigStorageService implements ConfigStorageInterface {
value: ConfigVariables[T],
): Promise<void> {
try {
let processedValue;
try {
processedValue =
this.configValueConverter.convertAppValueToDbValue(value);
} catch (error) {
this.logger.error(
`Failed to convert value to storage type for key ${key as string}`,
error,
);
throw error;
}
const dbValue = await this.convertAndSecureValue(value, key, false);
const existingRecord = await this.keyValuePairRepository.findOne({
where: this.getConfigVariableWhereClause(key as string),
@ -92,20 +138,19 @@ export class ConfigStorageService implements ConfigStorageInterface {
if (existingRecord) {
await this.keyValuePairRepository.update(
{ id: existingRecord.id },
{ value: processedValue },
{ value: dbValue },
);
} else {
await this.keyValuePairRepository.insert({
key: key as string,
value: processedValue,
value: dbValue,
userId: null,
workspaceId: null,
type: KeyValuePairType.CONFIG_VARIABLE,
});
}
} catch (error) {
this.logger.error(`Failed to set config for ${key as string}`, error);
throw error;
this.logAndRethrow(`Failed to set config for ${key as string}`, error);
}
}
@ -115,8 +160,7 @@ export class ConfigStorageService implements ConfigStorageInterface {
this.getConfigVariableWhereClause(key as string),
);
} catch (error) {
this.logger.error(`Failed to delete config for ${key as string}`, error);
throw error;
this.logAndRethrow(`Failed to delete config for ${key as string}`, error);
}
}
@ -138,9 +182,10 @@ export class ConfigStorageService implements ConfigStorageInterface {
const key = configVar.key as keyof ConfigVariables;
try {
const value = this.configValueConverter.convertDbValueToAppValue(
const value = await this.convertAndSecureValue(
configVar.value,
key,
true,
);
if (value !== undefined) {
@ -148,7 +193,7 @@ export class ConfigStorageService implements ConfigStorageInterface {
}
} catch (error) {
this.logger.error(
`Failed to convert value to app type for key ${key as string}`,
`Failed to process config value for key ${key as string}`,
error,
);
continue;
@ -158,8 +203,7 @@ export class ConfigStorageService implements ConfigStorageInterface {
return result;
} catch (error) {
this.logger.error('Failed to load all config variables', error);
throw error;
this.logAndRethrow('Failed to load all config variables', error);
}
}
}

View File

@ -0,0 +1,17 @@
import { CustomException } from 'src/utils/custom-exception';
export class ConfigVariableException extends CustomException {
constructor(message: string, code: ConfigVariableExceptionCode) {
super(message, code);
}
}
export enum ConfigVariableExceptionCode {
DATABASE_CONFIG_DISABLED = 'DATABASE_CONFIG_DISABLED',
ENVIRONMENT_ONLY_VARIABLE = 'ENVIRONMENT_ONLY_VARIABLE',
VARIABLE_NOT_FOUND = 'VARIABLE_NOT_FOUND',
VALIDATION_FAILED = 'VALIDATION_FAILED',
UNSUPPORTED_CONFIG_TYPE = 'UNSUPPORTED_CONFIG_TYPE',
METADATA_NOT_FOUND = 'METADATA_NOT_FOUND',
INTERNAL_ERROR = 'INTERNAL_ERROR',
}

View File

@ -2,6 +2,7 @@ import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
import { CONFIG_VARIABLES_INSTANCE_TOKEN } from 'src/engine/core-modules/twenty-config/constants/config-variables-instance-tokens.constants';
import { DatabaseConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/database-config.driver';
import { EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver';
import { ConfigSource } from 'src/engine/core-modules/twenty-config/enums/config-source.enum';
@ -58,7 +59,6 @@ const mockConfigVarMetadata = {
},
};
// Setup with database driver
const setupTestModule = async (isDatabaseConfigEnabled = true) => {
const configServiceMock = {
get: jest.fn().mockImplementation((key) => {
@ -70,6 +70,13 @@ const setupTestModule = async (isDatabaseConfigEnabled = true) => {
}),
};
const mockConfigVariablesInstance = {
TEST_VAR: 'test value',
ENV_ONLY_VAR: 'env only value',
SENSITIVE_VAR: 'sensitive value',
NO_METADATA_KEY: 'value without metadata',
};
const module: TestingModule = await Test.createTestingModule({
providers: [
TwentyConfigService,
@ -77,8 +84,10 @@ const setupTestModule = async (isDatabaseConfigEnabled = true) => {
provide: DatabaseConfigDriver,
useValue: {
get: jest.fn(),
set: jest.fn(),
update: jest.fn(),
getCacheInfo: jest.fn(),
delete: jest.fn(),
},
},
{
@ -93,6 +102,10 @@ const setupTestModule = async (isDatabaseConfigEnabled = true) => {
provide: ConfigService,
useValue: configServiceMock,
},
{
provide: CONFIG_VARIABLES_INSTANCE_TOKEN,
useValue: mockConfigVariablesInstance,
},
],
}).compile();
@ -104,10 +117,10 @@ const setupTestModule = async (isDatabaseConfigEnabled = true) => {
EnvironmentConfigDriver,
),
configService: module.get<ConfigService>(ConfigService),
configVariablesInstance: module.get(CONFIG_VARIABLES_INSTANCE_TOKEN),
};
};
// Setup without database driver
const setupTestModuleWithoutDb = async () => {
const configServiceMock = {
get: jest.fn().mockImplementation((key) => {
@ -119,6 +132,13 @@ const setupTestModuleWithoutDb = async () => {
}),
};
const mockConfigVariablesInstance = {
TEST_VAR: 'test value',
ENV_ONLY_VAR: 'env only value',
SENSITIVE_VAR: 'sensitive value',
NO_METADATA_KEY: 'value without metadata',
};
const module: TestingModule = await Test.createTestingModule({
providers: [
TwentyConfigService,
@ -134,6 +154,10 @@ const setupTestModuleWithoutDb = async () => {
provide: ConfigService,
useValue: configServiceMock,
},
{
provide: CONFIG_VARIABLES_INSTANCE_TOKEN,
useValue: mockConfigVariablesInstance,
},
],
}).compile();
@ -143,6 +167,7 @@ const setupTestModuleWithoutDb = async () => {
EnvironmentConfigDriver,
),
configService: module.get<ConfigService>(ConfigService),
configVariablesInstance: module.get(CONFIG_VARIABLES_INSTANCE_TOKEN),
};
};
@ -278,6 +303,10 @@ describe('TwentyConfigService', () => {
});
describe('update', () => {
beforeEach(() => {
jest.spyOn(service, 'validateConfigVariableExists').mockReturnValue(true);
});
it('should throw error when database driver is not active', async () => {
setPrivateProps(service, { isDatabaseDriverActive: false });
@ -468,4 +497,51 @@ describe('TwentyConfigService', () => {
});
});
});
describe('validateConfigVariableExists', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should be called by set, update, and delete methods', async () => {
const validateSpy = jest
.spyOn(service, 'validateConfigVariableExists')
.mockReturnValue(true);
setPrivateProps(service, { isDatabaseDriverActive: true });
jest
.spyOn(service as any, 'validateNotEnvOnly')
.mockImplementation(() => {});
await service.set('TEST_VAR' as keyof ConfigVariables, 'test value');
await service.update(
'TEST_VAR' as keyof ConfigVariables,
'updated value',
);
await service.delete('TEST_VAR' as keyof ConfigVariables);
expect(validateSpy).toHaveBeenCalledTimes(3);
expect(validateSpy).toHaveBeenCalledWith('TEST_VAR');
});
it('should return true for valid config variables with metadata', () => {
jest.spyOn(service, 'validateConfigVariableExists').mockRestore();
jest
.spyOn(service as any, 'getMetadata')
.mockReturnValue(mockConfigVarMetadata.TEST_VAR);
expect(service.validateConfigVariableExists('TEST_VAR')).toBe(true);
});
it('should throw error when config variable does not exist', () => {
jest.spyOn(service, 'validateConfigVariableExists').mockRestore();
expect(() => {
service.validateConfigVariableExists('MISSING_KEY');
}).toThrow(
'Config variable "MISSING_KEY" does not exist in ConfigVariables',
);
});
});
});

View File

@ -1,14 +1,19 @@
import { Injectable, Logger, Optional } from '@nestjs/common';
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
import { isString } from 'class-validator';
import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
import { CONFIG_VARIABLES_INSTANCE_TOKEN } from 'src/engine/core-modules/twenty-config/constants/config-variables-instance-tokens.constants';
import { CONFIG_VARIABLES_MASKING_CONFIG } from 'src/engine/core-modules/twenty-config/constants/config-variables-masking-config';
import { ConfigVariablesMetadataOptions } from 'src/engine/core-modules/twenty-config/decorators/config-variables-metadata.decorator';
import { DatabaseConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/database-config.driver';
import { EnvironmentConfigDriver } from 'src/engine/core-modules/twenty-config/drivers/environment-config.driver';
import { ConfigSource } from 'src/engine/core-modules/twenty-config/enums/config-source.enum';
import { ConfigVariablesMaskingStrategies } from 'src/engine/core-modules/twenty-config/enums/config-variables-masking-strategies.enum';
import {
ConfigVariableException,
ConfigVariableExceptionCode,
} from 'src/engine/core-modules/twenty-config/twenty-config.exception';
import { configVariableMaskSensitiveData } from 'src/engine/core-modules/twenty-config/utils/config-variable-mask-sensitive-data.util';
import { isEnvOnlyConfigVar } from 'src/engine/core-modules/twenty-config/utils/is-env-only-config-var.util';
import { TypedReflect } from 'src/utils/typed-reflect';
@ -21,6 +26,8 @@ export class TwentyConfigService {
constructor(
private readonly environmentConfigDriver: EnvironmentConfigDriver,
@Optional() private readonly databaseConfigDriver: DatabaseConfigDriver,
@Inject(CONFIG_VARIABLES_INSTANCE_TOKEN)
private readonly configVariablesInstance: ConfigVariables,
) {
const isConfigVariablesInDbEnabled = this.environmentConfigDriver.get(
'IS_CONFIG_VARIABLES_IN_DB_ENABLED',
@ -59,49 +66,37 @@ export class TwentyConfigService {
if (cachedValueFromDb !== undefined) {
return cachedValueFromDb;
}
return this.environmentConfigDriver.get(key);
}
return this.environmentConfigDriver.get(key);
}
async set<T extends keyof ConfigVariables>(
key: T,
value: ConfigVariables[T],
): Promise<void> {
this.validateDatabaseDriverActive('set');
this.validateNotEnvOnly(key, 'create');
this.validateConfigVariableExists(key as string);
await this.databaseConfigDriver.set(key, value);
}
async update<T extends keyof ConfigVariables>(
key: T,
value: ConfigVariables[T],
): Promise<void> {
if (!this.isDatabaseDriverActive) {
throw new Error(
'Database configuration is disabled or unavailable, cannot update configuration',
);
}
this.validateDatabaseDriverActive('update');
this.validateNotEnvOnly(key, 'update');
this.validateConfigVariableExists(key as string);
const metadata =
TypedReflect.getMetadata('config-variables', ConfigVariables) ?? {};
const envMetadata = metadata[key];
if (envMetadata?.isEnvOnly) {
throw new Error(
`Cannot update environment-only variable: ${key as string}`,
);
}
try {
await this.databaseConfigDriver.update(key, value);
this.logger.debug(`Updated config variable: ${key as string}`);
} catch (error) {
this.logger.error(`Failed to update config for ${key as string}`, error);
throw error;
}
await this.databaseConfigDriver.update(key, value);
}
getMetadata(
key: keyof ConfigVariables,
): ConfigVariablesMetadataOptions | undefined {
const metadata =
TypedReflect.getMetadata('config-variables', ConfigVariables) ?? {};
return metadata[key];
return this.getConfigMetadata()[key as string];
}
getAll(): Record<
@ -121,44 +116,14 @@ export class TwentyConfigService {
}
> = {};
const configVars = new ConfigVariables();
const metadata =
TypedReflect.getMetadata('config-variables', ConfigVariables) ?? {};
const metadata = this.getConfigMetadata();
Object.entries(metadata).forEach(([key, envMetadata]) => {
let value = this.get(key as keyof ConfigVariables) ?? '';
let source = ConfigSource.ENVIRONMENT;
const typedKey = key as keyof ConfigVariables;
let value = this.get(typedKey) ?? '';
const source = this.determineConfigSource(typedKey, value, envMetadata);
if (!this.isDatabaseDriverActive || envMetadata.isEnvOnly) {
if (value === configVars[key as keyof ConfigVariables]) {
source = ConfigSource.DEFAULT;
}
} else {
const dbValue = value;
source =
dbValue !== configVars[key as keyof ConfigVariables]
? ConfigSource.DATABASE
: ConfigSource.DEFAULT;
}
if (isString(value) && key in CONFIG_VARIABLES_MASKING_CONFIG) {
const varMaskingConfig =
CONFIG_VARIABLES_MASKING_CONFIG[
key as keyof typeof CONFIG_VARIABLES_MASKING_CONFIG
];
const options =
varMaskingConfig.strategy ===
ConfigVariablesMaskingStrategies.LAST_N_CHARS
? { chars: varMaskingConfig.chars }
: undefined;
value = configVariableMaskSensitiveData(
value,
varMaskingConfig.strategy,
{ ...options, variableName: key },
);
}
value = this.maskSensitiveValue(typedKey, value);
result[key] = {
value,
@ -170,6 +135,29 @@ export class TwentyConfigService {
return result;
}
getVariableWithMetadata(key: keyof ConfigVariables): {
value: ConfigVariables[keyof ConfigVariables];
metadata: ConfigVariablesMetadataOptions;
source: ConfigSource;
} | null {
const metadata = this.getMetadata(key);
if (!metadata) {
return null;
}
let value = this.get(key) ?? '';
const source = this.determineConfigSource(key, value, metadata);
value = this.maskSensitiveValue(key, value);
return {
value,
metadata,
source,
};
}
getCacheInfo(): {
usingDatabaseDriver: boolean;
cacheStats?: {
@ -191,4 +179,100 @@ export class TwentyConfigService {
return result;
}
async delete(key: keyof ConfigVariables): Promise<void> {
this.validateDatabaseDriverActive('delete');
this.validateConfigVariableExists(key as string);
await this.databaseConfigDriver.delete(key);
}
private getConfigMetadata(): Record<string, ConfigVariablesMetadataOptions> {
return TypedReflect.getMetadata('config-variables', ConfigVariables) ?? {};
}
private validateDatabaseDriverActive(operation: string): void {
if (!this.isDatabaseDriverActive) {
throw new ConfigVariableException(
`Database configuration is disabled or unavailable, cannot ${operation} configuration`,
ConfigVariableExceptionCode.DATABASE_CONFIG_DISABLED,
);
}
}
private validateNotEnvOnly<T extends keyof ConfigVariables>(
key: T,
operation: string,
): void {
const metadata = this.getConfigMetadata();
const envMetadata = metadata[key as string];
if (envMetadata?.isEnvOnly) {
throw new ConfigVariableException(
`Cannot ${operation} environment-only variable: ${key as string}`,
ConfigVariableExceptionCode.ENVIRONMENT_ONLY_VARIABLE,
);
}
}
private determineConfigSource<T extends keyof ConfigVariables>(
key: T,
value: ConfigVariables[T],
metadata: ConfigVariablesMetadataOptions,
): ConfigSource {
const configVars = new ConfigVariables();
if (!this.isDatabaseDriverActive || metadata.isEnvOnly) {
return value === configVars[key]
? ConfigSource.DEFAULT
: ConfigSource.ENVIRONMENT;
}
const dbValue = this.databaseConfigDriver.get(key);
if (dbValue !== undefined) {
return ConfigSource.DATABASE;
}
return value === configVars[key]
? ConfigSource.DEFAULT
: ConfigSource.ENVIRONMENT;
}
private maskSensitiveValue<T extends keyof ConfigVariables>(
key: T,
value: any,
): any {
if (!isString(value) || !(key in CONFIG_VARIABLES_MASKING_CONFIG)) {
return value;
}
const varMaskingConfig =
CONFIG_VARIABLES_MASKING_CONFIG[
key as keyof typeof CONFIG_VARIABLES_MASKING_CONFIG
];
const options =
varMaskingConfig.strategy ===
ConfigVariablesMaskingStrategies.LAST_N_CHARS
? { chars: varMaskingConfig.chars }
: undefined;
return configVariableMaskSensitiveData(value, varMaskingConfig.strategy, {
...options,
variableName: key as string,
});
}
validateConfigVariableExists(key: string): boolean {
const metadata = this.getConfigMetadata();
const keyExists = key in metadata;
if (!keyExists) {
throw new ConfigVariableException(
`Config variable "${key}" does not exist in ConfigVariables`,
ConfigVariableExceptionCode.VARIABLE_NOT_FOUND,
);
}
return true;
}
}

View File

@ -1,6 +0,0 @@
export type ConfigVariableType =
| 'boolean'
| 'number'
| 'array'
| 'string'
| 'enum';

View File

@ -7,6 +7,7 @@ import {
IsString,
} from 'class-validator';
import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/enums/config-variable-type.enum';
import { applyBasicValidators } from 'src/engine/core-modules/twenty-config/utils/apply-basic-validators.util';
import { configTransformers } from 'src/engine/core-modules/twenty-config/utils/config-transformers.util';
@ -50,7 +51,11 @@ describe('applyBasicValidators', () => {
return jest.fn();
});
applyBasicValidators('boolean', mockTarget, mockPropertyKey);
applyBasicValidators(
ConfigVariableType.BOOLEAN,
mockTarget,
mockPropertyKey,
);
expect(Transform).toHaveBeenCalled();
expect(IsBoolean).toHaveBeenCalled();
@ -81,7 +86,11 @@ describe('applyBasicValidators', () => {
return jest.fn();
});
applyBasicValidators('number', mockTarget, mockPropertyKey);
applyBasicValidators(
ConfigVariableType.NUMBER,
mockTarget,
mockPropertyKey,
);
expect(Transform).toHaveBeenCalled();
expect(IsNumber).toHaveBeenCalled();
@ -104,7 +113,11 @@ describe('applyBasicValidators', () => {
describe('string type', () => {
it('should apply string validator', () => {
applyBasicValidators('string', mockTarget, mockPropertyKey);
applyBasicValidators(
ConfigVariableType.STRING,
mockTarget,
mockPropertyKey,
);
expect(IsString).toHaveBeenCalled();
expect(Transform).not.toHaveBeenCalled(); // String doesn't need a transform
@ -115,7 +128,12 @@ describe('applyBasicValidators', () => {
it('should apply enum validator with string array options', () => {
const enumOptions = ['option1', 'option2', 'option3'];
applyBasicValidators('enum', mockTarget, mockPropertyKey, enumOptions);
applyBasicValidators(
ConfigVariableType.ENUM,
mockTarget,
mockPropertyKey,
enumOptions,
);
expect(IsEnum).toHaveBeenCalledWith(enumOptions);
expect(Transform).not.toHaveBeenCalled(); // Enum doesn't need a transform
@ -128,14 +146,23 @@ describe('applyBasicValidators', () => {
Option3 = 'value3',
}
applyBasicValidators('enum', mockTarget, mockPropertyKey, TestEnum);
applyBasicValidators(
ConfigVariableType.ENUM,
mockTarget,
mockPropertyKey,
TestEnum,
);
expect(IsEnum).toHaveBeenCalledWith(TestEnum);
expect(Transform).not.toHaveBeenCalled(); // Enum doesn't need a transform
});
it('should not apply enum validator without options', () => {
applyBasicValidators('enum', mockTarget, mockPropertyKey);
applyBasicValidators(
ConfigVariableType.ENUM,
mockTarget,
mockPropertyKey,
);
expect(IsEnum).not.toHaveBeenCalled();
expect(Transform).not.toHaveBeenCalled();
@ -144,7 +171,11 @@ describe('applyBasicValidators', () => {
describe('array type', () => {
it('should apply array validator', () => {
applyBasicValidators('array', mockTarget, mockPropertyKey);
applyBasicValidators(
ConfigVariableType.ARRAY,
mockTarget,
mockPropertyKey,
);
expect(IsArray).toHaveBeenCalled();
expect(Transform).not.toHaveBeenCalled(); // Array doesn't need a transform

View File

@ -7,8 +7,12 @@ import {
IsString,
} from 'class-validator';
import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/enums/config-variable-type.enum';
import {
ConfigVariableException,
ConfigVariableExceptionCode,
} from 'src/engine/core-modules/twenty-config/twenty-config.exception';
import { ConfigVariableOptions } from 'src/engine/core-modules/twenty-config/types/config-variable-options.type';
import { ConfigVariableType } from 'src/engine/core-modules/twenty-config/types/config-variable-type.type';
import { configTransformers } from 'src/engine/core-modules/twenty-config/utils/config-transformers.util';
export function applyBasicValidators(
@ -18,7 +22,7 @@ export function applyBasicValidators(
options?: ConfigVariableOptions,
): void {
switch (type) {
case 'boolean':
case ConfigVariableType.BOOLEAN:
Transform(({ value }) => {
const result = configTransformers.boolean(value);
@ -27,7 +31,7 @@ export function applyBasicValidators(
IsBoolean()(target, propertyKey);
break;
case 'number':
case ConfigVariableType.NUMBER:
Transform(({ value }) => {
const result = configTransformers.number(value);
@ -36,21 +40,24 @@ export function applyBasicValidators(
IsNumber()(target, propertyKey);
break;
case 'string':
case ConfigVariableType.STRING:
IsString()(target, propertyKey);
break;
case 'enum':
case ConfigVariableType.ENUM:
if (options) {
IsEnum(options)(target, propertyKey);
}
break;
case 'array':
case ConfigVariableType.ARRAY:
IsArray()(target, propertyKey);
break;
default:
throw new Error(`Unsupported config variable type: ${type}`);
throw new ConfigVariableException(
`Unsupported config variable type: ${type}`,
ConfigVariableExceptionCode.UNSUPPORTED_CONFIG_TYPE,
);
}
}