From 4dbaafc65d8ee32ebb346df3cb426fab65467569 Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:27:00 +0200 Subject: [PATCH] Revert "Make subdomain minimum length configurable via env var" (#23871) Instead, reduce the subdomain minimum length to 1 char Review in cubic --- .../src/metadata/generated/schema.graphql | 2 +- .../src/metadata/generated/schema.ts | 2 + .../src/generated-metadata/graphql.ts | 1 + .../hooks/useWorkspaceSubdomainField.ts | 8 +- .../client-config/hooks/useClientConfig.ts | 1 - .../client-config/types/ClientConfig.ts | 1 - .../states/domainConfigurationState.ts | 7 +- .../domains/hooks/useSettingsSubdomain.ts | 5 +- .../utils/getSubdomainValidationSchema.ts | 11 +- .../__stories__/SettingsSubdomain.stories.tsx | 21 +- .../src/testing/mock-data/config.ts | 1 - .../client-config.controller.spec.ts | 2 +- .../client-config/client-config.entity.ts | 5 +- .../services/client-config.service.spec.ts | 3 +- .../services/client-config.service.ts | 6 +- .../subdomain-manager.service.spec.ts | 4 +- .../services/subdomain-manager.service.ts | 26 +- .../__test__/is-subdomain-valid.util.spec.ts | 338 +++++++++--------- .../utils/is-subdomain-valid.util.ts | 14 +- .../__tests__/config-variables.spec.ts | 29 -- .../twenty-config/config-variables.ts | 16 - .../src/constants/SubdomainMinLength.ts | 1 - .../src/constants/SubdomainPattern.ts | 8 +- packages/twenty-shared/src/constants/index.ts | 1 - .../getSubdomainSlugFromDisplayName.test.ts | 5 +- .../getSubdomainSlugFromDisplayName.ts | 2 +- .../__tests__/isValidTwentySubdomain.test.ts | 6 +- 27 files changed, 214 insertions(+), 312 deletions(-) delete mode 100644 packages/twenty-server/src/engine/core-modules/twenty-config/__tests__/config-variables.spec.ts delete mode 100644 packages/twenty-shared/src/constants/SubdomainMinLength.ts diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index dfb1619cca..c8030867db 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -1989,7 +1989,7 @@ type ClientConfig { isMultiWorkspaceEnabled: Boolean! isEmailVerificationRequired: Boolean! defaultSubdomain: String - subdomainMinLength: Float! + subdomainMinLength: Float! @deprecated(reason: "Subdomain minimum length is no longer configurable; kept one release for API compatibility, no longer read by the frontend.") 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 ee0c06b699..4498a40996 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -1626,6 +1626,7 @@ export interface ClientConfig { isMultiWorkspaceEnabled: Scalars['Boolean'] isEmailVerificationRequired: Scalars['Boolean'] defaultSubdomain?: Scalars['String'] + /** @deprecated Subdomain minimum length is no longer configurable; kept one release for API compatibility, no longer read by the frontend. */ subdomainMinLength: Scalars['Float'] frontDomain: Scalars['String'] publicFunctionDomain?: Scalars['String'] @@ -4895,6 +4896,7 @@ export interface ClientConfigGenqlSelection{ isMultiWorkspaceEnabled?: boolean | number isEmailVerificationRequired?: boolean | number defaultSubdomain?: boolean | number + /** @deprecated Subdomain minimum length is no longer configurable; kept one release for API compatibility, no longer read by the frontend. */ subdomainMinLength?: boolean | number frontDomain?: boolean | number publicFunctionDomain?: boolean | number diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 9023cebc88..c0dadf3e41 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -993,6 +993,7 @@ export type ClientConfig = { publicFunctionDomain?: Maybe; sentry: Sentry; signInPrefilled: Scalars['Boolean']['output']; + /** @deprecated Subdomain minimum length is no longer configurable; kept one release for API compatibility, no longer read by the frontend. */ 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 69867a111c..71076e5891 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,6 +1,4 @@ -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 { @@ -27,11 +25,7 @@ export const useWorkspaceSubdomainField = ({ isSubdomainEnabled = true, }: { isSubdomainEnabled?: boolean } = {}) => { const apolloClient = useApolloClient(); - const { subdomainMinLength } = useAtomStateValue(domainConfigurationState); - const subdomainSchema = useMemo( - () => getSubdomainValidationSchema(subdomainMinLength), - [subdomainMinLength], - ); + const subdomainSchema = getSubdomainValidationSchema(); 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 81ca6dfdda..93e5b21b4b 100644 --- a/packages/twenty-front/src/modules/client-config/hooks/useClientConfig.ts +++ b/packages/twenty-front/src/modules/client-config/hooks/useClientConfig.ts @@ -208,7 +208,6 @@ 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 701a410f14..e1ec32e44c 100644 --- a/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts +++ b/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts @@ -25,7 +25,6 @@ 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 9c181a3efc..38eb1823cc 100644 --- a/packages/twenty-front/src/modules/domain-manager/states/domainConfigurationState.ts +++ b/packages/twenty-front/src/modules/domain-manager/states/domainConfigurationState.ts @@ -1,14 +1,10 @@ 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' - | 'subdomainMinLength' + 'frontDomain' | 'defaultSubdomain' | 'publicFunctionDomain' > >({ key: 'domainConfiguration', @@ -16,6 +12,5 @@ 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 49af783539..3bc1ddb131 100644 --- a/packages/twenty-front/src/modules/settings/domains/hooks/useSettingsSubdomain.ts +++ b/packages/twenty-front/src/modules/settings/domains/hooks/useSettingsSubdomain.ts @@ -1,11 +1,9 @@ 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'; @@ -18,8 +16,7 @@ export const SUBDOMAIN_CHANGE_CONFIRMATION_MODAL_ID = export const useSettingsSubdomain = () => { const { t } = useLingui(); - const { subdomainMinLength } = useAtomStateValue(domainConfigurationState); - const subdomainSchema = getSubdomainValidationSchema(subdomainMinLength); + const subdomainSchema = getSubdomainValidationSchema(); 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 95441a2306..3b1d7b0bc3 100644 --- a/packages/twenty-front/src/modules/settings/domains/utils/getSubdomainValidationSchema.ts +++ b/packages/twenty-front/src/modules/settings/domains/utils/getSubdomainValidationSchema.ts @@ -1,20 +1,15 @@ 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 = ( - minLength = DEFAULT_SUBDOMAIN_MIN_LENGTH, -) => +export const getSubdomainValidationSchema = () => z .string() - .min(minLength, { - message: t`Subdomain cannot be shorter than ${minLength} characters`, - }) - .max(30, { message: t`Subdomain can not be longer than 30 characters` }) + .min(1, { message: t`Subdomain cannot be empty` }) + .max(30, { message: t`Subdomain cannot 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 2ff657a340..b647ac6c61 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 @@ -1,11 +1,14 @@ import { type Meta, type StoryObj } from '@storybook/react-vite'; import { expect, userEvent, within } from 'storybook/test'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore'; import { PageDecorator, type PageDecoratorArgs, } from '~/testing/decorators/PageDecorator'; import { graphqlMocks } from '~/testing/graphqlMocks'; +import { mockCurrentWorkspace } from '~/testing/mock-data/users'; import { SettingsSubdomainPage } from '~/pages/settings/domains/SettingsSubdomainPage'; @@ -14,6 +17,13 @@ const meta: Meta = { component: SettingsSubdomainPage, decorators: [PageDecorator], args: { routePath: '/settings/domains/subdomain' }, + beforeEach: () => { + jotaiStore.set(currentWorkspaceState.atom, mockCurrentWorkspace); + + return () => { + jotaiStore.set(currentWorkspaceState.atom, null); + }; + }, parameters: { msw: graphqlMocks, }, @@ -25,20 +35,17 @@ export type Story = StoryObj; export const Default: Story = {}; -export const TooShortSubdomain: Story = { +export const EmptySubdomain: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); const input = await canvas.findByRole('textbox', {}, { timeout: 5000 }); + await expect(input).toHaveValue(mockCurrentWorkspace.subdomain); + await userEvent.clear(input); - await userEvent.type(input, 'ab'); - const errorMessage = await canvas.findByText( - 'Subdomain cannot be shorter than 3 characters', - ); - - await expect(errorMessage).toBeVisible(); + await canvas.findByText('Subdomain cannot be empty'); const saveButton = canvas.getByText('Save'); diff --git a/packages/twenty-front/src/testing/mock-data/config.ts b/packages/twenty-front/src/testing/mock-data/config.ts index 92871293d1..604c203345 100644 --- a/packages/twenty-front/src/testing/mock-data/config.ts +++ b/packages/twenty-front/src/testing/mock-data/config.ts @@ -16,7 +16,6 @@ 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 44ef93dfb6..8bf5cf617b 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,7 +68,7 @@ describe('ClientConfigController', () => { isMultiWorkspaceEnabled: true, isEmailVerificationRequired: false, defaultSubdomain: 'app', - subdomainMinLength: 3, + subdomainMinLength: 1, 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 a1409f8345..1518a83b5a 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,7 +277,10 @@ export class ClientConfig { @Field(() => String, { nullable: true }) defaultSubdomain: string; - @Field(() => Number) + @Field(() => Number, { + deprecationReason: + 'Subdomain minimum length is no longer configurable; kept one release for API compatibility, no longer read by the frontend.', + }) subdomainMinLength: number; @Field(() => 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 6a2290f1c8..5cdc2540a7 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,7 +84,6 @@ 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', @@ -155,7 +154,7 @@ describe('ClientConfigService', () => { isMultiWorkspaceEnabled: true, isEmailVerificationRequired: true, defaultSubdomain: 'app', - subdomainMinLength: 3, + subdomainMinLength: 1, 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 a8f29a29f6..9a8fbd5a1e 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 @@ -27,6 +27,10 @@ import { MODEL_FAMILY_LABELS } from 'src/engine/metadata-modules/ai/ai-models/co import { getNativeModelCapabilities } from 'src/engine/metadata-modules/ai/ai-models/utils/get-native-model-capabilities.util'; import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; +// Served only so front bundles cached from the previous release keep booting; the +// subdomain minimum is now fixed at 1 and nothing reads this value anymore. +const DEPRECATED_SUBDOMAIN_MIN_LENGTH = 1; + @Injectable() export class ClientConfigService { constructor( @@ -206,7 +210,7 @@ export class ClientConfigService { 'IS_EMAIL_VERIFICATION_REQUIRED', ), defaultSubdomain: this.twentyConfigService.get('DEFAULT_SUBDOMAIN'), - subdomainMinLength: this.twentyConfigService.get('SUBDOMAIN_MIN_LENGTH'), + subdomainMinLength: DEPRECATED_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 f527c68aea..c1fedd8536 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,9 +44,7 @@ describe('SubdomainManagerService', () => { { provide: TwentyConfigService, useValue: { - get: jest.fn((key: string) => - key === 'SUBDOMAIN_MIN_LENGTH' ? 3 : 'app', - ), + get: jest.fn().mockReturnValue('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 506a42cd41..c459c89f8f 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,15 +72,12 @@ export class SubdomainManagerService { desired: string, count: number, ): Promise { - const minLength = this.twentyConfigService.get('SUBDOMAIN_MIN_LENGTH'); - - const derivedBase = isSubdomainValid({ subdomain: desired, minLength }) + const derivedBase = isSubdomainValid(desired) ? desired : getSubdomainSlugFromDisplayName(desired); const base = - isDefined(derivedBase) && - isSubdomainValid({ subdomain: derivedBase, minLength }) + isDefined(derivedBase) && isSubdomainValid(derivedBase) ? derivedBase : generateRandomSubdomain(); @@ -114,12 +111,10 @@ 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({ subdomain: candidate, minLength }) && - candidate !== defaultSubdomain, + isSubdomainValid(candidate) && candidate !== defaultSubdomain, ); if (validCandidates.length === 0) { @@ -144,10 +139,7 @@ export class SubdomainManagerService { async getSubdomainAvailability( subdomain: string, ): Promise { - const isValid = isSubdomainValid({ - subdomain, - minLength: this.twentyConfigService.get('SUBDOMAIN_MIN_LENGTH'), - }); + const isValid = isSubdomainValid(subdomain); const available = isValid && (await this.isSubdomainFreeToUse(subdomain)); // Autofill adopts the first suggestion directly, so never echo an invalid @@ -177,10 +169,7 @@ export class SubdomainManagerService { } async validateSubdomainOrThrow(subdomain: string) { - const isValid = isSubdomainValid({ - subdomain, - minLength: this.twentyConfigService.get('SUBDOMAIN_MIN_LENGTH'), - }); + const isValid = isSubdomainValid(subdomain); if (!isValid) { throw new WorkspaceException( @@ -204,10 +193,7 @@ export class SubdomainManagerService { private async isSubdomainFreeToUse(subdomain: string): Promise { return ( - isSubdomainValid({ - subdomain, - minLength: this.twentyConfigService.get('SUBDOMAIN_MIN_LENGTH'), - }) && + isSubdomainValid(subdomain) && 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 6d115e4d2d..604b99707b 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,222 +3,198 @@ import { isSubdomainValid } from 'src/engine/core-modules/domain/subdomain-manag describe('isSubdomainValid', () => { describe('valid subdomains', () => { it('should accept valid alphanumeric subdomains', () => { - expect(isSubdomainValid({ subdomain: 'abc' })).toBe(true); - expect(isSubdomainValid({ subdomain: 'test123' })).toBe(true); - expect(isSubdomainValid({ subdomain: 'company1' })).toBe(true); - expect(isSubdomainValid({ subdomain: 'workspace2024' })).toBe(true); + expect(isSubdomainValid('abc')).toBe(true); + expect(isSubdomainValid('test123')).toBe(true); + expect(isSubdomainValid('company1')).toBe(true); + expect(isSubdomainValid('workspace2024')).toBe(true); }); it('should accept subdomains with hyphens in the middle', () => { - 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); + 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); }); it('should accept subdomains with mixed alphanumeric and hyphens', () => { - 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); + 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); }); - it('should accept minimum length subdomains (3 characters)', () => { - expect(isSubdomainValid({ subdomain: 'abc' })).toBe(true); - expect(isSubdomainValid({ subdomain: 'a1b' })).toBe(true); - expect(isSubdomainValid({ subdomain: 'x-y' })).toBe(true); + it('should accept minimum length subdomains (1 character)', () => { + expect(isSubdomainValid('a')).toBe(true); + expect(isSubdomainValid('1')).toBe(true); + expect(isSubdomainValid('ab')).toBe(true); + expect(isSubdomainValid('x-y')).toBe(true); }); it('should accept maximum length subdomains (30 characters)', () => { const maxLengthSubdomain = 'a'.repeat(28) + 'bc'; // 30 characters total - expect(isSubdomainValid({ subdomain: maxLengthSubdomain })).toBe(true); + expect(isSubdomainValid(maxLengthSubdomain)).toBe(true); const maxLengthWithHyphens = 'a' + '-'.repeat(28) + 'b'; // 30 characters with hyphens - expect(isSubdomainValid({ subdomain: maxLengthWithHyphens })).toBe(true); + expect(isSubdomainValid(maxLengthWithHyphens)).toBe(true); }); it('should accept subdomains starting and ending with alphanumeric characters', () => { - expect(isSubdomainValid({ subdomain: 'a-b' })).toBe(true); - expect(isSubdomainValid({ subdomain: '1-test-2' })).toBe(true); - expect(isSubdomainValid({ subdomain: 'start-middle-end' })).toBe(true); + expect(isSubdomainValid('a-b')).toBe(true); + expect(isSubdomainValid('1-test-2')).toBe(true); + expect(isSubdomainValid('start-middle-end')).toBe(true); }); }); describe('invalid subdomain patterns', () => { it('should reject empty strings', () => { - expect(isSubdomainValid({ subdomain: '' })).toBe(false); - }); - - it('should reject subdomains that are too short (less than 3 characters)', () => { - expect(isSubdomainValid({ subdomain: 'a' })).toBe(false); - expect(isSubdomainValid({ subdomain: 'ab' })).toBe(false); - expect(isSubdomainValid({ subdomain: '1' })).toBe(false); - expect(isSubdomainValid({ subdomain: '12' })).toBe(false); + expect(isSubdomainValid('')).toBe(false); }); it('should reject subdomains that are too long (more than 30 characters)', () => { const tooLongSubdomain = 'a'.repeat(31); - expect(isSubdomainValid({ subdomain: tooLongSubdomain })).toBe(false); + expect(isSubdomainValid(tooLongSubdomain)).toBe(false); const wayTooLongSubdomain = 'a'.repeat(50); - expect(isSubdomainValid({ subdomain: wayTooLongSubdomain })).toBe(false); + expect(isSubdomainValid(wayTooLongSubdomain)).toBe(false); }); it('should reject subdomains starting with hyphens', () => { - expect(isSubdomainValid({ subdomain: '-test' })).toBe(false); - expect(isSubdomainValid({ subdomain: '-abc' })).toBe(false); - expect(isSubdomainValid({ subdomain: '-my-company' })).toBe(false); + expect(isSubdomainValid('-test')).toBe(false); + expect(isSubdomainValid('-abc')).toBe(false); + expect(isSubdomainValid('-my-company')).toBe(false); }); it('should reject subdomains ending with hyphens', () => { - expect(isSubdomainValid({ subdomain: 'test-' })).toBe(false); - expect(isSubdomainValid({ subdomain: 'abc-' })).toBe(false); - expect(isSubdomainValid({ subdomain: 'my-company-' })).toBe(false); + expect(isSubdomainValid('test-')).toBe(false); + expect(isSubdomainValid('abc-')).toBe(false); + expect(isSubdomainValid('my-company-')).toBe(false); }); it('should reject subdomains with uppercase letters', () => { - 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); + expect(isSubdomainValid('Test')).toBe(false); + expect(isSubdomainValid('MyCompany')).toBe(false); + expect(isSubdomainValid('WORKSPACE')).toBe(false); + expect(isSubdomainValid('test-Company')).toBe(false); }); it('should reject subdomains with special characters', () => { - 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); + 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); }); it('should reject subdomains with spaces', () => { - 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); + 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); }); it('should reject subdomains starting with "api-"', () => { - 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); + expect(isSubdomainValid('api-test')).toBe(false); + expect(isSubdomainValid('api-company')).toBe(false); + expect(isSubdomainValid('api-workspace')).toBe(false); + expect(isSubdomainValid('api-123')).toBe(false); }); it('should reject subdomains with only hyphens', () => { - expect(isSubdomainValid({ subdomain: '---' })).toBe(false); - expect(isSubdomainValid({ subdomain: '----' })).toBe(false); + expect(isSubdomainValid('---')).toBe(false); + expect(isSubdomainValid('----')).toBe(false); }); it('should reject subdomains with numbers only at boundaries but invalid patterns', () => { - 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); + expect(isSubdomainValid('1-')).toBe(false); + expect(isSubdomainValid('-1')).toBe(false); }); }); describe('reserved subdomains', () => { it('should reject common reserved subdomains', () => { - 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); + 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); }); it('should reject technical reserved subdomains', () => { - 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); + 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); }); it('should reject authentication related reserved subdomains', () => { - 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); + 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); }); it('should reject business related reserved subdomains', () => { - 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); + 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); }); it('should reject country code reserved subdomains', () => { - 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); + 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); }); it('should reject geographic reserved subdomains', () => { - 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); + 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); }); it('should reject environment related reserved subdomains', () => { - 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); + 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); }); it('should reject reserved subdomains case-insensitively', () => { - 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); + 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); }); it('should reject all reserved subdomains from the constant', () => { @@ -291,112 +267,118 @@ describe('isSubdomainValid', () => { ]; reservedSubdomains.forEach((subdomain) => { - expect(isSubdomainValid({ subdomain: subdomain })).toBe(false); + expect(isSubdomainValid(subdomain)).toBe(false); }); }); }); describe('edge cases', () => { it('should handle whitespace-only strings', () => { - expect(isSubdomainValid({ subdomain: ' ' })).toBe(false); - expect(isSubdomainValid({ subdomain: '\t' })).toBe(false); - expect(isSubdomainValid({ subdomain: '\n' })).toBe(false); - expect(isSubdomainValid({ subdomain: '\r' })).toBe(false); + expect(isSubdomainValid(' ')).toBe(false); + expect(isSubdomainValid('\t')).toBe(false); + expect(isSubdomainValid('\n')).toBe(false); + expect(isSubdomainValid('\r')).toBe(false); }); it('should handle strings with leading/trailing whitespace', () => { - 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); + expect(isSubdomainValid(' test')).toBe(false); + expect(isSubdomainValid('test ')).toBe(false); + expect(isSubdomainValid(' test ')).toBe(false); + expect(isSubdomainValid('\ttest\t')).toBe(false); }); it('should handle boundary length cases precisely', () => { - // Exactly 3 characters (minimum valid) - expect(isSubdomainValid({ subdomain: 'abc' })).toBe(true); + // Exactly 1 character (minimum valid) + expect(isSubdomainValid('a')).toBe(true); + + // Exactly 2 characters + expect(isSubdomainValid('ab')).toBe(true); // Exactly 30 characters (maximum valid) const exactly30Chars = 'a'.repeat(28) + 'bc'; expect(exactly30Chars.length).toBe(30); - expect(isSubdomainValid({ subdomain: exactly30Chars })).toBe(true); + expect(isSubdomainValid(exactly30Chars)).toBe(true); // Exactly 31 characters (first invalid length) const exactly31Chars = 'a'.repeat(29) + 'bc'; expect(exactly31Chars.length).toBe(31); - expect(isSubdomainValid({ subdomain: exactly31Chars })).toBe(false); + expect(isSubdomainValid(exactly31Chars)).toBe(false); }); it('should validate that reserved subdomains check is case insensitive', () => { // Test mixed case variations of reserved subdomains - 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); + 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); }); it('should accept valid subdomains that are similar to reserved ones but not exact matches', () => { // 'testing' is reserved, but 'testing123' is not - expect(isSubdomainValid({ subdomain: 'testing123' })).toBe(true); + expect(isSubdomainValid('testing123')).toBe(true); // 'api' is reserved, but 'myapi' is not - expect(isSubdomainValid({ subdomain: 'myapi' })).toBe(true); + expect(isSubdomainValid('myapi')).toBe(true); // 'admin' is reserved, but 'adminpanel' is not - expect(isSubdomainValid({ subdomain: 'adminpanel' })).toBe(true); + expect(isSubdomainValid('adminpanel')).toBe(true); // 'test' is reserved, but 'testapp' is not - expect(isSubdomainValid({ subdomain: 'testapp' })).toBe(true); + expect(isSubdomainValid('testapp')).toBe(true); }); it('should handle Unicode characters', () => { - 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); + 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); }); it('should handle numeric strings', () => { - expect(isSubdomainValid({ subdomain: '123' })).toBe(true); - expect(isSubdomainValid({ subdomain: '456789' })).toBe(true); - expect(isSubdomainValid({ subdomain: '1-2-3' })).toBe(true); + expect(isSubdomainValid('123')).toBe(true); + expect(isSubdomainValid('456789')).toBe(true); + expect(isSubdomainValid('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({ subdomain: 'a-b' })).toBe(true); - expect(isSubdomainValid({ subdomain: '1-2' })).toBe(true); - expect(isSubdomainValid({ subdomain: 'test-123' })).toBe(true); + expect(isSubdomainValid('a-b')).toBe(true); + expect(isSubdomainValid('1-2')).toBe(true); + expect(isSubdomainValid('test-123')).toBe(true); // Test that it rejects patterns not matching the regex - expect(isSubdomainValid({ subdomain: '-ab' })).toBe(false); - expect(isSubdomainValid({ subdomain: 'ab-' })).toBe(false); + expect(isSubdomainValid('-ab')).toBe(false); + expect(isSubdomainValid('ab-')).toBe(false); }); it('should reject api- prefix specifically', () => { - expect(isSubdomainValid({ subdomain: 'api-anything' })).toBe(false); - expect(isSubdomainValid({ subdomain: 'api-test' })).toBe(false); - expect(isSubdomainValid({ subdomain: 'api-123' })).toBe(false); + expect(isSubdomainValid('api-anything')).toBe(false); + expect(isSubdomainValid('api-test')).toBe(false); + expect(isSubdomainValid('api-123')).toBe(false); // But allow 'api' in other positions - expect(isSubdomainValid({ subdomain: 'myapi' })).toBe(true); + expect(isSubdomainValid('myapi')).toBe(true); }); it('should validate length constraints from regex', () => { - // The regex pattern is: /^(?!api-).*^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$/ - // This means: start char + 1-28 middle chars + end char = 3-30 total chars + // The regex pattern is: /^(?!api-)[a-z0-9](?:[a-z0-9-]{0,28}[a-z0-9])?$/ + // This means: start char + optional (0-28 middle chars + end char) = 1-30 total chars + + // 1 char: start only + expect(isSubdomainValid('a')).toBe(true); // 3 chars: start + 1 middle + end - expect(isSubdomainValid({ subdomain: 'abc' })).toBe(true); + expect(isSubdomainValid('abc')).toBe(true); // 30 chars: start + 28 middle + end const thirtyChars = 'a' + 'b'.repeat(28) + 'c'; expect(thirtyChars.length).toBe(30); - expect(isSubdomainValid({ subdomain: thirtyChars })).toBe(true); + expect(isSubdomainValid(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 4ff4996953..4e0c812fc2 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,18 +1,8 @@ -import { - DEFAULT_SUBDOMAIN_MIN_LENGTH, - RESERVED_SUBDOMAINS, -} from 'twenty-shared/constants'; +import { RESERVED_SUBDOMAINS } from 'twenty-shared/constants'; import { isValidTwentySubdomain } from 'twenty-shared/utils'; -export const isSubdomainValid = ({ - subdomain, - minLength = DEFAULT_SUBDOMAIN_MIN_LENGTH, -}: { - subdomain: string; - minLength?: number; -}) => { +export const isSubdomainValid = (subdomain: string) => { 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 deleted file mode 100644 index 35cf5c5560..0000000000 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/__tests__/config-variables.spec.ts +++ /dev/null @@ -1,29 +0,0 @@ -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 6c1488950f..a42c2f5790 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,14 +11,11 @@ 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'; @@ -1082,19 +1079,6 @@ 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 deleted file mode 100644 index c61c2e1fed..0000000000 --- a/packages/twenty-shared/src/constants/SubdomainMinLength.ts +++ /dev/null @@ -1 +0,0 @@ -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 ec20be8810..e2c3253c10 100644 --- a/packages/twenty-shared/src/constants/SubdomainPattern.ts +++ b/packages/twenty-shared/src/constants/SubdomainPattern.ts @@ -1,4 +1,4 @@ -// 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])?$/; +// 1-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-]{0,28}[a-z0-9])?$/; diff --git a/packages/twenty-shared/src/constants/index.ts b/packages/twenty-shared/src/constants/index.ts index fc3fe73b81..f9f65cddd8 100644 --- a/packages/twenty-shared/src/constants/index.ts +++ b/packages/twenty-shared/src/constants/index.ts @@ -61,7 +61,6 @@ 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/subdomain/__tests__/getSubdomainSlugFromDisplayName.test.ts b/packages/twenty-shared/src/utils/subdomain/__tests__/getSubdomainSlugFromDisplayName.test.ts index b84f55d8ad..25c9b23a39 100644 --- a/packages/twenty-shared/src/utils/subdomain/__tests__/getSubdomainSlugFromDisplayName.test.ts +++ b/packages/twenty-shared/src/utils/subdomain/__tests__/getSubdomainSlugFromDisplayName.test.ts @@ -41,8 +41,9 @@ describe('getSubdomainSlugFromDisplayName', () => { expect(getSubdomainSlugFromDisplayName('日本語')).toBe('ri-ben-yu'); }); - it('should return undefined when the result is shorter than the minimum length', () => { - expect(getSubdomainSlugFromDisplayName('AB')).toBeUndefined(); + it('should accept results as short as a single character', () => { + expect(getSubdomainSlugFromDisplayName('AB')).toBe('ab'); + expect(getSubdomainSlugFromDisplayName('A')).toBe('a'); }); it('should clamp long names to the maximum length without a trailing hyphen', () => { diff --git a/packages/twenty-shared/src/utils/subdomain/getSubdomainSlugFromDisplayName.ts b/packages/twenty-shared/src/utils/subdomain/getSubdomainSlugFromDisplayName.ts index d4206cdc39..e91eaa686b 100644 --- a/packages/twenty-shared/src/utils/subdomain/getSubdomainSlugFromDisplayName.ts +++ b/packages/twenty-shared/src/utils/subdomain/getSubdomainSlugFromDisplayName.ts @@ -2,7 +2,7 @@ import { slugify } from 'transliteration'; import { isDefined } from '@/utils/validation/isDefined'; -const SUBDOMAIN_MIN_LENGTH = 3; +const SUBDOMAIN_MIN_LENGTH = 1; const SUBDOMAIN_MAX_LENGTH = 30; export const getSubdomainSlugFromDisplayName = ( 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 63a0c50d43..655e780187 100644 --- a/packages/twenty-shared/src/utils/validation/__tests__/isValidTwentySubdomain.test.ts +++ b/packages/twenty-shared/src/utils/validation/__tests__/isValidTwentySubdomain.test.ts @@ -16,12 +16,10 @@ describe('isValidTwentySubdomain', () => { expect(isValidTwentySubdomain('a-b-c-d-e')).toBe(true); }); - it('should accept short subdomains regardless of minimum length', () => { - // Minimum length is enforced by the caller, not by the pattern + it('should accept minimum length subdomains (1 character)', () => { expect(isValidTwentySubdomain('a')).toBe(true); + expect(isValidTwentySubdomain('1')).toBe(true); expect(isValidTwentySubdomain('ab')).toBe(true); - expect(isValidTwentySubdomain('abc')).toBe(true); - expect(isValidTwentySubdomain('a1b')).toBe(true); expect(isValidTwentySubdomain('a-b')).toBe(true); });