rename core-module environment to twenty-config (#11445)

closes https://github.com/twentyhq/core-team-issues/issues/759
This commit is contained in:
nitin
2025-04-09 17:41:26 +05:30
committed by GitHub
parent fe6d0241a8
commit bd3ec6d5e3
193 changed files with 1454 additions and 1422 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,28 @@
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';
describe('CONFIG_VARIABLES_GROUP_METADATA', () => {
it('should include all ConfigVariablesGroup enum values', () => {
const enumValues = Object.values(ConfigVariablesGroup);
const metadataKeys = Object.keys(CONFIG_VARIABLES_GROUP_METADATA);
enumValues.forEach((enumValue) => {
expect(metadataKeys).toContain(enumValue);
});
metadataKeys.forEach((key) => {
expect(enumValues).toContain(key);
});
expect(enumValues.length).toBe(metadataKeys.length);
});
it('should have unique position values', () => {
const positions = Object.values(CONFIG_VARIABLES_GROUP_METADATA).map(
(metadata) => metadata.position,
);
const uniquePositions = new Set(positions);
expect(positions.length).toBe(uniquePositions.size);
});
});

View File

@ -0,0 +1,122 @@
import { ConfigVariablesGroup } from 'src/engine/core-modules/twenty-config/enums/config-variables-group.enum';
type GroupMetadata = {
position: number;
description: string;
isHiddenOnLoad: boolean;
};
export const CONFIG_VARIABLES_GROUP_METADATA: Record<
ConfigVariablesGroup,
GroupMetadata
> = {
[ConfigVariablesGroup.ServerConfig]: {
position: 100,
description: '',
isHiddenOnLoad: false,
},
[ConfigVariablesGroup.RateLimiting]: {
position: 200,
description:
'We use this to limit the number of requests to the server. This is useful to prevent abuse.',
isHiddenOnLoad: true,
},
[ConfigVariablesGroup.StorageConfig]: {
position: 300,
description:
'By default, file uploads are stored on the local filesystem, which is suitable for traditional servers. However, for ephemeral deployment servers, it is essential to configure the variables here to set up an S3-compatible file system. This ensures that files remain unaffected by server redeploys.',
isHiddenOnLoad: false,
},
[ConfigVariablesGroup.GoogleAuth]: {
position: 400,
description: 'Configure Google integration (login, calendar, email)',
isHiddenOnLoad: false,
},
[ConfigVariablesGroup.MicrosoftAuth]: {
position: 500,
description: 'Configure Microsoft integration (login, calendar, email)',
isHiddenOnLoad: false,
},
[ConfigVariablesGroup.EmailSettings]: {
position: 600,
description:
'This is used for emails that are sent by the app such as invitations to join a workspace. This is not used to email CRM contacts.',
isHiddenOnLoad: false,
},
[ConfigVariablesGroup.Logging]: {
position: 700,
description: '',
isHiddenOnLoad: true,
},
[ConfigVariablesGroup.ExceptionHandler]: {
position: 800,
description:
'By default, exceptions are sent to the logs. This should be enough for most self-hosting use-cases. For our cloud app we use Sentry.',
isHiddenOnLoad: true,
},
[ConfigVariablesGroup.Metering]: {
position: 900,
description:
'By default, metrics are sent to the console. OpenTelemetry collector can be set up for self-hosting use-cases.',
isHiddenOnLoad: true,
},
[ConfigVariablesGroup.Other]: {
position: 1000,
description:
"The variables in this section are mostly used for internal purposes (running our Cloud offering), but shouldn't usually be required for a simple self-hosted instance",
isHiddenOnLoad: true,
},
[ConfigVariablesGroup.BillingConfig]: {
position: 1100,
description:
'We use Stripe in our Cloud app to charge customers. Not relevant to Self-hosters.',
isHiddenOnLoad: true,
},
[ConfigVariablesGroup.CaptchaConfig]: {
position: 1200,
description:
'This protects critical endpoints like login and signup with a captcha to prevent bot attacks. Likely unnecessary for self-hosting scenarios.',
isHiddenOnLoad: true,
},
[ConfigVariablesGroup.CloudflareConfig]: {
position: 1300,
description: '',
isHiddenOnLoad: true,
},
[ConfigVariablesGroup.LLM]: {
position: 1400,
description:
'Configure the LLM provider and model to use for the app. This is experimental and not linked to any public feature.',
isHiddenOnLoad: true,
},
[ConfigVariablesGroup.ServerlessConfig]: {
position: 1500,
description:
'In our multi-tenant cloud app, we offload untrusted custom code from workflows to a serverless system (Lambda) for enhanced security and scalability. Self-hosters with a single tenant can typically ignore this configuration.',
isHiddenOnLoad: true,
},
[ConfigVariablesGroup.SSL]: {
position: 1600,
description:
'Configure this if you want to setup SSL on your server or full end-to-end encryption. If you just want basic HTTPS, a simple setup like Cloudflare in flexible mode might be easier.',
isHiddenOnLoad: true,
},
[ConfigVariablesGroup.SupportChatConfig]: {
position: 1700,
description:
'We use this to setup a small support chat on the bottom left. Currently powered by Front.',
isHiddenOnLoad: true,
},
[ConfigVariablesGroup.AnalyticsConfig]: {
position: 1800,
description:
'Were running a test to perform analytics within the app. This will evolve.',
isHiddenOnLoad: true,
},
[ConfigVariablesGroup.TokensDuration]: {
position: 1900,
description:
'These have been set to sensible default so you probably dont need to change them unless you have a specific use-case.',
isHiddenOnLoad: true,
},
};

View File

@ -0,0 +1,29 @@
import { ConfigVariablesMaskingStrategies } from 'src/engine/core-modules/twenty-config/enums/config-variables-masking-strategies.enum';
type LastNCharsConfig = {
strategy: ConfigVariablesMaskingStrategies.LAST_N_CHARS;
chars: number;
};
type HidePasswordConfig = {
strategy: ConfigVariablesMaskingStrategies.HIDE_PASSWORD;
};
type MaskingConfigType = {
APP_SECRET: LastNCharsConfig;
PG_DATABASE_URL: HidePasswordConfig;
REDIS_URL: HidePasswordConfig;
};
export const CONFIG_VARIABLES_MASKING_CONFIG: MaskingConfigType = {
APP_SECRET: {
strategy: ConfigVariablesMaskingStrategies.LAST_N_CHARS,
chars: 5,
},
PG_DATABASE_URL: {
strategy: ConfigVariablesMaskingStrategies.HIDE_PASSWORD,
},
REDIS_URL: {
strategy: ConfigVariablesMaskingStrategies.HIDE_PASSWORD,
},
} as const;

View File

@ -0,0 +1,2 @@
export const CONFIG_VARIABLES_METADATA_DECORATOR_KEY =
'config-variables-metadata' as const;

View File

@ -0,0 +1,2 @@
export const CONFIG_VARIABLES_METADATA_DECORATOR_NAMES_KEY =
'config-variable-names' as const;

View File

@ -0,0 +1,73 @@
import { plainToClass } from 'class-transformer';
import { IsString, validateSync } from 'class-validator';
import 'reflect-metadata';
import { AssertOrWarn } from 'src/engine/core-modules/twenty-config/decorators/assert-or-warn.decorator';
describe('AssertOrWarn Decorator', () => {
it('should pass validation if the condition is met', () => {
class ConfigVariables {
@AssertOrWarn((object, value) => value > 10, {
message: 'Value should be higher than 10',
})
someProperty!: number;
}
const validatedConfig = plainToClass(ConfigVariables, {
someProperty: 15,
});
const warnings = validateSync(validatedConfig, { groups: ['warning'] });
expect(warnings.length).toBe(0);
});
it('should provide a warning message if the condition is not met', () => {
class ConfigVariables {
@AssertOrWarn((object, value) => value > 10, {
message: 'Value should be higher than 10',
})
someProperty!: number;
}
const validatedConfig = plainToClass(ConfigVariables, {
someProperty: 9,
});
const warnings = validateSync(validatedConfig, { groups: ['warning'] });
expect(warnings.length).toBe(1);
expect(warnings[0].constraints!.AssertOrWarn).toBe(
'Value should be higher than 10',
);
});
it('should not impact errors if the condition is not met', () => {
class ConfigVariables {
@IsString()
unit: string;
@AssertOrWarn(
(object, value) => object.unit == 's' && value.toString().length <= 10,
{
message: 'The unit is in seconds but the duration in milliseconds',
},
)
duration!: number;
}
const validatedConfig = plainToClass(ConfigVariables, {
duration: 1731944140876000,
unit: 's',
});
const warnings = validateSync(validatedConfig, { groups: ['warning'] });
const errors = validateSync(validatedConfig, { strictGroups: true });
expect(errors.length).toBe(0);
expect(warnings.length).toBe(1);
expect(warnings[0].constraints!.AssertOrWarn).toBe(
'The unit is in seconds but the duration in milliseconds',
);
});
});

View File

@ -0,0 +1,66 @@
import { plainToClass } from 'class-transformer';
import { CastToLogLevelArray } from 'src/engine/core-modules/twenty-config/decorators/cast-to-log-level-array.decorator';
class TestClass {
@CastToLogLevelArray()
logLevels?: any;
}
describe('CastToLogLevelArray Decorator', () => {
it('should cast "log" to ["log"]', () => {
const transformedClass = plainToClass(TestClass, { logLevels: 'log' });
expect(transformedClass.logLevels).toStrictEqual(['log']);
});
it('should cast "error" to ["error"]', () => {
const transformedClass = plainToClass(TestClass, { logLevels: 'error' });
expect(transformedClass.logLevels).toStrictEqual(['error']);
});
it('should cast "warn" to ["warn"]', () => {
const transformedClass = plainToClass(TestClass, { logLevels: 'warn' });
expect(transformedClass.logLevels).toStrictEqual(['warn']);
});
it('should cast "debug" to ["debug"]', () => {
const transformedClass = plainToClass(TestClass, { logLevels: 'debug' });
expect(transformedClass.logLevels).toStrictEqual(['debug']);
});
it('should cast "verbose" to ["verbose"]', () => {
const transformedClass = plainToClass(TestClass, { logLevels: 'verbose' });
expect(transformedClass.logLevels).toStrictEqual(['verbose']);
});
it('should cast "verbose,error,warn" to ["verbose", "error", "warn"]', () => {
const transformedClass = plainToClass(TestClass, {
logLevels: 'verbose,error,warn',
});
expect(transformedClass.logLevels).toStrictEqual([
'verbose',
'error',
'warn',
]);
});
it('should cast "toto" to undefined', () => {
const transformedClass = plainToClass(TestClass, { logLevels: 'toto' });
expect(transformedClass.logLevels).toBeUndefined();
});
it('should cast "verbose,error,toto" to undefined', () => {
const transformedClass = plainToClass(TestClass, {
logLevels: 'verbose,error,toto',
});
expect(transformedClass.logLevels).toBeUndefined();
});
});

View File

@ -0,0 +1,58 @@
import { plainToClass } from 'class-transformer';
import { CastToPositiveNumber } from 'src/engine/core-modules/twenty-config/decorators/cast-to-positive-number.decorator';
class TestClass {
@CastToPositiveNumber()
numberProperty?: any;
}
describe('CastToPositiveNumber Decorator', () => {
it('should cast number to number', () => {
const transformedClass = plainToClass(TestClass, { numberProperty: 123 });
expect(transformedClass.numberProperty).toBe(123);
});
it('should cast string to number', () => {
const transformedClass = plainToClass(TestClass, { numberProperty: '123' });
expect(transformedClass.numberProperty).toBe(123);
});
it('should cast null to undefined', () => {
const transformedClass = plainToClass(TestClass, { numberProperty: null });
expect(transformedClass.numberProperty).toBe(undefined);
});
it('should cast negative number to undefined', () => {
const transformedClass = plainToClass(TestClass, { numberProperty: -12 });
expect(transformedClass.numberProperty).toBe(undefined);
});
it('should cast undefined to undefined', () => {
const transformedClass = plainToClass(TestClass, {
numberProperty: undefined,
});
expect(transformedClass.numberProperty).toBe(undefined);
});
it('should cast NaN string to undefined', () => {
const transformedClass = plainToClass(TestClass, {
numberProperty: 'toto',
});
expect(transformedClass.numberProperty).toBe(undefined);
});
it('should cast a negative string to undefined', () => {
const transformedClass = plainToClass(TestClass, {
numberProperty: '-123',
});
expect(transformedClass.numberProperty).toBe(undefined);
});
});

View File

@ -0,0 +1,31 @@
import {
ValidationOptions,
registerDecorator,
ValidationArguments,
} from 'class-validator';
export const AssertOrWarn = (
condition: (object: any, value: any) => boolean,
validationOptions?: ValidationOptions,
) => {
return function (object: any, propertyName: string) {
registerDecorator({
name: 'AssertOrWarn',
target: object.constructor,
propertyName: propertyName,
options: {
...validationOptions,
groups: ['warning'],
},
constraints: [condition],
validator: {
validate(value: any, args: ValidationArguments) {
return condition(args.object, value);
},
defaultMessage(args: ValidationArguments) {
return `'${args.property}' failed the warning validation.`;
},
},
});
};
};

View File

@ -0,0 +1,18 @@
import { Transform } from 'class-transformer';
export const CastToBoolean = () =>
Transform(({ value }: { value: string }) => toBoolean(value));
const toBoolean = (value: any) => {
if (typeof value === 'boolean') {
return value;
}
if (['true', 'on', 'yes', '1'].includes(value.toLowerCase())) {
return true;
}
if (['false', 'off', 'no', '0'].includes(value.toLowerCase())) {
return false;
}
return undefined;
};

View File

@ -0,0 +1,19 @@
import { Transform } from 'class-transformer';
export const CastToLogLevelArray = () =>
Transform(({ value }: { value: string }) => toLogLevelArray(value));
const toLogLevelArray = (value: any) => {
if (typeof value === 'string') {
const rawLogLevels = value.split(',').map((level) => level.trim());
const isInvalid = rawLogLevels.some(
(level) => !['log', 'error', 'warn', 'debug', 'verbose'].includes(level),
);
if (!isInvalid) {
return rawLogLevels;
}
}
return undefined;
};

View File

@ -0,0 +1,21 @@
import { Transform } from 'class-transformer';
import { MeterDriver } from 'src/engine/core-modules/metrics/types/meter-driver.type';
export const CastToMeterDriverArray = () =>
Transform(({ value }: { value: string }) => toMeterDriverArray(value));
const toMeterDriverArray = (value: string | undefined) => {
if (typeof value === 'string') {
const rawMeterDrivers = value.split(',').map((driver) => driver.trim());
const isInvalid = rawMeterDrivers.some(
(driver) => !Object.values(MeterDriver).includes(driver as MeterDriver),
);
if (!isInvalid) {
return rawMeterDrivers;
}
}
return undefined;
};

View File

@ -0,0 +1,15 @@
import { Transform } from 'class-transformer';
export const CastToPositiveNumber = () =>
Transform(({ value }: { value: string }) => toNumber(value));
const toNumber = (value: any) => {
if (typeof value === 'number') {
return value >= 0 ? value : undefined;
}
if (typeof value === 'string') {
return isNaN(+value) ? undefined : toNumber(+value);
}
return undefined;
};

View File

@ -0,0 +1,12 @@
import { Transform } from 'class-transformer';
export const CastToStringArray = () =>
Transform(({ value }: { value: string }) => toStringArray(value));
const toStringArray = (value: any) => {
if (typeof value === 'string') {
return value.split(',').map((item) => item.trim());
}
return undefined;
};

View File

@ -0,0 +1,49 @@
import { registerDecorator, ValidationOptions } from 'class-validator';
import { ConfigVariablesGroup } from 'src/engine/core-modules/twenty-config/enums/config-variables-group.enum';
import { TypedReflect } from 'src/utils/typed-reflect';
export interface ConfigVariablesMetadataOptions {
group: ConfigVariablesGroup;
description: string;
isSensitive?: boolean;
}
export type ConfigVariablesMetadataMap = {
[key: string]: ConfigVariablesMetadataOptions;
};
export function ConfigVariablesMetadata(
options: ConfigVariablesMetadataOptions,
validationOptions?: ValidationOptions,
): PropertyDecorator {
return (target: object, propertyKey: string | symbol) => {
const existingMetadata: ConfigVariablesMetadataMap =
TypedReflect.getMetadata('config-variables', target.constructor) ?? {};
TypedReflect.defineMetadata(
'config-variables',
{
...existingMetadata,
[propertyKey.toString()]: options,
},
target.constructor,
);
registerDecorator({
name: propertyKey.toString(),
target: target.constructor,
propertyName: propertyKey.toString(),
options: validationOptions,
constraints: [options],
validator: {
validate() {
return true;
},
defaultMessage() {
return `${propertyKey.toString()} has invalid metadata`;
},
},
});
};
}

View File

@ -0,0 +1,27 @@
import {
registerDecorator,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
@ValidatorConstraint({ async: true })
export class IsAWSRegionConstraint implements ValidatorConstraintInterface {
validate(region: string) {
const regex = /^[a-z]{2}-[a-z]+-\d{1}$/;
return regex.test(region); // Returns true if region matches regex
}
}
export const IsAWSRegion =
(validationOptions?: ValidationOptions) =>
(object: object, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [],
validator: IsAWSRegionConstraint,
});
};

View File

@ -0,0 +1,28 @@
import {
registerDecorator,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
@ValidatorConstraint({ async: true })
export class IsDurationConstraint implements ValidatorConstraintInterface {
validate(duration: string) {
const regex =
/^-?[0-9]+(.[0-9]+)?(m(illiseconds?)?|s(econds?)?|h((ou)?rs?)?|d(ays?)?|w(eeks?)?|M(onths?)?|y(ears?)?)?$/;
return regex.test(duration); // Returns true if duration matches regex
}
}
export const IsDuration =
(validationOptions?: ValidationOptions) =>
(object: object, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [],
validator: IsDurationConstraint,
});
};

View File

@ -0,0 +1,7 @@
import { ValidateIf, ValidationOptions, isDefined } from 'class-validator';
export function IsOptionalOrEmptyString(validationOptions?: ValidationOptions) {
return ValidateIf((_obj, value) => {
return isDefined(value) && value !== '';
}, validationOptions);
}

View File

@ -0,0 +1,32 @@
import {
registerDecorator,
ValidationArguments,
ValidationOptions,
} from 'class-validator';
export const IsStrictlyLowerThan = (
property: string,
validationOptions?: ValidationOptions,
) => {
return (object: object, propertyName: string) => {
registerDecorator({
name: 'isStrictlyLowerThan',
target: object.constructor,
propertyName: propertyName,
constraints: [property],
options: validationOptions,
validator: {
validate(value: any, args: ValidationArguments) {
const [relatedPropertyName] = args.constraints;
const relatedValue = (args.object as any)[relatedPropertyName];
return (
typeof value === 'number' &&
typeof relatedValue === 'number' &&
value < relatedValue
);
},
},
});
};
};

View File

@ -0,0 +1,33 @@
import {
registerDecorator,
ValidationArguments,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
import semver from 'semver';
@ValidatorConstraint({ async: false })
export class IsTwentySemVerValidator implements ValidatorConstraintInterface {
validate(version: string) {
const parsed = semver.parse(version);
return parsed !== null;
}
defaultMessage(args: ValidationArguments) {
return `${args.property} must be a valid semantic version (e.g., 1.0.0)`;
}
}
export const IsTwentySemVer =
(validationOptions?: ValidationOptions) =>
(object: object, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
constraints: [],
validator: IsTwentySemVerValidator,
});
};

View File

@ -0,0 +1,21 @@
export enum ConfigVariablesGroup {
ServerConfig = 'server-config',
RateLimiting = 'rate-limiting',
StorageConfig = 'storage-config',
GoogleAuth = 'google-auth',
MicrosoftAuth = 'microsoft-auth',
EmailSettings = 'email-settings',
Logging = 'logging',
Metering = 'metering',
ExceptionHandler = 'exception-handler',
Other = 'other',
BillingConfig = 'billing-config',
CaptchaConfig = 'captcha-config',
CloudflareConfig = 'cloudflare-config',
LLM = 'llm',
ServerlessConfig = 'serverless-config',
SSL = 'ssl',
SupportChatConfig = 'support-chat-config',
AnalyticsConfig = 'analytics-config',
TokensDuration = 'tokens-duration',
}

View File

@ -0,0 +1,4 @@
export enum ConfigVariablesMaskingStrategies {
LAST_N_CHARS = 'LAST_N_CHARS',
HIDE_PASSWORD = 'HIDE_PASSWORD',
}

View File

@ -0,0 +1,82 @@
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
describe('TwentyConfigService', () => {
let service: TwentyConfigService;
let configService: ConfigService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
TwentyConfigService,
{
provide: ConfigService,
useValue: {
get: jest.fn(),
},
},
],
}).compile();
service = module.get<TwentyConfigService>(TwentyConfigService);
configService = module.get<ConfigService>(ConfigService);
Reflect.defineMetadata('config-variables', {}, ConfigVariables);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('getAll()', () => {
it('should return empty object when no config variables are defined', () => {
const result = service.getAll();
expect(result).toEqual({});
});
it('should return config variables with their metadata', () => {
const mockMetadata = {
TEST_VAR: {
title: 'Test Var',
description: 'Test Description',
},
};
Reflect.defineMetadata('config-variables', mockMetadata, ConfigVariables);
jest.spyOn(configService, 'get').mockReturnValue('test-value');
const result = service.getAll();
expect(result).toEqual({
TEST_VAR: {
value: 'test-value',
metadata: mockMetadata.TEST_VAR,
},
});
});
it('should mask sensitive data according to masking config', () => {
const mockMetadata = {
APP_SECRET: {
title: 'App Secret',
description: 'Application secret key',
sensitive: true,
},
};
Reflect.defineMetadata('config-variables', mockMetadata, ConfigVariables);
jest.spyOn(configService, 'get').mockReturnValue('super-secret-value');
const result = service.getAll();
expect(result.APP_SECRET.value).not.toBe('super-secret-value');
expect(result.APP_SECRET.value).toMatch(/^\*+[a-zA-Z0-9]{5}$/);
});
});
});

View File

@ -0,0 +1 @@
export type AwsRegion = `${string}-${string}-${number}`;

View File

@ -0,0 +1,5 @@
export enum NodeEnvironment {
test = 'test',
development = 'development',
production = 'production',
}

View File

@ -0,0 +1,4 @@
export enum SupportDriver {
None = 'none',
Front = 'front',
}

View File

@ -0,0 +1,8 @@
import { ConfigurableModuleBuilder } from '@nestjs/common';
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
new ConfigurableModuleBuilder({
moduleName: 'TwentyConfig',
})
.setClassMethodName('forRoot')
.build();

View File

@ -0,0 +1,21 @@
import { Global, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { validate } from 'src/engine/core-modules/twenty-config/config-variables';
import { ConfigurableModuleClass } from 'src/engine/core-modules/twenty-config/twenty-config.module-definition';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Global()
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
expandVariables: true,
validate,
envFilePath: process.env.NODE_ENV === 'test' ? '.env.test' : '.env',
}),
],
providers: [TwentyConfigService],
exports: [TwentyConfigService],
})
export class TwentyConfigModule extends ConfigurableModuleClass {}

View File

@ -0,0 +1,73 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ConfigVariables } from 'src/engine/core-modules/twenty-config/config-variables';
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 { ConfigVariablesMaskingStrategies } from 'src/engine/core-modules/twenty-config/enums/config-variables-masking-strategies.enum';
import { configVariableMaskSensitiveData } from 'src/engine/core-modules/twenty-config/utils/config-variable-mask-sensitive-data.util';
import { TypedReflect } from 'src/utils/typed-reflect';
@Injectable()
export class TwentyConfigService {
constructor(private readonly configService: ConfigService) {}
get<T extends keyof ConfigVariables>(key: T): ConfigVariables[T] {
return this.configService.get<ConfigVariables[T]>(
key,
new ConfigVariables()[key],
);
}
getAll(): Record<
string,
{
value: ConfigVariables[keyof ConfigVariables];
metadata: ConfigVariablesMetadataOptions;
}
> {
const result: Record<
string,
{
value: ConfigVariables[keyof ConfigVariables];
metadata: ConfigVariablesMetadataOptions;
}
> = {};
const configVars = new ConfigVariables();
const metadata =
TypedReflect.getMetadata('config-variables', ConfigVariables) ?? {};
Object.entries(metadata).forEach(([key, envMetadata]) => {
let value =
this.configService.get(key) ??
configVars[key as keyof ConfigVariables] ??
'';
if (typeof value === 'string' && 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 },
);
}
result[key] = {
value,
metadata: envMetadata,
};
});
return result;
}
}

View File

@ -0,0 +1,105 @@
import { ConfigVariablesMaskingStrategies } from 'src/engine/core-modules/twenty-config/enums/config-variables-masking-strategies.enum';
import { configVariableMaskSensitiveData } from 'src/engine/core-modules/twenty-config/utils/config-variable-mask-sensitive-data.util';
describe('configVariableMaskSensitiveData', () => {
describe('LAST_N_CHARS strategy', () => {
it('should mask all but the last 5 characters by default', () => {
const result = configVariableMaskSensitiveData(
'mysecretvalue123',
ConfigVariablesMaskingStrategies.LAST_N_CHARS,
);
expect(result).toBe('********ue123');
});
it('should mask all but the specified number of characters', () => {
const result = configVariableMaskSensitiveData(
'mysecretvalue123',
ConfigVariablesMaskingStrategies.LAST_N_CHARS,
{ chars: 3 },
);
expect(result).toBe('********123');
});
it('should return all asterisks if value is shorter than mask length', () => {
const result = configVariableMaskSensitiveData(
'123',
ConfigVariablesMaskingStrategies.LAST_N_CHARS,
{ chars: 5 },
);
expect(result).toBe('********');
});
it('should handle empty string', () => {
const result = configVariableMaskSensitiveData(
'',
ConfigVariablesMaskingStrategies.LAST_N_CHARS,
);
expect(result).toBe('');
});
});
describe('HIDE_PASSWORD strategy', () => {
it('should mask password in URL', () => {
const result = configVariableMaskSensitiveData(
'postgresql://user:password123@localhost:5432/db',
ConfigVariablesMaskingStrategies.HIDE_PASSWORD,
);
expect(result).toBe('postgresql://********:********@localhost:5432/db');
});
it('should handle URL without password', () => {
const result = configVariableMaskSensitiveData(
'postgresql://localhost:5432/db',
ConfigVariablesMaskingStrategies.HIDE_PASSWORD,
);
expect(result).toBe('postgresql://localhost:5432/db');
});
it('should throw error for invalid URLs', () => {
expect(() =>
configVariableMaskSensitiveData(
'not-a-url',
ConfigVariablesMaskingStrategies.HIDE_PASSWORD,
{ variableName: 'TEST_URL' },
),
).toThrow(
'Invalid URL format for TEST_URL in HIDE_PASSWORD masking strategy',
);
});
});
describe('edge cases', () => {
it('should handle null value', () => {
const result = configVariableMaskSensitiveData(
null as any,
ConfigVariablesMaskingStrategies.LAST_N_CHARS,
);
expect(result).toBeNull();
});
it('should handle undefined value', () => {
const result = configVariableMaskSensitiveData(
undefined as any,
ConfigVariablesMaskingStrategies.LAST_N_CHARS,
);
expect(result).toBeUndefined();
});
it('should handle non-string value', () => {
const result = configVariableMaskSensitiveData(
123 as any,
ConfigVariablesMaskingStrategies.LAST_N_CHARS,
);
expect(result).toBe(123);
});
});
});

View File

@ -0,0 +1,38 @@
import { ConfigVariablesMaskingStrategies } from 'src/engine/core-modules/twenty-config/enums/config-variables-masking-strategies.enum';
export const configVariableMaskSensitiveData = (
value: string,
strategy: ConfigVariablesMaskingStrategies,
options?: { chars?: number; variableName?: string },
): string => {
if (!value || typeof value !== 'string') return value;
switch (strategy) {
case ConfigVariablesMaskingStrategies.LAST_N_CHARS: {
const n = Math.max(1, options?.chars ?? 5);
return value.length > n ? `********${value.slice(-n)}` : '********';
}
case ConfigVariablesMaskingStrategies.HIDE_PASSWORD: {
try {
const url = new URL(value);
if (url.password) {
url.password = '********';
}
if (url.username) {
url.username = '********';
}
return url.toString();
} catch {
throw new Error(
`Invalid URL format for ${options?.variableName || 'config variable'} in HIDE_PASSWORD masking strategy`,
);
}
}
default:
return value;
}
};