Revert "Make subdomain minimum length configurable via env var" (#23871)

Instead, reduce the subdomain minimum length to 1 char

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23871?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Marie
2026-08-07 12:27:00 +02:00
committed by GitHub
parent 47f82b9121
commit 4dbaafc65d
27 changed files with 214 additions and 312 deletions
@@ -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!
@@ -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
@@ -993,6 +993,7 @@ export type ClientConfig = {
publicFunctionDomain?: Maybe<Scalars['String']['output']>;
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;
};
@@ -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(
() =>
@@ -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);
@@ -25,7 +25,6 @@ export type ClientConfig = {
canManageFeatureFlags: boolean;
captcha: Captcha;
defaultSubdomain?: string;
subdomainMinLength: number;
frontDomain: string;
publicFunctionDomain?: string | null;
isAttachmentPreviewEnabled: boolean;
@@ -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,
},
});
@@ -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);
@@ -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`,
})
@@ -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<PageDecoratorArgs> = {
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<typeof SettingsSubdomainPage>;
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');
@@ -16,7 +16,6 @@ export const mockedClientConfig: ClientConfig = {
},
frontDomain: 'localhost',
defaultSubdomain: 'app',
subdomainMinLength: 3,
analyticsEnabled: true,
support: {
supportDriver: SupportDriver.FRONT,
@@ -68,7 +68,7 @@ describe('ClientConfigController', () => {
isMultiWorkspaceEnabled: true,
isEmailVerificationRequired: false,
defaultSubdomain: 'app',
subdomainMinLength: 3,
subdomainMinLength: 1,
frontDomain: 'localhost',
publicFunctionDomain: null,
support: {
@@ -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)
@@ -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: {
@@ -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() ??
@@ -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'),
},
},
],
@@ -72,15 +72,12 @@ export class SubdomainManagerService {
desired: string,
count: number,
): Promise<string[]> {
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<string[]> {
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<SubdomainAvailabilityDTO> {
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<boolean> {
return (
isSubdomainValid({
subdomain,
minLength: this.twentyConfigService.get('SUBDOMAIN_MIN_LENGTH'),
}) &&
isSubdomainValid(subdomain) &&
this.twentyConfigService.get('DEFAULT_SUBDOMAIN') !== subdomain &&
(await this.isSubdomainAvailable(subdomain))
);
@@ -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);
});
});
});
@@ -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())
);
@@ -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();
});
});
@@ -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',
@@ -1 +0,0 @@
export const DEFAULT_SUBDOMAIN_MIN_LENGTH = 3;
@@ -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])?$/;
@@ -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';
@@ -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', () => {
@@ -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 = (
@@ -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);
});