diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index 82964243b6..a3c48b5f89 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -1976,6 +1976,7 @@ type ClientConfig { isMultiWorkspaceEnabled: Boolean! isEmailVerificationRequired: Boolean! defaultSubdomain: String + subdomainMinLength: Float! frontDomain: String! publicFunctionDomain: String analyticsEnabled: Boolean! diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 43fa94d66e..03c7ba5df3 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -1615,6 +1615,7 @@ export interface ClientConfig { isMultiWorkspaceEnabled: Scalars['Boolean'] isEmailVerificationRequired: Scalars['Boolean'] defaultSubdomain?: Scalars['String'] + subdomainMinLength: Scalars['Float'] frontDomain: Scalars['String'] publicFunctionDomain?: Scalars['String'] analyticsEnabled: Scalars['Boolean'] @@ -4868,6 +4869,7 @@ export interface ClientConfigGenqlSelection{ isMultiWorkspaceEnabled?: boolean | number isEmailVerificationRequired?: boolean | number defaultSubdomain?: boolean | number + subdomainMinLength?: boolean | number frontDomain?: boolean | number publicFunctionDomain?: boolean | number analyticsEnabled?: boolean | number diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index 2644d9beb9..4afb1c1123 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -3801,6 +3801,9 @@ export default { "defaultSubdomain": [ 1 ], + "subdomainMinLength": [ + 16 + ], "frontDomain": [ 1 ], diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index c149708c37..4226b5229c 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -983,6 +983,7 @@ export type ClientConfig = { publicFunctionDomain?: Maybe; sentry: Sentry; signInPrefilled: Scalars['Boolean']['output']; + subdomainMinLength: Scalars['Float']['output']; support: Support; }; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspaceSubdomainField.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspaceSubdomainField.ts index e7b5bec08d..69867a111c 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspaceSubdomainField.ts +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useWorkspaceSubdomainField.ts @@ -1,4 +1,6 @@ +import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState'; import { getSubdomainValidationSchema } from '@/settings/domains/utils/getSubdomainValidationSchema'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { useApolloClient, useLazyQuery } from '@apollo/client/react'; import { useMemo, useState } from 'react'; import { @@ -25,7 +27,11 @@ export const useWorkspaceSubdomainField = ({ isSubdomainEnabled = true, }: { isSubdomainEnabled?: boolean } = {}) => { const apolloClient = useApolloClient(); - const subdomainSchema = useMemo(() => getSubdomainValidationSchema(), []); + const { subdomainMinLength } = useAtomStateValue(domainConfigurationState); + const subdomainSchema = useMemo( + () => getSubdomainValidationSchema(subdomainMinLength), + [subdomainMinLength], + ); const defaults = useMemo( () => diff --git a/packages/twenty-front/src/modules/client-config/hooks/useClientConfig.ts b/packages/twenty-front/src/modules/client-config/hooks/useClientConfig.ts index 93e5b21b4b..81ca6dfdda 100644 --- a/packages/twenty-front/src/modules/client-config/hooks/useClientConfig.ts +++ b/packages/twenty-front/src/modules/client-config/hooks/useClientConfig.ts @@ -208,6 +208,7 @@ export const useClientConfig = (): UseClientConfigResult => { defaultSubdomain: clientConfig?.defaultSubdomain, frontDomain: clientConfig?.frontDomain, publicFunctionDomain: clientConfig?.publicFunctionDomain, + subdomainMinLength: clientConfig?.subdomainMinLength, }); setCanManageFeatureFlags(clientConfig?.canManageFeatureFlags); setLabPublicFeatureFlags(clientConfig?.publicFeatureFlags); diff --git a/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts b/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts index e1ec32e44c..701a410f14 100644 --- a/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts +++ b/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts @@ -25,6 +25,7 @@ export type ClientConfig = { canManageFeatureFlags: boolean; captcha: Captcha; defaultSubdomain?: string; + subdomainMinLength: number; frontDomain: string; publicFunctionDomain?: string | null; isAttachmentPreviewEnabled: boolean; diff --git a/packages/twenty-front/src/modules/domain-manager/states/domainConfigurationState.ts b/packages/twenty-front/src/modules/domain-manager/states/domainConfigurationState.ts index 38eb1823cc..9c181a3efc 100644 --- a/packages/twenty-front/src/modules/domain-manager/states/domainConfigurationState.ts +++ b/packages/twenty-front/src/modules/domain-manager/states/domainConfigurationState.ts @@ -1,10 +1,14 @@ import { type ClientConfig } from '@/client-config/types/ClientConfig'; import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; +import { DEFAULT_SUBDOMAIN_MIN_LENGTH } from 'twenty-shared/constants'; export const domainConfigurationState = createAtomState< Pick< ClientConfig, - 'frontDomain' | 'defaultSubdomain' | 'publicFunctionDomain' + | 'frontDomain' + | 'defaultSubdomain' + | 'publicFunctionDomain' + | 'subdomainMinLength' > >({ key: 'domainConfiguration', @@ -12,5 +16,6 @@ export const domainConfigurationState = createAtomState< frontDomain: '', defaultSubdomain: undefined, publicFunctionDomain: undefined, + subdomainMinLength: DEFAULT_SUBDOMAIN_MIN_LENGTH, }, }); diff --git a/packages/twenty-front/src/modules/settings/domains/hooks/useSettingsSubdomain.ts b/packages/twenty-front/src/modules/settings/domains/hooks/useSettingsSubdomain.ts index 3bc1ddb131..49af783539 100644 --- a/packages/twenty-front/src/modules/settings/domains/hooks/useSettingsSubdomain.ts +++ b/packages/twenty-front/src/modules/settings/domains/hooks/useSettingsSubdomain.ts @@ -1,9 +1,11 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain'; +import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState'; import { getSubdomainValidationSchema } from '@/settings/domains/utils/getSubdomainValidationSchema'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useModal } from '@/ui/layout/modal/hooks/useModal'; import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { CombinedGraphQLErrors } from '@apollo/client/errors'; import { useMutation } from '@apollo/client/react'; import { useLingui } from '@lingui/react/macro'; @@ -16,7 +18,8 @@ export const SUBDOMAIN_CHANGE_CONFIRMATION_MODAL_ID = export const useSettingsSubdomain = () => { const { t } = useLingui(); - const subdomainSchema = getSubdomainValidationSchema(); + const { subdomainMinLength } = useAtomStateValue(domainConfigurationState); + const subdomainSchema = getSubdomainValidationSchema(subdomainMinLength); const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar(); const [updateWorkspace] = useMutation(UpdateWorkspaceDocument); diff --git a/packages/twenty-front/src/modules/settings/domains/utils/getSubdomainValidationSchema.ts b/packages/twenty-front/src/modules/settings/domains/utils/getSubdomainValidationSchema.ts index bc1b5cf019..95441a2306 100644 --- a/packages/twenty-front/src/modules/settings/domains/utils/getSubdomainValidationSchema.ts +++ b/packages/twenty-front/src/modules/settings/domains/utils/getSubdomainValidationSchema.ts @@ -1,14 +1,19 @@ import { t } from '@lingui/core/macro'; import { + DEFAULT_SUBDOMAIN_MIN_LENGTH, RESERVED_SUBDOMAINS, SUBDOMAIN_PATTERN, } from 'twenty-shared/constants'; import { z } from 'zod'; -export const getSubdomainValidationSchema = () => +export const getSubdomainValidationSchema = ( + minLength = DEFAULT_SUBDOMAIN_MIN_LENGTH, +) => z .string() - .min(3, { message: t`Subdomain can not be shorter than 3 characters` }) + .min(minLength, { + message: t`Subdomain cannot be shorter than ${minLength} characters`, + }) .max(30, { message: t`Subdomain can not be longer than 30 characters` }) .regex(SUBDOMAIN_PATTERN, { message: t`Use letter, number and dash only. Start and finish with a letter or a number`, diff --git a/packages/twenty-front/src/pages/settings/domains/__stories__/SettingsSubdomain.stories.tsx b/packages/twenty-front/src/pages/settings/domains/__stories__/SettingsSubdomain.stories.tsx index 1902406597..2ff657a340 100644 --- a/packages/twenty-front/src/pages/settings/domains/__stories__/SettingsSubdomain.stories.tsx +++ b/packages/twenty-front/src/pages/settings/domains/__stories__/SettingsSubdomain.stories.tsx @@ -35,7 +35,7 @@ export const TooShortSubdomain: Story = { await userEvent.type(input, 'ab'); const errorMessage = await canvas.findByText( - 'Subdomain can not be shorter than 3 characters', + 'Subdomain cannot be shorter than 3 characters', ); await expect(errorMessage).toBeVisible(); diff --git a/packages/twenty-front/src/testing/mock-data/config.ts b/packages/twenty-front/src/testing/mock-data/config.ts index 604c203345..92871293d1 100644 --- a/packages/twenty-front/src/testing/mock-data/config.ts +++ b/packages/twenty-front/src/testing/mock-data/config.ts @@ -16,6 +16,7 @@ export const mockedClientConfig: ClientConfig = { }, frontDomain: 'localhost', defaultSubdomain: 'app', + subdomainMinLength: 3, analyticsEnabled: true, support: { supportDriver: SupportDriver.FRONT, diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts index e3b2edb8fc..44ef93dfb6 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts @@ -68,6 +68,7 @@ describe('ClientConfigController', () => { isMultiWorkspaceEnabled: true, isEmailVerificationRequired: false, defaultSubdomain: 'app', + subdomainMinLength: 3, frontDomain: 'localhost', publicFunctionDomain: null, support: { diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts index 8b2ae617b3..a1409f8345 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts @@ -277,6 +277,9 @@ export class ClientConfig { @Field(() => String, { nullable: true }) defaultSubdomain: string; + @Field(() => Number) + subdomainMinLength: number; + @Field(() => String) frontDomain: string; diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts index 4cd4da967f..6a2290f1c8 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts @@ -84,6 +84,7 @@ describe('ClientConfigService', () => { IS_MULTIWORKSPACE_ENABLED: true, IS_EMAIL_VERIFICATION_REQUIRED: true, DEFAULT_SUBDOMAIN: 'app', + SUBDOMAIN_MIN_LENGTH: 3, NODE_ENV: NodeEnvironment.DEVELOPMENT, SUPPORT_DRIVER: SupportDriver.FRONT, SUPPORT_FRONT_CHAT_ID: 'chat-123', @@ -154,6 +155,7 @@ describe('ClientConfigService', () => { isMultiWorkspaceEnabled: true, isEmailVerificationRequired: true, defaultSubdomain: 'app', + subdomainMinLength: 3, frontDomain: 'app.twenty.com', publicFunctionDomain: null, support: { diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts index 4d7dd535d8..a8f29a29f6 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts @@ -206,6 +206,7 @@ export class ClientConfigService { 'IS_EMAIL_VERIFICATION_REQUIRED', ), defaultSubdomain: this.twentyConfigService.get('DEFAULT_SUBDOMAIN'), + subdomainMinLength: this.twentyConfigService.get('SUBDOMAIN_MIN_LENGTH'), frontDomain: this.domainServerConfigService.getFrontUrl().hostname, publicFunctionDomain: this.domainServerConfigService.getPublicBaseHostnameOrUndefined() ?? diff --git a/packages/twenty-server/src/engine/core-modules/domain/subdomain-manager/services/__test__/subdomain-manager.service.spec.ts b/packages/twenty-server/src/engine/core-modules/domain/subdomain-manager/services/__test__/subdomain-manager.service.spec.ts index c1fedd8536..f527c68aea 100644 --- a/packages/twenty-server/src/engine/core-modules/domain/subdomain-manager/services/__test__/subdomain-manager.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/domain/subdomain-manager/services/__test__/subdomain-manager.service.spec.ts @@ -44,7 +44,9 @@ describe('SubdomainManagerService', () => { { provide: TwentyConfigService, useValue: { - get: jest.fn().mockReturnValue('app'), + get: jest.fn((key: string) => + key === 'SUBDOMAIN_MIN_LENGTH' ? 3 : 'app', + ), }, }, ], diff --git a/packages/twenty-server/src/engine/core-modules/domain/subdomain-manager/services/subdomain-manager.service.ts b/packages/twenty-server/src/engine/core-modules/domain/subdomain-manager/services/subdomain-manager.service.ts index c459c89f8f..506a42cd41 100644 --- a/packages/twenty-server/src/engine/core-modules/domain/subdomain-manager/services/subdomain-manager.service.ts +++ b/packages/twenty-server/src/engine/core-modules/domain/subdomain-manager/services/subdomain-manager.service.ts @@ -72,12 +72,15 @@ export class SubdomainManagerService { desired: string, count: number, ): Promise { - const derivedBase = isSubdomainValid(desired) + const minLength = this.twentyConfigService.get('SUBDOMAIN_MIN_LENGTH'); + + const derivedBase = isSubdomainValid({ subdomain: desired, minLength }) ? desired : getSubdomainSlugFromDisplayName(desired); const base = - isDefined(derivedBase) && isSubdomainValid(derivedBase) + isDefined(derivedBase) && + isSubdomainValid({ subdomain: derivedBase, minLength }) ? derivedBase : generateRandomSubdomain(); @@ -111,10 +114,12 @@ export class SubdomainManagerService { candidates: string[], ): Promise { const defaultSubdomain = this.twentyConfigService.get('DEFAULT_SUBDOMAIN'); + const minLength = this.twentyConfigService.get('SUBDOMAIN_MIN_LENGTH'); const validCandidates = candidates.filter( (candidate) => - isSubdomainValid(candidate) && candidate !== defaultSubdomain, + isSubdomainValid({ subdomain: candidate, minLength }) && + candidate !== defaultSubdomain, ); if (validCandidates.length === 0) { @@ -139,7 +144,10 @@ export class SubdomainManagerService { async getSubdomainAvailability( subdomain: string, ): Promise { - const isValid = isSubdomainValid(subdomain); + const isValid = isSubdomainValid({ + subdomain, + minLength: this.twentyConfigService.get('SUBDOMAIN_MIN_LENGTH'), + }); const available = isValid && (await this.isSubdomainFreeToUse(subdomain)); // Autofill adopts the first suggestion directly, so never echo an invalid @@ -169,7 +177,10 @@ export class SubdomainManagerService { } async validateSubdomainOrThrow(subdomain: string) { - const isValid = isSubdomainValid(subdomain); + const isValid = isSubdomainValid({ + subdomain, + minLength: this.twentyConfigService.get('SUBDOMAIN_MIN_LENGTH'), + }); if (!isValid) { throw new WorkspaceException( @@ -193,7 +204,10 @@ export class SubdomainManagerService { private async isSubdomainFreeToUse(subdomain: string): Promise { return ( - isSubdomainValid(subdomain) && + isSubdomainValid({ + subdomain, + minLength: this.twentyConfigService.get('SUBDOMAIN_MIN_LENGTH'), + }) && this.twentyConfigService.get('DEFAULT_SUBDOMAIN') !== subdomain && (await this.isSubdomainAvailable(subdomain)) ); diff --git a/packages/twenty-server/src/engine/core-modules/domain/subdomain-manager/utils/__test__/is-subdomain-valid.util.spec.ts b/packages/twenty-server/src/engine/core-modules/domain/subdomain-manager/utils/__test__/is-subdomain-valid.util.spec.ts index a9eaaa548a..6d115e4d2d 100644 --- a/packages/twenty-server/src/engine/core-modules/domain/subdomain-manager/utils/__test__/is-subdomain-valid.util.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/domain/subdomain-manager/utils/__test__/is-subdomain-valid.util.spec.ts @@ -3,204 +3,222 @@ import { isSubdomainValid } from 'src/engine/core-modules/domain/subdomain-manag describe('isSubdomainValid', () => { describe('valid subdomains', () => { it('should accept valid alphanumeric subdomains', () => { - expect(isSubdomainValid('abc')).toBe(true); - expect(isSubdomainValid('test123')).toBe(true); - expect(isSubdomainValid('company1')).toBe(true); - expect(isSubdomainValid('workspace2024')).toBe(true); + expect(isSubdomainValid({ subdomain: 'abc' })).toBe(true); + expect(isSubdomainValid({ subdomain: 'test123' })).toBe(true); + expect(isSubdomainValid({ subdomain: 'company1' })).toBe(true); + expect(isSubdomainValid({ subdomain: 'workspace2024' })).toBe(true); }); it('should accept subdomains with hyphens in the middle', () => { - expect(isSubdomainValid('my-company')).toBe(true); - expect(isSubdomainValid('test-workspace')).toBe(true); - expect(isSubdomainValid('multi-word-subdomain')).toBe(true); - expect(isSubdomainValid('a-b-c-d-e')).toBe(true); + expect(isSubdomainValid({ subdomain: 'my-company' })).toBe(true); + expect(isSubdomainValid({ subdomain: 'test-workspace' })).toBe(true); + expect(isSubdomainValid({ subdomain: 'multi-word-subdomain' })).toBe( + true, + ); + expect(isSubdomainValid({ subdomain: 'a-b-c-d-e' })).toBe(true); }); it('should accept subdomains with mixed alphanumeric and hyphens', () => { - expect(isSubdomainValid('test-123')).toBe(true); - expect(isSubdomainValid('company-2024')).toBe(true); - expect(isSubdomainValid('workspace-v2')).toBe(true); - expect(isSubdomainValid('my-app-123')).toBe(true); + expect(isSubdomainValid({ subdomain: 'test-123' })).toBe(true); + expect(isSubdomainValid({ subdomain: 'company-2024' })).toBe(true); + expect(isSubdomainValid({ subdomain: 'workspace-v2' })).toBe(true); + expect(isSubdomainValid({ subdomain: 'my-app-123' })).toBe(true); }); it('should accept minimum length subdomains (3 characters)', () => { - expect(isSubdomainValid('abc')).toBe(true); - expect(isSubdomainValid('a1b')).toBe(true); - expect(isSubdomainValid('x-y')).toBe(true); + expect(isSubdomainValid({ subdomain: 'abc' })).toBe(true); + expect(isSubdomainValid({ subdomain: 'a1b' })).toBe(true); + expect(isSubdomainValid({ subdomain: 'x-y' })).toBe(true); }); it('should accept maximum length subdomains (30 characters)', () => { const maxLengthSubdomain = 'a'.repeat(28) + 'bc'; // 30 characters total - expect(isSubdomainValid(maxLengthSubdomain)).toBe(true); + expect(isSubdomainValid({ subdomain: maxLengthSubdomain })).toBe(true); const maxLengthWithHyphens = 'a' + '-'.repeat(28) + 'b'; // 30 characters with hyphens - expect(isSubdomainValid(maxLengthWithHyphens)).toBe(true); + expect(isSubdomainValid({ subdomain: maxLengthWithHyphens })).toBe(true); }); it('should accept subdomains starting and ending with alphanumeric characters', () => { - expect(isSubdomainValid('a-b')).toBe(true); - expect(isSubdomainValid('1-test-2')).toBe(true); - expect(isSubdomainValid('start-middle-end')).toBe(true); + expect(isSubdomainValid({ subdomain: 'a-b' })).toBe(true); + expect(isSubdomainValid({ subdomain: '1-test-2' })).toBe(true); + expect(isSubdomainValid({ subdomain: 'start-middle-end' })).toBe(true); }); }); describe('invalid subdomain patterns', () => { it('should reject empty strings', () => { - expect(isSubdomainValid('')).toBe(false); + expect(isSubdomainValid({ subdomain: '' })).toBe(false); }); it('should reject subdomains that are too short (less than 3 characters)', () => { - expect(isSubdomainValid('a')).toBe(false); - expect(isSubdomainValid('ab')).toBe(false); - expect(isSubdomainValid('1')).toBe(false); - expect(isSubdomainValid('12')).toBe(false); + expect(isSubdomainValid({ subdomain: 'a' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'ab' })).toBe(false); + expect(isSubdomainValid({ subdomain: '1' })).toBe(false); + expect(isSubdomainValid({ subdomain: '12' })).toBe(false); }); it('should reject subdomains that are too long (more than 30 characters)', () => { const tooLongSubdomain = 'a'.repeat(31); - expect(isSubdomainValid(tooLongSubdomain)).toBe(false); + expect(isSubdomainValid({ subdomain: tooLongSubdomain })).toBe(false); const wayTooLongSubdomain = 'a'.repeat(50); - expect(isSubdomainValid(wayTooLongSubdomain)).toBe(false); + expect(isSubdomainValid({ subdomain: wayTooLongSubdomain })).toBe(false); }); it('should reject subdomains starting with hyphens', () => { - expect(isSubdomainValid('-test')).toBe(false); - expect(isSubdomainValid('-abc')).toBe(false); - expect(isSubdomainValid('-my-company')).toBe(false); + expect(isSubdomainValid({ subdomain: '-test' })).toBe(false); + expect(isSubdomainValid({ subdomain: '-abc' })).toBe(false); + expect(isSubdomainValid({ subdomain: '-my-company' })).toBe(false); }); it('should reject subdomains ending with hyphens', () => { - expect(isSubdomainValid('test-')).toBe(false); - expect(isSubdomainValid('abc-')).toBe(false); - expect(isSubdomainValid('my-company-')).toBe(false); + expect(isSubdomainValid({ subdomain: 'test-' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'abc-' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'my-company-' })).toBe(false); }); it('should reject subdomains with uppercase letters', () => { - expect(isSubdomainValid('Test')).toBe(false); - expect(isSubdomainValid('MyCompany')).toBe(false); - expect(isSubdomainValid('WORKSPACE')).toBe(false); - expect(isSubdomainValid('test-Company')).toBe(false); + expect(isSubdomainValid({ subdomain: 'Test' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'MyCompany' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'WORKSPACE' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'test-Company' })).toBe(false); }); it('should reject subdomains with special characters', () => { - expect(isSubdomainValid('test@company')).toBe(false); - expect(isSubdomainValid('my_workspace')).toBe(false); - expect(isSubdomainValid('test.company')).toBe(false); - expect(isSubdomainValid('workspace#1')).toBe(false); - expect(isSubdomainValid('test$company')).toBe(false); - expect(isSubdomainValid('my%workspace')).toBe(false); - expect(isSubdomainValid('test&company')).toBe(false); - expect(isSubdomainValid('workspace*1')).toBe(false); - expect(isSubdomainValid('test+company')).toBe(false); - expect(isSubdomainValid('my=workspace')).toBe(false); + expect(isSubdomainValid({ subdomain: 'test@company' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'my_workspace' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'test.company' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'workspace#1' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'test$company' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'my%workspace' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'test&company' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'workspace*1' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'test+company' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'my=workspace' })).toBe(false); }); it('should reject subdomains with spaces', () => { - expect(isSubdomainValid('test company')).toBe(false); - expect(isSubdomainValid('my workspace')).toBe(false); - expect(isSubdomainValid(' test')).toBe(false); - expect(isSubdomainValid('test ')).toBe(false); - expect(isSubdomainValid(' ')).toBe(false); + expect(isSubdomainValid({ subdomain: 'test company' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'my workspace' })).toBe(false); + expect(isSubdomainValid({ subdomain: ' test' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'test ' })).toBe(false); + expect(isSubdomainValid({ subdomain: ' ' })).toBe(false); }); it('should reject subdomains starting with "api-"', () => { - expect(isSubdomainValid('api-test')).toBe(false); - expect(isSubdomainValid('api-company')).toBe(false); - expect(isSubdomainValid('api-workspace')).toBe(false); - expect(isSubdomainValid('api-123')).toBe(false); + expect(isSubdomainValid({ subdomain: 'api-test' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'api-company' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'api-workspace' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'api-123' })).toBe(false); }); it('should reject subdomains with only hyphens', () => { - expect(isSubdomainValid('---')).toBe(false); - expect(isSubdomainValid('----')).toBe(false); + expect(isSubdomainValid({ subdomain: '---' })).toBe(false); + expect(isSubdomainValid({ subdomain: '----' })).toBe(false); }); it('should reject subdomains with numbers only at boundaries but invalid patterns', () => { - expect(isSubdomainValid('1-')).toBe(false); - expect(isSubdomainValid('-1')).toBe(false); + expect(isSubdomainValid({ subdomain: '1-' })).toBe(false); + expect(isSubdomainValid({ subdomain: '-1' })).toBe(false); + }); + }); + + describe('configurable minimum length', () => { + it('should accept subdomains shorter than 3 when a lower minimum is provided', () => { + expect(isSubdomainValid({ subdomain: 'ab', minLength: 2 })).toBe(true); + expect(isSubdomainValid({ subdomain: 'xy', minLength: 1 })).toBe(true); + }); + + it('should still reject subdomains shorter than the provided minimum', () => { + expect(isSubdomainValid({ subdomain: 'ab', minLength: 3 })).toBe(false); + expect(isSubdomainValid({ subdomain: 'abcd', minLength: 5 })).toBe(false); + }); + + it('should reject reserved subdomains even when a lower minimum is provided', () => { + expect(isSubdomainValid({ subdomain: 'us', minLength: 2 })).toBe(false); }); }); describe('reserved subdomains', () => { it('should reject common reserved subdomains', () => { - expect(isSubdomainValid('api')).toBe(false); - expect(isSubdomainValid('www')).toBe(false); - expect(isSubdomainValid('admin')).toBe(false); - expect(isSubdomainValid('dashboard')).toBe(false); - expect(isSubdomainValid('billing')).toBe(false); - expect(isSubdomainValid('support')).toBe(false); + expect(isSubdomainValid({ subdomain: 'api' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'www' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'admin' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'dashboard' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'billing' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'support' })).toBe(false); }); it('should reject technical reserved subdomains', () => { - expect(isSubdomainValid('db')).toBe(false); - expect(isSubdomainValid('cdn')).toBe(false); - expect(isSubdomainValid('storage')).toBe(false); - expect(isSubdomainValid('files')).toBe(false); - expect(isSubdomainValid('media')).toBe(false); - expect(isSubdomainValid('assets')).toBe(false); + expect(isSubdomainValid({ subdomain: 'db' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'cdn' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'storage' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'files' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'media' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'assets' })).toBe(false); }); it('should reject authentication related reserved subdomains', () => { - expect(isSubdomainValid('auth')).toBe(false); - expect(isSubdomainValid('login')).toBe(false); - expect(isSubdomainValid('signin')).toBe(false); - expect(isSubdomainValid('signup')).toBe(false); - expect(isSubdomainValid('register')).toBe(false); + expect(isSubdomainValid({ subdomain: 'auth' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'login' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'signin' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'signup' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'register' })).toBe(false); }); it('should reject business related reserved subdomains', () => { - expect(isSubdomainValid('about')).toBe(false); - expect(isSubdomainValid('contact')).toBe(false); - expect(isSubdomainValid('careers')).toBe(false); - expect(isSubdomainValid('jobs')).toBe(false); - expect(isSubdomainValid('blog')).toBe(false); - expect(isSubdomainValid('news')).toBe(false); + expect(isSubdomainValid({ subdomain: 'about' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'contact' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'careers' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'jobs' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'blog' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'news' })).toBe(false); }); it('should reject country code reserved subdomains', () => { - expect(isSubdomainValid('us')).toBe(false); - expect(isSubdomainValid('uk')).toBe(false); - expect(isSubdomainValid('ca')).toBe(false); - expect(isSubdomainValid('au')).toBe(false); - expect(isSubdomainValid('de')).toBe(false); - expect(isSubdomainValid('fr')).toBe(false); - expect(isSubdomainValid('it')).toBe(false); - expect(isSubdomainValid('es')).toBe(false); + expect(isSubdomainValid({ subdomain: 'us' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'uk' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'ca' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'au' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'de' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'fr' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'it' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'es' })).toBe(false); }); it('should reject geographic reserved subdomains', () => { - expect(isSubdomainValid('europe')).toBe(false); - expect(isSubdomainValid('asia')).toBe(false); - expect(isSubdomainValid('africa')).toBe(false); - expect(isSubdomainValid('america')).toBe(false); - expect(isSubdomainValid('oceania')).toBe(false); - expect(isSubdomainValid('paris')).toBe(false); - expect(isSubdomainValid('london')).toBe(false); - expect(isSubdomainValid('new-york')).toBe(false); + expect(isSubdomainValid({ subdomain: 'europe' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'asia' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'africa' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'america' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'oceania' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'paris' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'london' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'new-york' })).toBe(false); }); it('should reject environment related reserved subdomains', () => { - expect(isSubdomainValid('dev')).toBe(false); - expect(isSubdomainValid('test')).toBe(false); - expect(isSubdomainValid('testing')).toBe(false); - expect(isSubdomainValid('staging')).toBe(false); - expect(isSubdomainValid('production')).toBe(false); + expect(isSubdomainValid({ subdomain: 'dev' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'test' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'testing' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'staging' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'production' })).toBe(false); }); it('should reject reserved subdomains case-insensitively', () => { - expect(isSubdomainValid('API')).toBe(false); - expect(isSubdomainValid('Api')).toBe(false); - expect(isSubdomainValid('WWW')).toBe(false); - expect(isSubdomainValid('Www')).toBe(false); - expect(isSubdomainValid('ADMIN')).toBe(false); - expect(isSubdomainValid('Admin')).toBe(false); - expect(isSubdomainValid('TEST')).toBe(false); - expect(isSubdomainValid('Test')).toBe(false); + expect(isSubdomainValid({ subdomain: 'API' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'Api' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'WWW' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'Www' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'ADMIN' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'Admin' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'TEST' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'Test' })).toBe(false); }); it('should reject all reserved subdomains from the constant', () => { @@ -273,98 +291,98 @@ describe('isSubdomainValid', () => { ]; reservedSubdomains.forEach((subdomain) => { - expect(isSubdomainValid(subdomain)).toBe(false); + expect(isSubdomainValid({ subdomain: subdomain })).toBe(false); }); }); }); describe('edge cases', () => { it('should handle whitespace-only strings', () => { - expect(isSubdomainValid(' ')).toBe(false); - expect(isSubdomainValid('\t')).toBe(false); - expect(isSubdomainValid('\n')).toBe(false); - expect(isSubdomainValid('\r')).toBe(false); + expect(isSubdomainValid({ subdomain: ' ' })).toBe(false); + expect(isSubdomainValid({ subdomain: '\t' })).toBe(false); + expect(isSubdomainValid({ subdomain: '\n' })).toBe(false); + expect(isSubdomainValid({ subdomain: '\r' })).toBe(false); }); it('should handle strings with leading/trailing whitespace', () => { - expect(isSubdomainValid(' test')).toBe(false); - expect(isSubdomainValid('test ')).toBe(false); - expect(isSubdomainValid(' test ')).toBe(false); - expect(isSubdomainValid('\ttest\t')).toBe(false); + expect(isSubdomainValid({ subdomain: ' test' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'test ' })).toBe(false); + expect(isSubdomainValid({ subdomain: ' test ' })).toBe(false); + expect(isSubdomainValid({ subdomain: '\ttest\t' })).toBe(false); }); it('should handle boundary length cases precisely', () => { // Exactly 3 characters (minimum valid) - expect(isSubdomainValid('abc')).toBe(true); + expect(isSubdomainValid({ subdomain: 'abc' })).toBe(true); // Exactly 30 characters (maximum valid) const exactly30Chars = 'a'.repeat(28) + 'bc'; expect(exactly30Chars.length).toBe(30); - expect(isSubdomainValid(exactly30Chars)).toBe(true); + expect(isSubdomainValid({ subdomain: exactly30Chars })).toBe(true); // Exactly 31 characters (first invalid length) const exactly31Chars = 'a'.repeat(29) + 'bc'; expect(exactly31Chars.length).toBe(31); - expect(isSubdomainValid(exactly31Chars)).toBe(false); + expect(isSubdomainValid({ subdomain: exactly31Chars })).toBe(false); }); it('should validate that reserved subdomains check is case insensitive', () => { // Test mixed case variations of reserved subdomains - expect(isSubdomainValid('Trust')).toBe(false); - expect(isSubdomainValid('TRUST')).toBe(false); - expect(isSubdomainValid('tRuSt')).toBe(false); - expect(isSubdomainValid('Demo')).toBe(false); - expect(isSubdomainValid('DEMO')).toBe(false); - expect(isSubdomainValid('dEmO')).toBe(false); + expect(isSubdomainValid({ subdomain: 'Trust' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'TRUST' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'tRuSt' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'Demo' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'DEMO' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'dEmO' })).toBe(false); }); it('should accept valid subdomains that are similar to reserved ones but not exact matches', () => { // 'testing' is reserved, but 'testing123' is not - expect(isSubdomainValid('testing123')).toBe(true); + expect(isSubdomainValid({ subdomain: 'testing123' })).toBe(true); // 'api' is reserved, but 'myapi' is not - expect(isSubdomainValid('myapi')).toBe(true); + expect(isSubdomainValid({ subdomain: 'myapi' })).toBe(true); // 'admin' is reserved, but 'adminpanel' is not - expect(isSubdomainValid('adminpanel')).toBe(true); + expect(isSubdomainValid({ subdomain: 'adminpanel' })).toBe(true); // 'test' is reserved, but 'testapp' is not - expect(isSubdomainValid('testapp')).toBe(true); + expect(isSubdomainValid({ subdomain: 'testapp' })).toBe(true); }); it('should handle Unicode characters', () => { - expect(isSubdomainValid('tëst')).toBe(false); - expect(isSubdomainValid('tést')).toBe(false); - expect(isSubdomainValid('tèst')).toBe(false); - expect(isSubdomainValid('café')).toBe(false); - expect(isSubdomainValid('naïve')).toBe(false); + expect(isSubdomainValid({ subdomain: 'tëst' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'tést' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'tèst' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'café' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'naïve' })).toBe(false); }); it('should handle numeric strings', () => { - expect(isSubdomainValid('123')).toBe(true); - expect(isSubdomainValid('456789')).toBe(true); - expect(isSubdomainValid('1-2-3')).toBe(true); + expect(isSubdomainValid({ subdomain: '123' })).toBe(true); + expect(isSubdomainValid({ subdomain: '456789' })).toBe(true); + expect(isSubdomainValid({ subdomain: '1-2-3' })).toBe(true); }); }); describe('pattern validation specifics', () => { it('should enforce the exact regex pattern requirements', () => { // Test that the pattern requires alphanumeric start and end - expect(isSubdomainValid('a-b')).toBe(true); - expect(isSubdomainValid('1-2')).toBe(true); - expect(isSubdomainValid('test-123')).toBe(true); + expect(isSubdomainValid({ subdomain: 'a-b' })).toBe(true); + expect(isSubdomainValid({ subdomain: '1-2' })).toBe(true); + expect(isSubdomainValid({ subdomain: 'test-123' })).toBe(true); // Test that it rejects patterns not matching the regex - expect(isSubdomainValid('-ab')).toBe(false); - expect(isSubdomainValid('ab-')).toBe(false); + expect(isSubdomainValid({ subdomain: '-ab' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'ab-' })).toBe(false); }); it('should reject api- prefix specifically', () => { - expect(isSubdomainValid('api-anything')).toBe(false); - expect(isSubdomainValid('api-test')).toBe(false); - expect(isSubdomainValid('api-123')).toBe(false); + expect(isSubdomainValid({ subdomain: 'api-anything' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'api-test' })).toBe(false); + expect(isSubdomainValid({ subdomain: 'api-123' })).toBe(false); // But allow 'api' in other positions - expect(isSubdomainValid('myapi')).toBe(true); + expect(isSubdomainValid({ subdomain: 'myapi' })).toBe(true); }); it('should validate length constraints from regex', () => { @@ -372,13 +390,13 @@ describe('isSubdomainValid', () => { // This means: start char + 1-28 middle chars + end char = 3-30 total chars // 3 chars: start + 1 middle + end - expect(isSubdomainValid('abc')).toBe(true); + expect(isSubdomainValid({ subdomain: 'abc' })).toBe(true); // 30 chars: start + 28 middle + end const thirtyChars = 'a' + 'b'.repeat(28) + 'c'; expect(thirtyChars.length).toBe(30); - expect(isSubdomainValid(thirtyChars)).toBe(true); + expect(isSubdomainValid({ subdomain: thirtyChars })).toBe(true); }); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/domain/subdomain-manager/utils/is-subdomain-valid.util.ts b/packages/twenty-server/src/engine/core-modules/domain/subdomain-manager/utils/is-subdomain-valid.util.ts index 4e0c812fc2..4ff4996953 100644 --- a/packages/twenty-server/src/engine/core-modules/domain/subdomain-manager/utils/is-subdomain-valid.util.ts +++ b/packages/twenty-server/src/engine/core-modules/domain/subdomain-manager/utils/is-subdomain-valid.util.ts @@ -1,8 +1,18 @@ -import { RESERVED_SUBDOMAINS } from 'twenty-shared/constants'; +import { + DEFAULT_SUBDOMAIN_MIN_LENGTH, + RESERVED_SUBDOMAINS, +} from 'twenty-shared/constants'; import { isValidTwentySubdomain } from 'twenty-shared/utils'; -export const isSubdomainValid = (subdomain: string) => { +export const isSubdomainValid = ({ + subdomain, + minLength = DEFAULT_SUBDOMAIN_MIN_LENGTH, +}: { + subdomain: string; + minLength?: number; +}) => { return ( + subdomain.length >= minLength && isValidTwentySubdomain(subdomain) && !RESERVED_SUBDOMAINS.includes(subdomain.toLowerCase()) ); diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/__tests__/config-variables.spec.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/__tests__/config-variables.spec.ts new file mode 100644 index 0000000000..35cf5c5560 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/__tests__/config-variables.spec.ts @@ -0,0 +1,29 @@ +import { DEFAULT_SUBDOMAIN_MIN_LENGTH } from 'twenty-shared/constants'; + +import { validate } from 'src/engine/core-modules/twenty-config/config-variables'; + +describe('SUBDOMAIN_MIN_LENGTH config variable', () => { + it('falls back to the shared default when unset', () => { + expect(validate({}).SUBDOMAIN_MIN_LENGTH).toBe( + DEFAULT_SUBDOMAIN_MIN_LENGTH, + ); + }); + + it('accepts an integer within the supported format range', () => { + expect(validate({ SUBDOMAIN_MIN_LENGTH: '2' }).SUBDOMAIN_MIN_LENGTH).toBe( + 2, + ); + }); + + it('rejects non-integer values', () => { + expect(() => validate({ SUBDOMAIN_MIN_LENGTH: '2.5' })).toThrow(); + }); + + it('rejects values below the minimum', () => { + expect(() => validate({ SUBDOMAIN_MIN_LENGTH: '0' })).toThrow(); + }); + + it('rejects values above the subdomain length limit', () => { + expect(() => validate({ SUBDOMAIN_MIN_LENGTH: '31' })).toThrow(); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index a42c2f5790..6c1488950f 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -11,11 +11,14 @@ import { IsOptional, IsString, IsUrl, + Max, + Min, ValidateIf, type ValidationError, validateSync, } from 'class-validator'; import { + DEFAULT_SUBDOMAIN_MIN_LENGTH, ENTERPRISE_INSTANCE_TYPE, type EnterpriseInstanceType, } from 'twenty-shared/constants'; @@ -1079,6 +1082,19 @@ export class ConfigVariables { @ValidateIf((env) => env.IS_MULTIWORKSPACE_ENABLED) DEFAULT_SUBDOMAIN = 'app'; + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.SERVER_CONFIG, + description: + 'Minimum number of characters allowed for a workspace subdomain (between 1 and 30)', + type: ConfigVariableType.NUMBER, + }) + @CastToPositiveNumber() + @IsInt() + @Min(1) + @Max(30) + @IsOptional() + SUBDOMAIN_MIN_LENGTH = DEFAULT_SUBDOMAIN_MIN_LENGTH; + @ConfigVariablesMetadata({ group: ConfigVariablesGroup.ADVANCED_SETTINGS, description: 'Page ID for Cal.com booking integration', diff --git a/packages/twenty-shared/src/constants/SubdomainMinLength.ts b/packages/twenty-shared/src/constants/SubdomainMinLength.ts new file mode 100644 index 0000000000..c61c2e1fed --- /dev/null +++ b/packages/twenty-shared/src/constants/SubdomainMinLength.ts @@ -0,0 +1 @@ +export const DEFAULT_SUBDOMAIN_MIN_LENGTH = 3; diff --git a/packages/twenty-shared/src/constants/SubdomainPattern.ts b/packages/twenty-shared/src/constants/SubdomainPattern.ts index c335c7721b..ec20be8810 100644 --- a/packages/twenty-shared/src/constants/SubdomainPattern.ts +++ b/packages/twenty-shared/src/constants/SubdomainPattern.ts @@ -1,3 +1,4 @@ -// 3–30 chars: lowercase alphanumeric and hyphens, must start and end -// with a letter or number, must not start with "api-" -export const SUBDOMAIN_PATTERN = /^(?!api-)[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/; +// Up to 30 chars: lowercase alphanumeric and hyphens, must start and end +// with a letter or number, must not start with "api-". Minimum length is +// enforced separately by the caller so it can be configurable. +export const SUBDOMAIN_PATTERN = /^(?!api-)[a-z0-9]([a-z0-9-]{0,28}[a-z0-9])?$/; diff --git a/packages/twenty-shared/src/constants/index.ts b/packages/twenty-shared/src/constants/index.ts index f9f65cddd8..fc3fe73b81 100644 --- a/packages/twenty-shared/src/constants/index.ts +++ b/packages/twenty-shared/src/constants/index.ts @@ -61,6 +61,7 @@ export { RATING_VALUES } from './RatingValues'; export { RELATION_NESTED_QUERY_KEYWORDS } from './RelationNestedQueriesKeyword'; export { RESERVED_SUBDOMAINS } from './ReservedSubdomains'; export { STANDARD_OBJECT_RECORDS_UNDER_OBJECT_RECORDS_PERMISSIONS } from './StandardObjectRecordsUnderObjectRecordsPermissions'; +export { DEFAULT_SUBDOMAIN_MIN_LENGTH } from './SubdomainMinLength'; export { SUBDOMAIN_PATTERN } from './SubdomainPattern'; export { SystemPermissionFlag } from './SystemPermissionFlag'; export { TWENTY_COMPANIES_BASE_URL } from './TwentyCompaniesBaseUrl'; diff --git a/packages/twenty-shared/src/utils/validation/__tests__/isValidTwentySubdomain.test.ts b/packages/twenty-shared/src/utils/validation/__tests__/isValidTwentySubdomain.test.ts index 2feef93a26..63a0c50d43 100644 --- a/packages/twenty-shared/src/utils/validation/__tests__/isValidTwentySubdomain.test.ts +++ b/packages/twenty-shared/src/utils/validation/__tests__/isValidTwentySubdomain.test.ts @@ -16,7 +16,10 @@ describe('isValidTwentySubdomain', () => { expect(isValidTwentySubdomain('a-b-c-d-e')).toBe(true); }); - it('should accept minimum length subdomains (3 characters)', () => { + it('should accept short subdomains regardless of minimum length', () => { + // Minimum length is enforced by the caller, not by the pattern + expect(isValidTwentySubdomain('a')).toBe(true); + expect(isValidTwentySubdomain('ab')).toBe(true); expect(isValidTwentySubdomain('abc')).toBe(true); expect(isValidTwentySubdomain('a1b')).toBe(true); expect(isValidTwentySubdomain('a-b')).toBe(true); @@ -41,11 +44,6 @@ describe('isValidTwentySubdomain', () => { expect(isValidTwentySubdomain('')).toBe(false); }); - it('should reject subdomains shorter than 3 characters', () => { - expect(isValidTwentySubdomain('a')).toBe(false); - expect(isValidTwentySubdomain('ab')).toBe(false); - }); - it('should reject subdomains longer than 30 characters', () => { const tooLong = 'a'.repeat(31);