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
@@ -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',