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
---
.../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);
});