rename core-module environment to twenty-config (#11445)
closes https://github.com/twentyhq/core-team-issues/issues/759
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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:
|
||||
'We’re 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 don’t need to change them unless you have a specific use-case.',
|
||||
isHiddenOnLoad: true,
|
||||
},
|
||||
};
|
||||
@ -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;
|
||||
@ -0,0 +1,2 @@
|
||||
export const CONFIG_VARIABLES_METADATA_DECORATOR_KEY =
|
||||
'config-variables-metadata' as const;
|
||||
@ -0,0 +1,2 @@
|
||||
export const CONFIG_VARIABLES_METADATA_DECORATOR_NAMES_KEY =
|
||||
'config-variable-names' as const;
|
||||
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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.`;
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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`;
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
@ -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);
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
@ -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',
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
export enum ConfigVariablesMaskingStrategies {
|
||||
LAST_N_CHARS = 'LAST_N_CHARS',
|
||||
HIDE_PASSWORD = 'HIDE_PASSWORD',
|
||||
}
|
||||
@ -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}$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1 @@
|
||||
export type AwsRegion = `${string}-${string}-${number}`;
|
||||
@ -0,0 +1,5 @@
|
||||
export enum NodeEnvironment {
|
||||
test = 'test',
|
||||
development = 'development',
|
||||
production = 'production',
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
export enum SupportDriver {
|
||||
None = 'none',
|
||||
Front = 'front',
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
import { ConfigurableModuleBuilder } from '@nestjs/common';
|
||||
|
||||
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
|
||||
new ConfigurableModuleBuilder({
|
||||
moduleName: 'TwentyConfig',
|
||||
})
|
||||
.setClassMethodName('forRoot')
|
||||
.build();
|
||||
@ -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 {}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user