diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index cddf23662..594e40568 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -394,6 +394,69 @@ export type EmailPasswordResetLink = { success: Scalars['Boolean']; }; +export type EnvironmentVariable = { + __typename?: 'EnvironmentVariable'; + description: Scalars['String']; + name: Scalars['String']; + sensitive: Scalars['Boolean']; + value: Scalars['String']; +}; + +export enum EnvironmentVariablesGroup { + Analytics = 'Analytics', + Authentication = 'Authentication', + Billing = 'Billing', + Cache = 'Cache', + Database = 'Database', + Email = 'Email', + Frontend = 'Frontend', + LLM = 'LLM', + Logging = 'Logging', + QueueConfig = 'QueueConfig', + Security = 'Security', + ServerConfig = 'ServerConfig', + Serverless = 'Serverless', + Storage = 'Storage', + Support = 'Support', + Workspace = 'Workspace' +} + +export type EnvironmentVariablesGroupData = { + __typename?: 'EnvironmentVariablesGroupData'; + groupName: EnvironmentVariablesGroup; + subgroups: Array; + variables: Array; +}; + +export type EnvironmentVariablesOutput = { + __typename?: 'EnvironmentVariablesOutput'; + groups: Array; +}; + +export enum EnvironmentVariablesSubGroup { + CloudflareConfig = 'CloudflareConfig', + EmailSettings = 'EmailSettings', + FrontSupportConfig = 'FrontSupportConfig', + GoogleAuth = 'GoogleAuth', + LambdaConfig = 'LambdaConfig', + MicrosoftAuth = 'MicrosoftAuth', + PasswordAuth = 'PasswordAuth', + RateLimiting = 'RateLimiting', + S3Config = 'S3Config', + SSL = 'SSL', + SentryConfig = 'SentryConfig', + SmtpConfig = 'SmtpConfig', + StripeConfig = 'StripeConfig', + TinybirdConfig = 'TinybirdConfig', + Tokens = 'Tokens' +} + +export type EnvironmentVariablesSubgroupData = { + __typename?: 'EnvironmentVariablesSubgroupData'; + subgroupName: EnvironmentVariablesSubGroup; + variables: Array; +}; + export type ExecuteServerlessFunctionInput = { /** Id of the serverless function to execute */ id: Scalars['UUID']; @@ -1177,6 +1240,7 @@ export type Query = { findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array; getAvailablePackages: Scalars['JSON']; + getEnvironmentVariablesGrouped: EnvironmentVariablesOutput; getHostnameDetails?: Maybe; getPostgresCredentials?: Maybe; getProductPrices: BillingProductPricesOutput; @@ -2151,6 +2215,11 @@ export type UserLookupAdminPanelMutationVariables = Exact<{ export type UserLookupAdminPanelMutation = { __typename?: 'Mutation', userLookupAdminPanel: { __typename?: 'UserLookup', user: { __typename?: 'UserInfo', id: string, email: string, firstName?: string | null, lastName?: string | null }, workspaces: Array<{ __typename?: 'WorkspaceInfo', id: string, name: string, logo?: string | null, totalUsers: number, allowImpersonation: boolean, users: Array<{ __typename?: 'UserInfo', id: string, email: string, firstName?: string | null, lastName?: string | null }>, featureFlags: Array<{ __typename?: 'FeatureFlag', key: FeatureFlagKey, value: boolean }> }> } }; +export type GetEnvironmentVariablesGroupedQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GetEnvironmentVariablesGroupedQuery = { __typename?: 'Query', getEnvironmentVariablesGrouped: { __typename?: 'EnvironmentVariablesOutput', groups: Array<{ __typename?: 'EnvironmentVariablesGroupData', groupName: EnvironmentVariablesGroup, variables: Array<{ __typename?: 'EnvironmentVariable', name: string, description: string, value: string, sensitive: boolean }>, subgroups: Array<{ __typename?: 'EnvironmentVariablesSubgroupData', subgroupName: EnvironmentVariablesSubGroup, variables: Array<{ __typename?: 'EnvironmentVariable', name: string, description: string, value: string, sensitive: boolean }> }> }> } }; + export type UpdateLabPublicFeatureFlagMutationVariables = Exact<{ input: UpdateLabPublicFeatureFlagInput; }>; @@ -3762,6 +3831,57 @@ export function useUserLookupAdminPanelMutation(baseOptions?: Apollo.MutationHoo export type UserLookupAdminPanelMutationHookResult = ReturnType; export type UserLookupAdminPanelMutationResult = Apollo.MutationResult; export type UserLookupAdminPanelMutationOptions = Apollo.BaseMutationOptions; +export const GetEnvironmentVariablesGroupedDocument = gql` + query GetEnvironmentVariablesGrouped { + getEnvironmentVariablesGrouped { + groups { + groupName + variables { + name + description + value + sensitive + } + subgroups { + subgroupName + variables { + name + description + value + sensitive + } + } + } + } +} + `; + +/** + * __useGetEnvironmentVariablesGroupedQuery__ + * + * To run a query within a React component, call `useGetEnvironmentVariablesGroupedQuery` and pass it any options that fit your needs. + * When your component renders, `useGetEnvironmentVariablesGroupedQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetEnvironmentVariablesGroupedQuery({ + * variables: { + * }, + * }); + */ +export function useGetEnvironmentVariablesGroupedQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetEnvironmentVariablesGroupedDocument, options); + } +export function useGetEnvironmentVariablesGroupedLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetEnvironmentVariablesGroupedDocument, options); + } +export type GetEnvironmentVariablesGroupedQueryHookResult = ReturnType; +export type GetEnvironmentVariablesGroupedLazyQueryHookResult = ReturnType; +export type GetEnvironmentVariablesGroupedQueryResult = Apollo.QueryResult; export const UpdateLabPublicFeatureFlagDocument = gql` mutation UpdateLabPublicFeatureFlag($input: UpdateLabPublicFeatureFlagInput!) { updateLabPublicFeatureFlag(input: $input) diff --git a/packages/twenty-front/src/modules/settings/admin-panel/graphql/queries/getEnvironmentVariablesGrouped.ts b/packages/twenty-front/src/modules/settings/admin-panel/graphql/queries/getEnvironmentVariablesGrouped.ts new file mode 100644 index 000000000..9de1062c0 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/admin-panel/graphql/queries/getEnvironmentVariablesGrouped.ts @@ -0,0 +1,26 @@ +import { gql } from '@apollo/client'; + +export const GET_ENVIRONMENT_VARIABLES_GROUPED = gql` + query GetEnvironmentVariablesGrouped { + getEnvironmentVariablesGrouped { + groups { + groupName + variables { + name + description + value + sensitive + } + subgroups { + subgroupName + variables { + name + description + value + sensitive + } + } + } + } + } +`; diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.spec.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/__tests__/admin-panel.spec.ts similarity index 59% rename from packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.spec.ts rename to packages/twenty-server/src/engine/core-modules/admin-panel/__tests__/admin-panel.spec.ts index 895934c33..d32307ed8 100644 --- a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/__tests__/admin-panel.spec.ts @@ -7,6 +7,8 @@ import { AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; +import { EnvironmentVariablesGroup } from 'src/engine/core-modules/environment/enums/environment-variables-group.enum'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.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 { User } from 'src/engine/core-modules/user/user.entity'; @@ -17,6 +19,7 @@ const WorkspaceFindOneMock = jest.fn(); const FeatureFlagUpdateMock = jest.fn(); const FeatureFlagSaveMock = jest.fn(); const LoginTokenServiceGenerateLoginTokenMock = jest.fn(); +const EnvironmentServiceGetAllMock = jest.fn(); jest.mock( 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum', @@ -29,6 +32,13 @@ jest.mock( }, ); +jest.mock( + 'src/engine/core-modules/environment/constants/environment-variables-hidden-groups', + () => ({ + ENVIRONMENT_VARIABLES_HIDDEN_GROUPS: new Set(['HIDDEN_GROUP']), + }), +); + describe('AdminPanelService', () => { let service: AdminPanelService; @@ -61,6 +71,12 @@ describe('AdminPanelService', () => { generateLoginToken: LoginTokenServiceGenerateLoginTokenMock, }, }, + { + provide: EnvironmentService, + useValue: { + getAll: EnvironmentServiceGetAllMock, + }, + }, ], }).compile(); @@ -214,4 +230,144 @@ describe('AdminPanelService', () => { expect(UserFindOneMock).toHaveBeenCalled(); }); + + describe('getEnvironmentVariablesGrouped', () => { + it('should correctly group environment variables', () => { + EnvironmentServiceGetAllMock.mockReturnValue({ + VAR_1: { + value: 'value1', + metadata: { + group: 'GROUP_1', + description: 'Description 1', + }, + }, + VAR_2: { + value: 'value2', + metadata: { + group: 'GROUP_1', + subGroup: 'SUBGROUP_1', + description: 'Description 2', + sensitive: true, + }, + }, + VAR_3: { + value: 'value3', + metadata: { + group: 'GROUP_2', + description: 'Description 3', + }, + }, + }); + + const result = service.getEnvironmentVariablesGrouped(); + + expect(result).toEqual({ + groups: expect.arrayContaining([ + expect.objectContaining({ + groupName: 'GROUP_1', + variables: [ + { + name: 'VAR_1', + value: 'value1', + description: 'Description 1', + sensitive: false, + }, + ], + subgroups: [ + { + subgroupName: 'SUBGROUP_1', + variables: [ + { + name: 'VAR_2', + value: 'value2', + description: 'Description 2', + sensitive: true, + }, + ], + }, + ], + }), + expect.objectContaining({ + groupName: 'GROUP_2', + variables: [ + { + name: 'VAR_3', + value: 'value3', + description: 'Description 3', + sensitive: false, + }, + ], + subgroups: [], + }), + ]), + }); + }); + + it('should sort groups by position and variables alphabetically', () => { + EnvironmentServiceGetAllMock.mockReturnValue({ + Z_VAR: { + value: 'valueZ', + metadata: { + group: 'GROUP_1', + description: 'Description Z', + }, + }, + A_VAR: { + value: 'valueA', + metadata: { + group: 'GROUP_1', + description: 'Description A', + }, + }, + }); + + const result = service.getEnvironmentVariablesGrouped(); + + const group = result.groups.find( + (g) => g.groupName === ('GROUP_1' as EnvironmentVariablesGroup), + ); + + expect(group?.variables[0].name).toBe('A_VAR'); + expect(group?.variables[1].name).toBe('Z_VAR'); + }); + + it('should handle empty environment variables', () => { + EnvironmentServiceGetAllMock.mockReturnValue({}); + + const result = service.getEnvironmentVariablesGrouped(); + + expect(result).toEqual({ + groups: [], + }); + }); + + it('should exclude hidden groups from the output', () => { + EnvironmentServiceGetAllMock.mockReturnValue({ + VAR_1: { + value: 'value1', + metadata: { + group: 'HIDDEN_GROUP', + description: 'Description 1', + }, + }, + VAR_2: { + value: 'value2', + metadata: { + group: 'VISIBLE_GROUP', + description: 'Description 2', + }, + }, + }); + + const result = service.getEnvironmentVariablesGrouped(); + + expect(result.groups).toHaveLength(1); + expect(result.groups[0].groupName).toBe('VISIBLE_GROUP'); + expect(result.groups).not.toContainEqual( + expect.objectContaining({ + groupName: 'HIDDEN_GROUP', + }), + ); + }); + }); }); diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts index 0e91c3c57..4a15dbc0f 100644 --- a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.resolver.ts @@ -1,7 +1,8 @@ import { UseFilters, UseGuards } from '@nestjs/common'; -import { Args, Mutation, Resolver } from '@nestjs/graphql'; +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service'; +import { EnvironmentVariablesOutput } from 'src/engine/core-modules/admin-panel/dtos/environment-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'; import { UpdateWorkspaceFeatureFlagInput } from 'src/engine/core-modules/admin-panel/dtos/update-workspace-feature-flag.input'; @@ -46,4 +47,10 @@ export class AdminPanelResolver { return true; } + + @UseGuards(WorkspaceAuthGuard, UserAuthGuard, ImpersonateGuard) + @Query(() => EnvironmentVariablesOutput) + async getEnvironmentVariablesGrouped(): Promise { + return this.adminService.getEnvironmentVariablesGrouped(); + } } diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.service.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.service.ts index e05d8a322..3baadf2bb 100644 --- a/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.service.ts +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/admin-panel.service.ts @@ -3,12 +3,20 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { EnvironmentVariable } from 'src/engine/core-modules/admin-panel/dtos/environment-variable.dto'; +import { EnvironmentVariablesGroupData } from 'src/engine/core-modules/admin-panel/dtos/environment-variables-group.dto'; +import { EnvironmentVariablesOutput } from 'src/engine/core-modules/admin-panel/dtos/environment-variables.output'; import { UserLookup } from 'src/engine/core-modules/admin-panel/dtos/user-lookup.entity'; import { AuthException, AuthExceptionCode, } from 'src/engine/core-modules/auth/auth.exception'; import { LoginTokenService } from 'src/engine/core-modules/auth/token/services/login-token.service'; +import { ENVIRONMENT_VARIABLES_GROUP_POSITION } from 'src/engine/core-modules/environment/constants/environment-variables-group-position'; +import { ENVIRONMENT_VARIABLES_HIDDEN_GROUPS } from 'src/engine/core-modules/environment/constants/environment-variables-hidden-groups'; +import { EnvironmentVariablesGroup } from 'src/engine/core-modules/environment/enums/environment-variables-group.enum'; +import { EnvironmentVariablesSubGroup } from 'src/engine/core-modules/environment/enums/environment-variables-sub-group.enum'; +import { EnvironmentService } from 'src/engine/core-modules/environment/environment.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 { @@ -25,6 +33,7 @@ import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace. export class AdminPanelService { constructor( private readonly loginTokenService: LoginTokenService, + private readonly environmentService: EnvironmentService, @InjectRepository(User, 'core') private readonly userRepository: Repository, @InjectRepository(Workspace, 'core') @@ -157,4 +166,74 @@ export class AdminPanelService { }); } } + + getEnvironmentVariablesGrouped(): EnvironmentVariablesOutput { + const rawEnvVars = this.environmentService.getAll(); + const groupedData = new Map< + EnvironmentVariablesGroup, + { + variables: EnvironmentVariable[]; + subgroups: Map; + } + >(); + + for (const [varName, { value, metadata }] of Object.entries(rawEnvVars)) { + const { group, subGroup, description } = metadata; + + if (ENVIRONMENT_VARIABLES_HIDDEN_GROUPS.has(group)) { + continue; + } + + const envVar: EnvironmentVariable = { + name: varName, + description, + value: String(value), + sensitive: metadata.sensitive ?? false, + }; + + let currentGroup = groupedData.get(group); + + if (!currentGroup) { + currentGroup = { + variables: [], + subgroups: new Map(), + }; + groupedData.set(group, currentGroup); + } + + if (subGroup) { + let subgroupVars = currentGroup.subgroups.get(subGroup); + + if (!subgroupVars) { + subgroupVars = []; + currentGroup.subgroups.set(subGroup, subgroupVars); + } + subgroupVars.push(envVar); + } else { + currentGroup.variables.push(envVar); + } + } + + const groups: EnvironmentVariablesGroupData[] = Array.from( + groupedData.entries(), + ) + .sort((a, b) => { + const positionA = ENVIRONMENT_VARIABLES_GROUP_POSITION[a[0]]; + const positionB = ENVIRONMENT_VARIABLES_GROUP_POSITION[b[0]]; + + return positionA - positionB; + }) + .map(([groupName, data]) => ({ + groupName, + variables: data.variables.sort((a, b) => a.name.localeCompare(b.name)), + subgroups: Array.from(data.subgroups.entries()) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([subgroupName, variables]) => ({ + subgroupName, + variables, + })), + })); + + return { groups }; + } } diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/environment-variable.dto.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/environment-variable.dto.ts new file mode 100644 index 000000000..fa1a7c1e0 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/environment-variable.dto.ts @@ -0,0 +1,16 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class EnvironmentVariable { + @Field() + name: string; + + @Field() + description: string; + + @Field() + value: string; + + @Field() + sensitive: boolean; +} diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/environment-variables-group.dto.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/environment-variables-group.dto.ts new file mode 100644 index 000000000..e1945cb16 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/environment-variables-group.dto.ts @@ -0,0 +1,22 @@ +import { Field, ObjectType, registerEnumType } from '@nestjs/graphql'; + +import { EnvironmentVariablesGroup } from 'src/engine/core-modules/environment/enums/environment-variables-group.enum'; + +import { EnvironmentVariable } from './environment-variable.dto'; +import { EnvironmentVariablesSubgroupData } from './environment-variables-subgroup.dto'; + +registerEnumType(EnvironmentVariablesGroup, { + name: 'EnvironmentVariablesGroup', +}); + +@ObjectType() +export class EnvironmentVariablesGroupData { + @Field(() => [EnvironmentVariable]) + variables: EnvironmentVariable[]; + + @Field(() => [EnvironmentVariablesSubgroupData]) + subgroups: EnvironmentVariablesSubgroupData[]; + + @Field(() => EnvironmentVariablesGroup) + groupName: EnvironmentVariablesGroup; +} diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/environment-variables-subgroup.dto.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/environment-variables-subgroup.dto.ts new file mode 100644 index 000000000..10f579a71 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/environment-variables-subgroup.dto.ts @@ -0,0 +1,18 @@ +import { Field, ObjectType, registerEnumType } from '@nestjs/graphql'; + +import { EnvironmentVariablesSubGroup } from 'src/engine/core-modules/environment/enums/environment-variables-sub-group.enum'; + +import { EnvironmentVariable } from './environment-variable.dto'; + +registerEnumType(EnvironmentVariablesSubGroup, { + name: 'EnvironmentVariablesSubGroup', +}); + +@ObjectType() +export class EnvironmentVariablesSubgroupData { + @Field(() => [EnvironmentVariable]) + variables: EnvironmentVariable[]; + + @Field(() => EnvironmentVariablesSubGroup) + subgroupName: EnvironmentVariablesSubGroup; +} diff --git a/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/environment-variables.output.ts b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/environment-variables.output.ts new file mode 100644 index 000000000..02e98d388 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/admin-panel/dtos/environment-variables.output.ts @@ -0,0 +1,9 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import { EnvironmentVariablesGroupData } from './environment-variables-group.dto'; + +@ObjectType() +export class EnvironmentVariablesOutput { + @Field(() => [EnvironmentVariablesGroupData]) + groups: EnvironmentVariablesGroupData[]; +} diff --git a/packages/twenty-server/src/engine/core-modules/environment/constants/__tests__/environment-variables-group-position.spec.ts b/packages/twenty-server/src/engine/core-modules/environment/constants/__tests__/environment-variables-group-position.spec.ts new file mode 100644 index 000000000..5ee2e95bc --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/environment/constants/__tests__/environment-variables-group-position.spec.ts @@ -0,0 +1,26 @@ +import { ENVIRONMENT_VARIABLES_GROUP_POSITION } from 'src/engine/core-modules/environment/constants/environment-variables-group-position'; +import { EnvironmentVariablesGroup } from 'src/engine/core-modules/environment/enums/environment-variables-group.enum'; + +describe('ENVIRONMENT_VARIABLES_GROUP_POSITION', () => { + it('should include all EnvironmentVariablesGroup enum values', () => { + const enumValues = Object.values(EnvironmentVariablesGroup); + const positionKeys = Object.keys(ENVIRONMENT_VARIABLES_GROUP_POSITION); + + enumValues.forEach((enumValue) => { + expect(positionKeys).toContain(enumValue); + }); + + positionKeys.forEach((key) => { + expect(enumValues).toContain(key); + }); + + expect(enumValues.length).toBe(positionKeys.length); + }); + + it('should have unique position values', () => { + const positions = Object.values(ENVIRONMENT_VARIABLES_GROUP_POSITION); + const uniquePositions = new Set(positions); + + expect(positions.length).toBe(uniquePositions.size); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/environment/constants/environment-variables-group-position.ts b/packages/twenty-server/src/engine/core-modules/environment/constants/environment-variables-group-position.ts new file mode 100644 index 000000000..835869236 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/environment/constants/environment-variables-group-position.ts @@ -0,0 +1,23 @@ +import { EnvironmentVariablesGroup } from 'src/engine/core-modules/environment/enums/environment-variables-group.enum'; + +export const ENVIRONMENT_VARIABLES_GROUP_POSITION: Record< + EnvironmentVariablesGroup, + number +> = { + [EnvironmentVariablesGroup.ServerConfig]: 100, + [EnvironmentVariablesGroup.Database]: 200, + [EnvironmentVariablesGroup.Security]: 300, + [EnvironmentVariablesGroup.Authentication]: 400, + [EnvironmentVariablesGroup.Cache]: 500, + [EnvironmentVariablesGroup.QueueConfig]: 600, + [EnvironmentVariablesGroup.Storage]: 700, + [EnvironmentVariablesGroup.Email]: 800, + [EnvironmentVariablesGroup.Frontend]: 900, + [EnvironmentVariablesGroup.Workspace]: 1000, + [EnvironmentVariablesGroup.Analytics]: 1100, + [EnvironmentVariablesGroup.Logging]: 1200, + [EnvironmentVariablesGroup.Billing]: 1300, + [EnvironmentVariablesGroup.Support]: 1400, + [EnvironmentVariablesGroup.LLM]: 1500, + [EnvironmentVariablesGroup.Serverless]: 1600, +}; diff --git a/packages/twenty-server/src/engine/core-modules/environment/constants/environment-variables-hidden-groups.ts b/packages/twenty-server/src/engine/core-modules/environment/constants/environment-variables-hidden-groups.ts new file mode 100644 index 000000000..820b5d9c7 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/environment/constants/environment-variables-hidden-groups.ts @@ -0,0 +1,4 @@ +import { EnvironmentVariablesGroup } from 'src/engine/core-modules/environment/enums/environment-variables-group.enum'; + +export const ENVIRONMENT_VARIABLES_HIDDEN_GROUPS: Set = + new Set([EnvironmentVariablesGroup.LLM]); diff --git a/packages/twenty-server/src/engine/core-modules/environment/constants/environment-variables-masking-config.ts b/packages/twenty-server/src/engine/core-modules/environment/constants/environment-variables-masking-config.ts new file mode 100644 index 000000000..4b0d9a848 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/environment/constants/environment-variables-masking-config.ts @@ -0,0 +1,29 @@ +import { EnvironmentVariablesMaskingStrategies } from 'src/engine/core-modules/environment/enums/environment-variables-masking-strategies.enum'; + +type LastNCharsConfig = { + strategy: EnvironmentVariablesMaskingStrategies.LAST_N_CHARS; + chars: number; +}; + +type HidePasswordConfig = { + strategy: EnvironmentVariablesMaskingStrategies.HIDE_PASSWORD; +}; + +type MaskingConfigType = { + APP_SECRET: LastNCharsConfig; + PG_DATABASE_URL: HidePasswordConfig; + REDIS_URL: HidePasswordConfig; +}; + +export const ENVIRONMENT_VARIABLES_MASKING_CONFIG: MaskingConfigType = { + APP_SECRET: { + strategy: EnvironmentVariablesMaskingStrategies.LAST_N_CHARS, + chars: 5, + }, + PG_DATABASE_URL: { + strategy: EnvironmentVariablesMaskingStrategies.HIDE_PASSWORD, + }, + REDIS_URL: { + strategy: EnvironmentVariablesMaskingStrategies.HIDE_PASSWORD, + }, +} as const; diff --git a/packages/twenty-server/src/engine/core-modules/environment/constants/environment-variables-metadata-decorator-key.ts b/packages/twenty-server/src/engine/core-modules/environment/constants/environment-variables-metadata-decorator-key.ts new file mode 100644 index 000000000..a1249e471 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/environment/constants/environment-variables-metadata-decorator-key.ts @@ -0,0 +1,2 @@ +export const ENVIRONMENT_VARIABLES_METADATA_DECORATOR_KEY = + 'environment-variables-metadata' as const; diff --git a/packages/twenty-server/src/engine/core-modules/environment/constants/environment-variables-metadata-decorator-names-key.ts b/packages/twenty-server/src/engine/core-modules/environment/constants/environment-variables-metadata-decorator-names-key.ts new file mode 100644 index 000000000..aa1c0c378 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/environment/constants/environment-variables-metadata-decorator-names-key.ts @@ -0,0 +1,2 @@ +export const ENVIRONMENT_VARIABLES_METADATA_DECORATOR_NAMES_KEY = + 'environment-variable-names' as const; diff --git a/packages/twenty-server/src/engine/core-modules/environment/decorators/environment-variables-metadata.decorator.ts b/packages/twenty-server/src/engine/core-modules/environment/decorators/environment-variables-metadata.decorator.ts new file mode 100644 index 000000000..87a283647 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/environment/decorators/environment-variables-metadata.decorator.ts @@ -0,0 +1,52 @@ +import { registerDecorator, ValidationOptions } from 'class-validator'; + +import { EnvironmentVariablesGroup } from 'src/engine/core-modules/environment/enums/environment-variables-group.enum'; +import { EnvironmentVariablesSubGroup } from 'src/engine/core-modules/environment/enums/environment-variables-sub-group.enum'; +import { TypedReflect } from 'src/utils/typed-reflect'; + +export interface EnvironmentVariablesMetadataOptions { + group: EnvironmentVariablesGroup; + subGroup?: EnvironmentVariablesSubGroup; + description: string; + sensitive?: boolean; +} + +export type EnvironmentVariablesMetadataMap = { + [key: string]: EnvironmentVariablesMetadataOptions; +}; + +export function EnvironmentVariablesMetadata( + options: EnvironmentVariablesMetadataOptions, + validationOptions?: ValidationOptions, +): PropertyDecorator { + return (target: object, propertyKey: string | symbol) => { + const existingMetadata: EnvironmentVariablesMetadataMap = + TypedReflect.getMetadata('environment-variables', target.constructor) ?? + {}; + + TypedReflect.defineMetadata( + 'environment-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`; + }, + }, + }); + }; +} diff --git a/packages/twenty-server/src/engine/core-modules/environment/enums/environment-variables-group.enum.ts b/packages/twenty-server/src/engine/core-modules/environment/enums/environment-variables-group.enum.ts new file mode 100644 index 000000000..d03194a52 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/environment/enums/environment-variables-group.enum.ts @@ -0,0 +1,18 @@ +export enum EnvironmentVariablesGroup { + Authentication = 'authentication', + Email = 'email', + Database = 'database', + Storage = 'storage', + ServerConfig = 'server-config', + QueueConfig = 'queue-config', + Logging = 'logging', + Cache = 'cache', + Analytics = 'analytics', + Billing = 'billing', + Frontend = 'frontend', + Security = 'security', + Serverless = 'serverless', + Support = 'support', + LLM = 'llm', + Workspace = 'workspace', +} diff --git a/packages/twenty-server/src/engine/core-modules/environment/enums/environment-variables-masking-strategies.enum.ts b/packages/twenty-server/src/engine/core-modules/environment/enums/environment-variables-masking-strategies.enum.ts new file mode 100644 index 000000000..cbbeb6cca --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/environment/enums/environment-variables-masking-strategies.enum.ts @@ -0,0 +1,4 @@ +export enum EnvironmentVariablesMaskingStrategies { + LAST_N_CHARS = 'LAST_N_CHARS', + HIDE_PASSWORD = 'HIDE_PASSWORD', +} diff --git a/packages/twenty-server/src/engine/core-modules/environment/enums/environment-variables-sub-group.enum.ts b/packages/twenty-server/src/engine/core-modules/environment/enums/environment-variables-sub-group.enum.ts new file mode 100644 index 000000000..538faecc4 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/environment/enums/environment-variables-sub-group.enum.ts @@ -0,0 +1,17 @@ +export enum EnvironmentVariablesSubGroup { + PasswordAuth = 'password-auth', + GoogleAuth = 'google-auth', + MicrosoftAuth = 'microsoft-auth', + SmtpConfig = 'smtp-config', + EmailSettings = 'email-settings', + S3Config = 's3-config', + Tokens = 'tokens', + SSL = 'ssl', + RateLimiting = 'rate-limiting', + LambdaConfig = 'lambda-config', + TinybirdConfig = 'tinybird-config', + StripeConfig = 'stripe-config', + SentryConfig = 'sentry-config', + FrontSupportConfig = 'front-support-config', + CloudflareConfig = 'cloudflare-config', +} diff --git a/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts b/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts index 0123c0735..aa902d8ac 100644 --- a/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/environment/environment-variables.ts @@ -28,9 +28,12 @@ import { CastToBoolean } from 'src/engine/core-modules/environment/decorators/ca import { CastToLogLevelArray } from 'src/engine/core-modules/environment/decorators/cast-to-log-level-array.decorator'; import { CastToPositiveNumber } from 'src/engine/core-modules/environment/decorators/cast-to-positive-number.decorator'; import { CastToStringArray } from 'src/engine/core-modules/environment/decorators/cast-to-string-array.decorator'; +import { EnvironmentVariablesMetadata } from 'src/engine/core-modules/environment/decorators/environment-variables-metadata.decorator'; import { IsAWSRegion } from 'src/engine/core-modules/environment/decorators/is-aws-region.decorator'; import { IsDuration } from 'src/engine/core-modules/environment/decorators/is-duration.decorator'; import { IsStrictlyLowerThan } from 'src/engine/core-modules/environment/decorators/is-strictly-lower-than.decorator'; +import { EnvironmentVariablesGroup } from 'src/engine/core-modules/environment/enums/environment-variables-group.enum'; +import { EnvironmentVariablesSubGroup } from 'src/engine/core-modules/environment/enums/environment-variables-sub-group.enum'; import { ExceptionHandlerDriver } from 'src/engine/core-modules/exception-handler/interfaces'; import { StorageDriverType } from 'src/engine/core-modules/file-storage/interfaces'; import { LoggerDriverType } from 'src/engine/core-modules/logger/interfaces'; @@ -39,89 +42,690 @@ import { ServerlessDriverType } from 'src/engine/core-modules/serverless/serverl import { assert } from 'src/utils/assert'; export class EnvironmentVariables { - // Misc + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Authentication, + description: 'Is password authentication enabled', + }) @CastToBoolean() @IsOptional() @IsBoolean() - DEBUG_MODE = false; + AUTH_PASSWORD_ENABLED = true; - @IsEnum(NodeEnvironment) - @IsString() - NODE_ENV: NodeEnvironment = NodeEnvironment.development; - - @CastToPositiveNumber() + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Authentication, + description: 'Is sign in prefilled enabled', + }) + @CastToBoolean() @IsOptional() - @IsNumber() - @Min(0) - @Max(65535) - DEBUG_PORT = 9000; + @IsBoolean() + @ValidateIf((env) => env.AUTH_PASSWORD_ENABLED) + SIGN_IN_PREFILLED = false; + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Authentication, + description: 'Is email verification required', + }) + @CastToBoolean() + @IsOptional() + @IsBoolean() + IS_EMAIL_VERIFICATION_REQUIRED = false; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Authentication, + description: 'Email verification token expires in', + }) + @IsDuration() + @IsOptional() + EMAIL_VERIFICATION_TOKEN_EXPIRES_IN = '1h'; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Authentication, + description: 'Password reset token expires in', + }) + @IsDuration() + @IsOptional() + PASSWORD_RESET_TOKEN_EXPIRES_IN = '5m'; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Authentication, + subGroup: EnvironmentVariablesSubGroup.GoogleAuth, + description: 'Is Google Calendar provider enabled', + }) + @CastToBoolean() + CALENDAR_PROVIDER_GOOGLE_ENABLED = false; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Authentication, + subGroup: EnvironmentVariablesSubGroup.GoogleAuth, + description: 'Google Auth APIs callback URL', + }) + AUTH_GOOGLE_APIS_CALLBACK_URL: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Authentication, + subGroup: EnvironmentVariablesSubGroup.GoogleAuth, + description: 'Is Google Auth enabled', + }) + @CastToBoolean() + @IsOptional() + @IsBoolean() + AUTH_GOOGLE_ENABLED = false; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Authentication, + subGroup: EnvironmentVariablesSubGroup.GoogleAuth, + sensitive: true, + description: 'Google Auth client ID', + }) + @IsString() + @ValidateIf((env) => env.AUTH_GOOGLE_ENABLED) + AUTH_GOOGLE_CLIENT_ID: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Authentication, + subGroup: EnvironmentVariablesSubGroup.GoogleAuth, + sensitive: true, + description: 'Google Auth client secret', + }) + @IsString() + @ValidateIf((env) => env.AUTH_GOOGLE_ENABLED) + AUTH_GOOGLE_CLIENT_SECRET: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Authentication, + subGroup: EnvironmentVariablesSubGroup.GoogleAuth, + sensitive: true, + description: 'Google Auth callback URL', + }) + @IsUrl({ require_tld: false, require_protocol: true }) + @ValidateIf((env) => env.AUTH_GOOGLE_ENABLED) + AUTH_GOOGLE_CALLBACK_URL: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Authentication, + subGroup: EnvironmentVariablesSubGroup.GoogleAuth, + description: 'Is Gmail messaging provider enabled', + }) + @CastToBoolean() + MESSAGING_PROVIDER_GMAIL_ENABLED = false; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Authentication, + subGroup: EnvironmentVariablesSubGroup.MicrosoftAuth, + description: 'Is Microsoft Auth enabled', + }) + @CastToBoolean() + @IsOptional() + @IsBoolean() + AUTH_MICROSOFT_ENABLED = false; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Authentication, + subGroup: EnvironmentVariablesSubGroup.MicrosoftAuth, + sensitive: true, + description: 'Microsoft Auth client ID', + }) + @IsString() + @ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED) + AUTH_MICROSOFT_CLIENT_ID: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Authentication, + subGroup: EnvironmentVariablesSubGroup.MicrosoftAuth, + sensitive: true, + description: 'Microsoft Auth client secret', + }) + @IsString() + @ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED) + AUTH_MICROSOFT_CLIENT_SECRET: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Authentication, + subGroup: EnvironmentVariablesSubGroup.MicrosoftAuth, + sensitive: true, + description: 'Microsoft Auth callback URL', + }) + @IsUrl({ require_tld: false, require_protocol: true }) + @ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED) + AUTH_MICROSOFT_CALLBACK_URL: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Authentication, + subGroup: EnvironmentVariablesSubGroup.MicrosoftAuth, + sensitive: true, + description: 'Microsoft Auth APIs callback URL', + }) + @IsUrl({ require_tld: false, require_protocol: true }) + @ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED) + AUTH_MICROSOFT_APIS_CALLBACK_URL: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Authentication, + subGroup: EnvironmentVariablesSubGroup.Tokens, + sensitive: true, + description: 'Access token secret', + }) + @IsOptional() + @IsString() + ACCESS_TOKEN_SECRET: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Authentication, + subGroup: EnvironmentVariablesSubGroup.Tokens, + description: 'Access token expires in', + }) + @IsDuration() + @IsOptional() + ACCESS_TOKEN_EXPIRES_IN = '30m'; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Authentication, + subGroup: EnvironmentVariablesSubGroup.Tokens, + description: 'Refresh token expires in', + }) + @IsOptional() + REFRESH_TOKEN_EXPIRES_IN = '60d'; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Authentication, + subGroup: EnvironmentVariablesSubGroup.Tokens, + description: 'Refresh token cool down', + }) + @IsDuration() + @IsOptional() + REFRESH_TOKEN_COOL_DOWN = '1m'; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Authentication, + subGroup: EnvironmentVariablesSubGroup.Tokens, + description: 'Login token expires in', + }) + @IsDuration() + @IsOptional() + LOGIN_TOKEN_EXPIRES_IN = '15m'; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Authentication, + subGroup: EnvironmentVariablesSubGroup.Tokens, + description: 'File token expires in', + }) + @IsDuration() + @IsOptional() + FILE_TOKEN_EXPIRES_IN = '1d'; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Authentication, + subGroup: EnvironmentVariablesSubGroup.Tokens, + description: 'Invitation token expires in', + }) + @IsDuration() + @IsOptional() + INVITATION_TOKEN_EXPIRES_IN = '30d'; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Authentication, + subGroup: EnvironmentVariablesSubGroup.Tokens, + description: 'Short term token expires in', + }) + SHORT_TERM_TOKEN_EXPIRES_IN = '5m'; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Email, + subGroup: EnvironmentVariablesSubGroup.EmailSettings, + description: 'Email from address', + }) + EMAIL_FROM_ADDRESS = 'noreply@yourdomain.com'; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Email, + subGroup: EnvironmentVariablesSubGroup.EmailSettings, + description: 'Email system address', + }) + EMAIL_SYSTEM_ADDRESS = 'system@yourdomain.com'; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Email, + subGroup: EnvironmentVariablesSubGroup.EmailSettings, + description: 'Email from name', + }) + EMAIL_FROM_NAME = 'Felix from Twenty'; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Email, + subGroup: EnvironmentVariablesSubGroup.EmailSettings, + description: 'Email driver', + }) + EMAIL_DRIVER: EmailDriver = EmailDriver.Logger; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Email, + subGroup: EnvironmentVariablesSubGroup.SmtpConfig, + description: 'SMTP host', + }) + EMAIL_SMTP_HOST: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Email, + subGroup: EnvironmentVariablesSubGroup.SmtpConfig, + description: 'SMTP port', + }) + @CastToPositiveNumber() + EMAIL_SMTP_PORT = 587; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Email, + subGroup: EnvironmentVariablesSubGroup.SmtpConfig, + description: 'SMTP user', + }) + EMAIL_SMTP_USER: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Email, + subGroup: EnvironmentVariablesSubGroup.SmtpConfig, + sensitive: true, + description: 'SMTP password', + }) + EMAIL_SMTP_PASSWORD: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Storage, + description: 'Storage type', + }) + @IsEnum(StorageDriverType) + @IsOptional() + STORAGE_TYPE: StorageDriverType = StorageDriverType.Local; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Storage, + description: 'Storage local path', + }) + @IsString() + @ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.Local) + STORAGE_LOCAL_PATH = '.local-storage'; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Storage, + subGroup: EnvironmentVariablesSubGroup.S3Config, + description: 'Storage S3 region', + }) + @ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S3) + @IsAWSRegion() + STORAGE_S3_REGION: AwsRegion; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Storage, + subGroup: EnvironmentVariablesSubGroup.S3Config, + description: 'Storage S3 name', + }) + @ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S3) + @IsString() + STORAGE_S3_NAME: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Storage, + subGroup: EnvironmentVariablesSubGroup.S3Config, + description: 'Storage S3 endpoint', + }) + @ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S3) + @IsString() + @IsOptional() + STORAGE_S3_ENDPOINT: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Storage, + subGroup: EnvironmentVariablesSubGroup.S3Config, + sensitive: true, + description: 'Storage S3 access key ID', + }) + @ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S3) + @IsString() + @IsOptional() + STORAGE_S3_ACCESS_KEY_ID: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Storage, + subGroup: EnvironmentVariablesSubGroup.S3Config, + sensitive: true, + description: 'Storage S3 secret access key', + }) + @ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S3) + @IsString() + @IsOptional() + STORAGE_S3_SECRET_ACCESS_KEY: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Serverless, + description: 'Serverless type', + }) + @IsEnum(ServerlessDriverType) + @IsOptional() + SERVERLESS_TYPE: ServerlessDriverType = ServerlessDriverType.Local; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Serverless, + description: 'Serverless function exec throttle limit', + }) + @CastToPositiveNumber() + SERVERLESS_FUNCTION_EXEC_THROTTLE_LIMIT = 10; + + // milliseconds + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Serverless, + description: 'Serverless function exec throttle TTL', + }) + @CastToPositiveNumber() + SERVERLESS_FUNCTION_EXEC_THROTTLE_TTL = 1000; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Serverless, + subGroup: EnvironmentVariablesSubGroup.LambdaConfig, + description: 'Serverless Lambda region', + }) + @ValidateIf((env) => env.SERVERLESS_TYPE === ServerlessDriverType.Lambda) + @IsAWSRegion() + SERVERLESS_LAMBDA_REGION: AwsRegion; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Serverless, + subGroup: EnvironmentVariablesSubGroup.LambdaConfig, + description: 'Serverless Lambda role', + }) + @ValidateIf((env) => env.SERVERLESS_TYPE === ServerlessDriverType.Lambda) + @IsString() + @IsOptional() + SERVERLESS_LAMBDA_ROLE: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Serverless, + subGroup: EnvironmentVariablesSubGroup.LambdaConfig, + sensitive: true, + description: 'Serverless Lambda access key ID', + }) + @ValidateIf((env) => env.SERVERLESS_TYPE === ServerlessDriverType.Lambda) + @IsString() + @IsOptional() + SERVERLESS_LAMBDA_ACCESS_KEY_ID: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Serverless, + subGroup: EnvironmentVariablesSubGroup.LambdaConfig, + sensitive: true, + description: 'Serverless Lambda secret access key', + }) + @ValidateIf((env) => env.SERVERLESS_TYPE === ServerlessDriverType.Lambda) + @IsString() + @IsOptional() + SERVERLESS_LAMBDA_SECRET_ACCESS_KEY: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Analytics, + description: 'Is analytics enabled', + }) + @CastToBoolean() + @IsOptional() + @IsBoolean() + ANALYTICS_ENABLED = false; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Analytics, + description: 'Is telemetry enabled', + }) + @CastToBoolean() + @IsOptional() + @IsBoolean() + TELEMETRY_ENABLED = true; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Analytics, + subGroup: EnvironmentVariablesSubGroup.TinybirdConfig, + sensitive: true, + description: 'Tinybird ingest token', + }) + @IsString() + @ValidateIf((env) => env.ANALYTICS_ENABLED) + TINYBIRD_INGEST_TOKEN: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Analytics, + subGroup: EnvironmentVariablesSubGroup.TinybirdConfig, + sensitive: true, + description: 'Tinybird workspace UUID', + }) + @IsString() + @ValidateIf((env) => env.ANALYTICS_ENABLED) + TINYBIRD_WORKSPACE_UUID: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Analytics, + subGroup: EnvironmentVariablesSubGroup.TinybirdConfig, + sensitive: true, + description: 'Tinybird generate JWT token', + }) + @IsString() + @ValidateIf((env) => env.ANALYTICS_ENABLED) + TINYBIRD_GENERATE_JWT_TOKEN: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Billing, + description: 'Is billing enabled', + }) @CastToBoolean() @IsOptional() @IsBoolean() IS_BILLING_ENABLED = false; + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Billing, + description: 'Billing plan required link', + }) @IsString() @ValidateIf((env) => env.IS_BILLING_ENABLED === true) BILLING_PLAN_REQUIRED_LINK: string; - @IsString() - @ValidateIf((env) => env.IS_BILLING_ENABLED === true) - BILLING_STRIPE_BASE_PLAN_PRODUCT_ID: string; - + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Billing, + description: 'Billing free trial with credit card duration in days', + }) @IsNumber() @CastToPositiveNumber() @IsOptional() @ValidateIf((env) => env.IS_BILLING_ENABLED === true) BILLING_FREE_TRIAL_WITH_CREDIT_CARD_DURATION_IN_DAYS = 30; + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Billing, + description: 'Billing free trial without credit card duration in days', + }) @IsNumber() @CastToPositiveNumber() @IsOptional() @ValidateIf((env) => env.IS_BILLING_ENABLED === true) BILLING_FREE_TRIAL_WITHOUT_CREDIT_CARD_DURATION_IN_DAYS = 7; + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Billing, + subGroup: EnvironmentVariablesSubGroup.StripeConfig, + sensitive: true, + description: 'Billing Stripe API key', + }) @IsString() @ValidateIf((env) => env.IS_BILLING_ENABLED === true) BILLING_STRIPE_API_KEY: string; + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Billing, + subGroup: EnvironmentVariablesSubGroup.StripeConfig, + sensitive: true, + description: 'Billing Stripe webhook secret', + }) @IsString() @ValidateIf((env) => env.IS_BILLING_ENABLED === true) BILLING_STRIPE_WEBHOOK_SECRET: string; - @CastToBoolean() - @IsOptional() - @IsBoolean() - TELEMETRY_ENABLED = true; - - @CastToBoolean() - @IsOptional() - @IsBoolean() - PERMISSIONS_ENABLED = false; - - @CastToBoolean() - @IsOptional() - @IsBoolean() - ANALYTICS_ENABLED = false; - + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Billing, + sensitive: true, + subGroup: EnvironmentVariablesSubGroup.StripeConfig, + description: 'Billing Stripe base plan product ID', + }) @IsString() - @ValidateIf((env) => env.ANALYTICS_ENABLED) - TINYBIRD_INGEST_TOKEN: string; + @ValidateIf((env) => env.IS_BILLING_ENABLED === true) + BILLING_STRIPE_BASE_PLAN_PRODUCT_ID: string; + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Frontend, + description: 'Frontend domain', + }) @IsString() - @ValidateIf((env) => env.ANALYTICS_ENABLED) - TINYBIRD_WORKSPACE_UUID: string; + @IsOptional() + FRONT_DOMAIN?: string; + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Frontend, + description: 'Frontend default subdomain', + }) @IsString() - @ValidateIf((env) => env.ANALYTICS_ENABLED) - TINYBIRD_GENERATE_JWT_TOKEN: string; + @ValidateIf((env) => env.IS_MULTIWORKSPACE_ENABLED) + DEFAULT_SUBDOMAIN = 'app'; + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Frontend, + description: 'Frontend protocol', + }) + @IsString() + @IsOptional() + FRONT_PROTOCOL?: 'http' | 'https' = 'http'; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Frontend, + description: 'Frontend port', + }) @CastToPositiveNumber() @IsNumber() @IsOptional() - PORT = 3000; + FRONT_PORT?: number; - // Database + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Frontend, + description: 'Chrome extension ID', + }) + @IsString() + @IsOptional() + CHROME_EXTENSION_ID: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Logging, + description: 'Logging driver', + }) + @IsEnum(LoggerDriverType) + @IsOptional() + LOGGER_DRIVER: LoggerDriverType = LoggerDriverType.Console; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Logging, + description: 'Is buffer enabled for logging', + }) + @CastToBoolean() + @IsBoolean() + @IsOptional() + LOGGER_IS_BUFFER_ENABLED = true; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Logging, + description: 'Exception handler driver', + }) + @IsEnum(ExceptionHandlerDriver) + @IsOptional() + EXCEPTION_HANDLER_DRIVER: ExceptionHandlerDriver = + ExceptionHandlerDriver.Console; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Logging, + description: 'Logging levels', + }) + @CastToLogLevelArray() + @IsOptional() + LOG_LEVELS: LogLevel[] = ['log', 'error', 'warn']; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Logging, + subGroup: EnvironmentVariablesSubGroup.SentryConfig, + description: 'Sentry DSN', + }) + @ValidateIf( + (env) => env.EXCEPTION_HANDLER_DRIVER === ExceptionHandlerDriver.Sentry, + ) + @IsString() + SENTRY_DSN: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Logging, + subGroup: EnvironmentVariablesSubGroup.SentryConfig, + description: 'Sentry Front DSN', + }) + @ValidateIf( + (env) => env.EXCEPTION_HANDLER_DRIVER === ExceptionHandlerDriver.Sentry, + ) + @IsString() + SENTRY_FRONT_DSN: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Logging, + subGroup: EnvironmentVariablesSubGroup.SentryConfig, + description: 'Sentry release', + }) + @ValidateIf( + (env) => env.EXCEPTION_HANDLER_DRIVER === ExceptionHandlerDriver.Sentry, + ) + @IsString() + @IsOptional() + SENTRY_RELEASE: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Logging, + subGroup: EnvironmentVariablesSubGroup.SentryConfig, + description: 'Sentry environment', + }) + @ValidateIf( + (env) => env.EXCEPTION_HANDLER_DRIVER === ExceptionHandlerDriver.Sentry, + ) + @IsString() + @IsOptional() + SENTRY_ENVIRONMENT: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Support, + description: 'Support driver', + }) + @IsEnum(SupportDriver) + @IsOptional() + SUPPORT_DRIVER: SupportDriver = SupportDriver.None; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Support, + subGroup: EnvironmentVariablesSubGroup.FrontSupportConfig, + sensitive: true, + description: 'Support front chat ID', + }) + @ValidateIf((env) => env.SUPPORT_DRIVER === SupportDriver.Front) + @IsString() + SUPPORT_FRONT_CHAT_ID: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Support, + subGroup: EnvironmentVariablesSubGroup.FrontSupportConfig, + sensitive: true, + description: 'Support front HMAC key', + }) + @ValidateIf((env) => env.SUPPORT_DRIVER === SupportDriver.Front) + @IsString() + SUPPORT_FRONT_HMAC_KEY: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Database, + sensitive: true, + description: 'Database URL', + }) @IsDefined() @IsUrl({ protocols: ['postgres'], @@ -131,288 +735,33 @@ export class EnvironmentVariables { }) PG_DATABASE_URL: string; + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Database, + description: 'Is SSL allowed for database', + }) @CastToBoolean() @IsBoolean() @IsOptional() PG_SSL_ALLOW_SELF_SIGNED = false; - // Frontend URL - @IsString() - @IsOptional() - FRONT_DOMAIN?: string; - - @IsString() - @ValidateIf((env) => env.IS_MULTIWORKSPACE_ENABLED) - DEFAULT_SUBDOMAIN = 'app'; - - @IsString() - @IsOptional() - FRONT_PROTOCOL?: 'http' | 'https' = 'http'; - - @CastToPositiveNumber() - @IsNumber() - @IsOptional() - FRONT_PORT?: number; - - @IsUrl({ require_tld: false, require_protocol: true }) - @IsOptional() - SERVER_URL = 'http://localhost:3000'; - - @IsString() - APP_SECRET: string; - - @IsOptional() - @IsString() - ACCESS_TOKEN_SECRET: string; - - @IsDuration() - @IsOptional() - ACCESS_TOKEN_EXPIRES_IN = '30m'; - - @IsOptional() - REFRESH_TOKEN_EXPIRES_IN = '60d'; - - @IsDuration() - @IsOptional() - REFRESH_TOKEN_COOL_DOWN = '1m'; - - @IsDuration() - @IsOptional() - LOGIN_TOKEN_EXPIRES_IN = '15m'; - - @IsDuration() - @IsOptional() - FILE_TOKEN_EXPIRES_IN = '1d'; - - @IsDuration() - @IsOptional() - INVITATION_TOKEN_EXPIRES_IN = '30d'; - - // Auth - @CastToBoolean() - @IsOptional() - @IsBoolean() - AUTH_PASSWORD_ENABLED = true; - - @CastToBoolean() - @IsOptional() - @IsBoolean() - @ValidateIf((env) => env.AUTH_PASSWORD_ENABLED) - SIGN_IN_PREFILLED = false; - - @CastToBoolean() - @IsOptional() - @IsBoolean() - AUTH_MICROSOFT_ENABLED = false; - - @IsString() - @ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED) - AUTH_MICROSOFT_CLIENT_ID: string; - - @IsString() - @ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED) - AUTH_MICROSOFT_CLIENT_SECRET: string; - - @IsUrl({ require_tld: false, require_protocol: true }) - @ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED) - AUTH_MICROSOFT_CALLBACK_URL: string; - - @IsUrl({ require_tld: false, require_protocol: true }) - @ValidateIf((env) => env.AUTH_MICROSOFT_ENABLED) - AUTH_MICROSOFT_APIS_CALLBACK_URL: string; - - @CastToBoolean() - @IsOptional() - @IsBoolean() - AUTH_GOOGLE_ENABLED = false; - - @IsString() - @ValidateIf((env) => env.AUTH_GOOGLE_ENABLED) - AUTH_GOOGLE_CLIENT_ID: string; - - @IsString() - @ValidateIf((env) => env.AUTH_GOOGLE_ENABLED) - AUTH_GOOGLE_CLIENT_SECRET: string; - - @IsUrl({ require_tld: false, require_protocol: true }) - @ValidateIf((env) => env.AUTH_GOOGLE_ENABLED) - AUTH_GOOGLE_CALLBACK_URL: string; - - @IsString() - @IsOptional() - ENTERPRISE_KEY: string; - - @CastToBoolean() - @IsOptional() - @IsBoolean() - IS_MULTIWORKSPACE_ENABLED = false; - - @IsString() - @ValidateIf((env) => env.CLOUDFLARE_ZONE_ID) - CLOUDFLARE_API_KEY: string; - - @IsString() - @ValidateIf((env) => env.CLOUDFLARE_API_KEY) - CLOUDFLARE_ZONE_ID: string; - - // Custom Code Engine - @IsEnum(ServerlessDriverType) - @IsOptional() - SERVERLESS_TYPE: ServerlessDriverType = ServerlessDriverType.Local; - - @ValidateIf((env) => env.SERVERLESS_TYPE === ServerlessDriverType.Lambda) - @IsAWSRegion() - SERVERLESS_LAMBDA_REGION: AwsRegion; - - @ValidateIf((env) => env.SERVERLESS_TYPE === ServerlessDriverType.Lambda) - @IsString() - @IsOptional() - SERVERLESS_LAMBDA_ROLE: string; - - @ValidateIf((env) => env.SERVERLESS_TYPE === ServerlessDriverType.Lambda) - @IsString() - @IsOptional() - SERVERLESS_LAMBDA_ACCESS_KEY_ID: string; - - @ValidateIf((env) => env.SERVERLESS_TYPE === ServerlessDriverType.Lambda) - @IsString() - @IsOptional() - SERVERLESS_LAMBDA_SECRET_ACCESS_KEY: string; - - // Storage - @IsEnum(StorageDriverType) - @IsOptional() - STORAGE_TYPE: StorageDriverType = StorageDriverType.Local; - - @ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S3) - @IsAWSRegion() - STORAGE_S3_REGION: AwsRegion; - - @ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S3) - @IsString() - STORAGE_S3_NAME: string; - - @ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S3) - @IsString() - @IsOptional() - STORAGE_S3_ENDPOINT: string; - - @ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S3) - @IsString() - @IsOptional() - STORAGE_S3_ACCESS_KEY_ID: string; - - @ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.S3) - @IsString() - @IsOptional() - STORAGE_S3_SECRET_ACCESS_KEY: string; - - @IsString() - @ValidateIf((env) => env.STORAGE_TYPE === StorageDriverType.Local) - STORAGE_LOCAL_PATH = '.local-storage'; - - // Support - @IsEnum(SupportDriver) - @IsOptional() - SUPPORT_DRIVER: SupportDriver = SupportDriver.None; - - @ValidateIf((env) => env.SUPPORT_DRIVER === SupportDriver.Front) - @IsString() - SUPPORT_FRONT_CHAT_ID: string; - - @ValidateIf((env) => env.SUPPORT_DRIVER === SupportDriver.Front) - @IsString() - SUPPORT_FRONT_HMAC_KEY: string; - - @IsEnum(LoggerDriverType) - @IsOptional() - LOGGER_DRIVER: LoggerDriverType = LoggerDriverType.Console; - - @CastToBoolean() - @IsBoolean() - @IsOptional() - LOGGER_IS_BUFFER_ENABLED = true; - - @IsEnum(ExceptionHandlerDriver) - @IsOptional() - EXCEPTION_HANDLER_DRIVER: ExceptionHandlerDriver = - ExceptionHandlerDriver.Console; - - @CastToLogLevelArray() - @IsOptional() - LOG_LEVELS: LogLevel[] = ['log', 'error', 'warn']; - - @CastToStringArray() - @IsOptional() - DEMO_WORKSPACE_IDS: string[] = []; - - @ValidateIf( - (env) => env.EXCEPTION_HANDLER_DRIVER === ExceptionHandlerDriver.Sentry, - ) - @IsString() - SENTRY_DSN: string; - - @ValidateIf( - (env) => env.EXCEPTION_HANDLER_DRIVER === ExceptionHandlerDriver.Sentry, - ) - @IsString() - SENTRY_FRONT_DSN: string; - - @ValidateIf( - (env) => env.EXCEPTION_HANDLER_DRIVER === ExceptionHandlerDriver.Sentry, - ) - @IsString() - @IsOptional() - SENTRY_RELEASE: string; - - @ValidateIf( - (env) => env.EXCEPTION_HANDLER_DRIVER === ExceptionHandlerDriver.Sentry, - ) - @IsString() - @IsOptional() - SENTRY_ENVIRONMENT: string; - - @IsDuration() - @IsOptional() - PASSWORD_RESET_TOKEN_EXPIRES_IN = '5m'; - - @CastToPositiveNumber() - @IsNumber() - @ValidateIf((env) => env.WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION > 0) - @IsStrictlyLowerThan('WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION', { - message: - '"WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION" should be strictly lower that "WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION"', + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Cache, + description: 'Cache storage type', }) - @ValidateIf((env) => env.WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION > 0) - WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION = 7; + CACHE_STORAGE_TYPE: CacheStorageType = CacheStorageType.Redis; + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Cache, + description: 'Cache storage TTL', + }) @CastToPositiveNumber() - @IsNumber() - @ValidateIf((env) => env.WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION > 0) - WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION = 14; - - @CastToPositiveNumber() - @IsNumber() - @ValidateIf((env) => env.MAX_NUMBER_OF_WORKSPACES_DELETED_PER_EXECUTION > 0) - MAX_NUMBER_OF_WORKSPACES_DELETED_PER_EXECUTION = 5; - - @IsEnum(CaptchaDriverType) - @IsOptional() - CAPTCHA_DRIVER?: CaptchaDriverType; - - @IsString() - @IsOptional() - CAPTCHA_SITE_KEY?: string; - - @IsString() - @IsOptional() - CAPTCHA_SECRET_KEY?: string; - - @CastToPositiveNumber() - @IsOptional() - @IsNumber() - MUTATION_MAXIMUM_AFFECTED_RECORDS = 100; + CACHE_STORAGE_TTL: number = 3600 * 24 * 7; + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Cache, + sensitive: true, + description: 'Cache storage URL', + }) @IsOptional() @ValidateIf( (env) => @@ -426,93 +775,275 @@ export class EnvironmentVariables { }) REDIS_URL: string; - SHORT_TERM_TOKEN_EXPIRES_IN = '5m'; - - @CastToBoolean() - MESSAGING_PROVIDER_GMAIL_ENABLED = false; - - MESSAGE_QUEUE_TYPE: string = MessageQueueDriverType.BullMQ; - + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.ServerConfig, + description: 'Is debug mode enabled', + }) @CastToBoolean() @IsOptional() @IsBoolean() - IS_EMAIL_VERIFICATION_REQUIRED = false; + DEBUG_MODE = false; - @IsDuration() + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.ServerConfig, + description: 'Node environment', + }) + @IsEnum(NodeEnvironment) + @IsString() + NODE_ENV: NodeEnvironment = NodeEnvironment.development; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.ServerConfig, + description: 'Debug port', + }) + @CastToPositiveNumber() @IsOptional() - EMAIL_VERIFICATION_TOKEN_EXPIRES_IN = '1h'; - - EMAIL_FROM_ADDRESS = 'noreply@yourdomain.com'; - - EMAIL_SYSTEM_ADDRESS = 'system@yourdomain.com'; - - EMAIL_FROM_NAME = 'Felix from Twenty'; - - EMAIL_DRIVER: EmailDriver = EmailDriver.Logger; - - EMAIL_SMTP_HOST: string; + @IsNumber() + @Min(0) + @Max(65535) + DEBUG_PORT = 9000; + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.ServerConfig, + description: 'Server port', + }) @CastToPositiveNumber() - EMAIL_SMTP_PORT = 587; + @IsNumber() + @IsOptional() + PORT = 3000; - EMAIL_SMTP_USER: string; + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.ServerConfig, + description: 'Server URL', + }) + @IsUrl({ require_tld: false, require_protocol: true }) + @IsOptional() + SERVER_URL = 'http://localhost:3000'; - EMAIL_SMTP_PASSWORD: string; - - LLM_CHAT_MODEL_DRIVER: LLMChatModelDriver; - - OPENAI_API_KEY: string; - - LANGFUSE_SECRET_KEY: string; - - LANGFUSE_PUBLIC_KEY: string; - - LLM_TRACING_DRIVER: LLMTracingDriver = LLMTracingDriver.Console; - - @CastToPositiveNumber() - API_RATE_LIMITING_TTL = 100; - - @CastToPositiveNumber() - API_RATE_LIMITING_LIMIT = 500; - - CACHE_STORAGE_TYPE: CacheStorageType = CacheStorageType.Redis; - - @CastToPositiveNumber() - CACHE_STORAGE_TTL: number = 3600 * 24 * 7; + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.ServerConfig, + sensitive: true, + description: 'Server secret', + }) + @IsString() + APP_SECRET: string; + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.ServerConfig, + sensitive: true, + description: 'Session store secret', + }) @IsString() @IsOptional() SESSION_STORE_SECRET = 'replace_me_with_a_random_string_session'; - @CastToBoolean() - CALENDAR_PROVIDER_GOOGLE_ENABLED = false; - - AUTH_GOOGLE_APIS_CALLBACK_URL: string; - - CHROME_EXTENSION_ID: string; - + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.ServerConfig, + description: 'Mutation maximum affected records', + }) @CastToPositiveNumber() - SERVERLESS_FUNCTION_EXEC_THROTTLE_LIMIT = 10; + @IsOptional() + @IsNumber() + MUTATION_MAXIMUM_AFFECTED_RECORDS = 100; - // milliseconds + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.ServerConfig, + subGroup: EnvironmentVariablesSubGroup.RateLimiting, + description: 'Rate limiting TTL', + }) @CastToPositiveNumber() - SERVERLESS_FUNCTION_EXEC_THROTTLE_TTL = 1000; + API_RATE_LIMITING_TTL = 100; + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.ServerConfig, + subGroup: EnvironmentVariablesSubGroup.RateLimiting, + description: 'Rate limiting limit', + }) @CastToPositiveNumber() - WORKFLOW_EXEC_THROTTLE_LIMIT = 10; + API_RATE_LIMITING_LIMIT = 500; - // milliseconds - @CastToPositiveNumber() - WORKFLOW_EXEC_THROTTLE_TTL = 1000; - - // SSL + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.ServerConfig, + subGroup: EnvironmentVariablesSubGroup.SSL, + description: 'SSL key path', + }) @IsString() @IsOptional() SSL_KEY_PATH: string; + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.ServerConfig, + subGroup: EnvironmentVariablesSubGroup.SSL, + description: 'SSL certificate path', + }) @IsString() @IsOptional() SSL_CERT_PATH: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.ServerConfig, + subGroup: EnvironmentVariablesSubGroup.CloudflareConfig, + sensitive: true, + description: 'Cloudflare API key', + }) + @IsString() + @ValidateIf((env) => env.CLOUDFLARE_ZONE_ID) + CLOUDFLARE_API_KEY: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.ServerConfig, + subGroup: EnvironmentVariablesSubGroup.CloudflareConfig, + description: 'Cloudflare Zone ID', + }) + @IsString() + @ValidateIf((env) => env.CLOUDFLARE_API_KEY) + CLOUDFLARE_ZONE_ID: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.LLM, + description: 'LLM chat model driver', + }) + LLM_CHAT_MODEL_DRIVER: LLMChatModelDriver; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.LLM, + sensitive: true, + description: 'OpenAI API key', + }) + OPENAI_API_KEY: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.LLM, + sensitive: true, + description: 'Langfuse secret key', + }) + LANGFUSE_SECRET_KEY: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.LLM, + description: 'Langfuse public key', + }) + LANGFUSE_PUBLIC_KEY: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.LLM, + description: 'LLM tracing driver', + }) + LLM_TRACING_DRIVER: LLMTracingDriver = LLMTracingDriver.Console; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Workspace, + description: 'Is multiworkspace enabled', + }) + @CastToBoolean() + @IsOptional() + @IsBoolean() + IS_MULTIWORKSPACE_ENABLED = false; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Workspace, + description: 'Permissions enabled', + }) + @CastToBoolean() + @IsOptional() + @IsBoolean() + PERMISSIONS_ENABLED = false; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Workspace, + description: 'Demo workspace IDs', + }) + @CastToStringArray() + @IsOptional() + DEMO_WORKSPACE_IDS: string[] = []; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Workspace, + description: 'Workspace inactive days before notification', + }) + @CastToPositiveNumber() + @IsNumber() + @ValidateIf((env) => env.WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION > 0) + @IsStrictlyLowerThan('WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION', { + message: + '"WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION" should be strictly lower than "WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION"', + }) + @ValidateIf((env) => env.WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION > 0) + WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION = 7; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Workspace, + description: 'Workspace inactive days before deletion', + }) + @CastToPositiveNumber() + @IsNumber() + @ValidateIf((env) => env.WORKSPACE_INACTIVE_DAYS_BEFORE_NOTIFICATION > 0) + WORKSPACE_INACTIVE_DAYS_BEFORE_DELETION = 14; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Workspace, + description: 'Maximum number of workspaces deleted per execution', + }) + @CastToPositiveNumber() + @IsNumber() + @ValidateIf((env) => env.MAX_NUMBER_OF_WORKSPACES_DELETED_PER_EXECUTION > 0) + MAX_NUMBER_OF_WORKSPACES_DELETED_PER_EXECUTION = 5; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.QueueConfig, + description: 'Queue driver type', + }) + MESSAGE_QUEUE_TYPE: string = MessageQueueDriverType.BullMQ; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.QueueConfig, + description: 'Workflow execution throttle limit', + }) + @CastToPositiveNumber() + WORKFLOW_EXEC_THROTTLE_LIMIT = 10; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.QueueConfig, + description: 'Workflow execution throttle TTL', + }) + // milliseconds + @CastToPositiveNumber() + WORKFLOW_EXEC_THROTTLE_TTL = 1000; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Security, + description: 'Captcha driver type', + }) + @IsEnum(CaptchaDriverType) + @IsOptional() + CAPTCHA_DRIVER?: CaptchaDriverType; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Security, + sensitive: true, + description: 'Captcha site key', + }) + @IsString() + @IsOptional() + CAPTCHA_SITE_KEY?: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Security, + sensitive: true, + description: 'Captcha secret key', + }) + @IsString() + @IsOptional() + CAPTCHA_SECRET_KEY?: string; + + @EnvironmentVariablesMetadata({ + group: EnvironmentVariablesGroup.Security, + sensitive: true, + description: 'Enterprise key', + }) + @IsString() + @IsOptional() + ENTERPRISE_KEY: string; } export const validate = ( diff --git a/packages/twenty-server/src/engine/core-modules/environment/environment.service.spec.ts b/packages/twenty-server/src/engine/core-modules/environment/environment.service.spec.ts index 49b35947d..e4267ebc7 100644 --- a/packages/twenty-server/src/engine/core-modules/environment/environment.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/environment/environment.service.spec.ts @@ -1,10 +1,12 @@ -import { Test, TestingModule } from '@nestjs/testing'; import { ConfigService } from '@nestjs/config'; +import { Test, TestingModule } from '@nestjs/testing'; +import { EnvironmentVariables } from 'src/engine/core-modules/environment/environment-variables'; import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service'; describe('EnvironmentService', () => { let service: EnvironmentService; + let configService: ConfigService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -12,15 +14,98 @@ describe('EnvironmentService', () => { EnvironmentService, { provide: ConfigService, - useValue: {}, + useValue: { + get: jest.fn(), + }, }, ], }).compile(); service = module.get(EnvironmentService); + configService = module.get(ConfigService); + + Reflect.defineMetadata('environment-variables', {}, EnvironmentVariables); }); it('should be defined', () => { expect(service).toBeDefined(); }); + + describe('getAll()', () => { + it('should return empty object when no environment variables are defined', () => { + const result = service.getAll(); + + expect(result).toEqual({}); + }); + + it('should return environment variables with their metadata', () => { + const mockMetadata = { + TEST_VAR: { + title: 'Test Var', + description: 'Test Description', + }, + }; + + Reflect.defineMetadata( + 'environment-variables', + mockMetadata, + EnvironmentVariables, + ); + + 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( + 'environment-variables', + mockMetadata, + EnvironmentVariables, + ); + + 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}$/); + }); + + it('should use default value when environment variable is not set', () => { + const mockMetadata = { + DEBUG_PORT: { + title: 'Debug Port', + description: 'Debug port number', + }, + }; + + Reflect.defineMetadata( + 'environment-variables', + mockMetadata, + EnvironmentVariables, + ); + + jest.spyOn(configService, 'get').mockReturnValue(undefined); + + const result = service.getAll(); + + expect(result.DEBUG_PORT.value).toBe(9000); + }); + }); }); diff --git a/packages/twenty-server/src/engine/core-modules/environment/environment.service.ts b/packages/twenty-server/src/engine/core-modules/environment/environment.service.ts index 3860fdabd..c0a9158bf 100644 --- a/packages/twenty-server/src/engine/core-modules/environment/environment.service.ts +++ b/packages/twenty-server/src/engine/core-modules/environment/environment.service.ts @@ -2,7 +2,12 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { ENVIRONMENT_VARIABLES_MASKING_CONFIG } from 'src/engine/core-modules/environment/constants/environment-variables-masking-config'; +import { EnvironmentVariablesMetadataOptions } from 'src/engine/core-modules/environment/decorators/environment-variables-metadata.decorator'; +import { EnvironmentVariablesMaskingStrategies } from 'src/engine/core-modules/environment/enums/environment-variables-masking-strategies.enum'; import { EnvironmentVariables } from 'src/engine/core-modules/environment/environment-variables'; +import { environmentVariableMaskSensitiveData } from 'src/engine/core-modules/environment/utils/environment-variable-mask-sensitive-data.util'; +import { TypedReflect } from 'src/utils/typed-reflect'; @Injectable() export class EnvironmentService { @@ -14,4 +19,60 @@ export class EnvironmentService { new EnvironmentVariables()[key], ); } + + getAll(): Record< + string, + { + value: EnvironmentVariables[keyof EnvironmentVariables]; + metadata: EnvironmentVariablesMetadataOptions; + } + > { + const result: Record< + string, + { + value: EnvironmentVariables[keyof EnvironmentVariables]; + metadata: EnvironmentVariablesMetadataOptions; + } + > = {}; + + const envVars = new EnvironmentVariables(); + const metadata = + TypedReflect.getMetadata('environment-variables', EnvironmentVariables) ?? + {}; + + Object.entries(metadata).forEach(([key, envMetadata]) => { + let value = + this.configService.get(key) ?? + envVars[key as keyof EnvironmentVariables] ?? + ''; + + if ( + typeof value === 'string' && + key in ENVIRONMENT_VARIABLES_MASKING_CONFIG + ) { + const varMaskingConfig = + ENVIRONMENT_VARIABLES_MASKING_CONFIG[ + key as keyof typeof ENVIRONMENT_VARIABLES_MASKING_CONFIG + ]; + const options = + varMaskingConfig.strategy === + EnvironmentVariablesMaskingStrategies.LAST_N_CHARS + ? { chars: varMaskingConfig.chars } + : undefined; + + value = environmentVariableMaskSensitiveData( + value, + varMaskingConfig.strategy, + { ...options, variableName: key }, + ); + } + + result[key] = { + value, + metadata: envMetadata, + }; + }); + + return result; + } } diff --git a/packages/twenty-server/src/engine/core-modules/environment/utils/__tests__/environment-variable-mask-sensitive-data.util.spec.ts b/packages/twenty-server/src/engine/core-modules/environment/utils/__tests__/environment-variable-mask-sensitive-data.util.spec.ts new file mode 100644 index 000000000..9d84c25e3 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/environment/utils/__tests__/environment-variable-mask-sensitive-data.util.spec.ts @@ -0,0 +1,105 @@ +import { EnvironmentVariablesMaskingStrategies } from 'src/engine/core-modules/environment/enums/environment-variables-masking-strategies.enum'; +import { environmentVariableMaskSensitiveData } from 'src/engine/core-modules/environment/utils/environment-variable-mask-sensitive-data.util'; + +describe('environmentVariableMaskSensitiveData', () => { + describe('LAST_N_CHARS strategy', () => { + it('should mask all but the last 5 characters by default', () => { + const result = environmentVariableMaskSensitiveData( + 'mysecretvalue123', + EnvironmentVariablesMaskingStrategies.LAST_N_CHARS, + ); + + expect(result).toBe('********ue123'); + }); + + it('should mask all but the specified number of characters', () => { + const result = environmentVariableMaskSensitiveData( + 'mysecretvalue123', + EnvironmentVariablesMaskingStrategies.LAST_N_CHARS, + { chars: 3 }, + ); + + expect(result).toBe('********123'); + }); + + it('should return all asterisks if value is shorter than mask length', () => { + const result = environmentVariableMaskSensitiveData( + '123', + EnvironmentVariablesMaskingStrategies.LAST_N_CHARS, + { chars: 5 }, + ); + + expect(result).toBe('********'); + }); + + it('should handle empty string', () => { + const result = environmentVariableMaskSensitiveData( + '', + EnvironmentVariablesMaskingStrategies.LAST_N_CHARS, + ); + + expect(result).toBe(''); + }); + }); + + describe('HIDE_PASSWORD strategy', () => { + it('should mask password in URL', () => { + const result = environmentVariableMaskSensitiveData( + 'postgresql://user:password123@localhost:5432/db', + EnvironmentVariablesMaskingStrategies.HIDE_PASSWORD, + ); + + expect(result).toBe('postgresql://********:********@localhost:5432/db'); + }); + + it('should handle URL without password', () => { + const result = environmentVariableMaskSensitiveData( + 'postgresql://localhost:5432/db', + EnvironmentVariablesMaskingStrategies.HIDE_PASSWORD, + ); + + expect(result).toBe('postgresql://localhost:5432/db'); + }); + + it('should throw error for invalid URLs', () => { + expect(() => + environmentVariableMaskSensitiveData( + 'not-a-url', + EnvironmentVariablesMaskingStrategies.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 = environmentVariableMaskSensitiveData( + null as any, + EnvironmentVariablesMaskingStrategies.LAST_N_CHARS, + ); + + expect(result).toBeNull(); + }); + + it('should handle undefined value', () => { + const result = environmentVariableMaskSensitiveData( + undefined as any, + EnvironmentVariablesMaskingStrategies.LAST_N_CHARS, + ); + + expect(result).toBeUndefined(); + }); + + it('should handle non-string value', () => { + const result = environmentVariableMaskSensitiveData( + 123 as any, + EnvironmentVariablesMaskingStrategies.LAST_N_CHARS, + ); + + expect(result).toBe(123); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/environment/utils/environment-variable-mask-sensitive-data.util.ts b/packages/twenty-server/src/engine/core-modules/environment/utils/environment-variable-mask-sensitive-data.util.ts new file mode 100644 index 000000000..4497d55e5 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/environment/utils/environment-variable-mask-sensitive-data.util.ts @@ -0,0 +1,38 @@ +import { EnvironmentVariablesMaskingStrategies } from 'src/engine/core-modules/environment/enums/environment-variables-masking-strategies.enum'; + +export const environmentVariableMaskSensitiveData = ( + value: string, + strategy: EnvironmentVariablesMaskingStrategies, + options?: { chars?: number; variableName?: string }, +): string => { + if (!value || typeof value !== 'string') return value; + switch (strategy) { + case EnvironmentVariablesMaskingStrategies.LAST_N_CHARS: { + const n = Math.max(1, options?.chars ?? 5); + + return value.length > n ? `********${value.slice(-n)}` : '********'; + } + + case EnvironmentVariablesMaskingStrategies.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 || 'environment variable'} in HIDE_PASSWORD masking strategy`, + ); + } + } + + default: + return value; + } +}; diff --git a/packages/twenty-server/src/utils/typed-reflect.ts b/packages/twenty-server/src/utils/typed-reflect.ts index 0ee6b0604..650d926a1 100644 --- a/packages/twenty-server/src/utils/typed-reflect.ts +++ b/packages/twenty-server/src/utils/typed-reflect.ts @@ -2,6 +2,8 @@ import 'reflect-metadata'; import { Gate } from 'src/engine/twenty-orm/interfaces/gate.interface'; +import { EnvironmentVariablesMetadataMap } from 'src/engine/core-modules/environment/decorators/environment-variables-metadata.decorator'; + export interface ReflectMetadataTypeMap { ['workspace:is-nullable-metadata-args']: true; ['workspace:gate-metadata-args']: Gate; @@ -10,6 +12,7 @@ export interface ReflectMetadataTypeMap { ['workspace:is-primary-field-metadata-args']: true; ['workspace:is-deprecated-field-metadata-args']: true; ['workspace:is-unique-metadata-args']: true; + ['environment-variables']: EnvironmentVariablesMetadataMap; } export class TypedReflect {