feat(custom-domains): allow to register a custom domain (without UI) (#9879)
# In this PR - Allow to register a custom domain - Refacto subdomain generation # In other PRs - Add UI to deal with a custom domain - Add logic to work with custom domain
This commit is contained in:
@ -7,5 +7,7 @@ export class DomainManagerException extends CustomException {
|
||||
}
|
||||
|
||||
export enum DomainManagerExceptionCode {
|
||||
CLOUDFLARE_CLIENT_NOT_INITIALIZED = 'CLOUDFLARE_CLIENT_NOT_INITIALIZED',
|
||||
HOSTNAME_ALREADY_REGISTERED = 'HOSTNAME_ALREADY_REGISTERED',
|
||||
SUBDOMAIN_REQUIRED = 'SUBDOMAIN_REQUIRED',
|
||||
}
|
||||
|
||||
@ -0,0 +1,75 @@
|
||||
import { createUnionType, Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
class CustomHostnameOwnershipVerificationTxt {
|
||||
@Field(() => String)
|
||||
type: 'txt';
|
||||
|
||||
@Field(() => String)
|
||||
name: string;
|
||||
|
||||
@Field(() => String)
|
||||
value: string;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
class CustomHostnameOwnershipVerificationHttp {
|
||||
@Field()
|
||||
type: 'http';
|
||||
|
||||
@Field(() => String)
|
||||
body: string;
|
||||
|
||||
@Field(() => String)
|
||||
url: string;
|
||||
}
|
||||
|
||||
const CustomHostnameOwnershipVerification = createUnionType({
|
||||
name: 'OwnershipVerification',
|
||||
types: () =>
|
||||
[
|
||||
CustomHostnameOwnershipVerificationTxt,
|
||||
CustomHostnameOwnershipVerificationHttp,
|
||||
] as const,
|
||||
resolveType(value) {
|
||||
if ('type' in value && value.type === 'txt') {
|
||||
return CustomHostnameOwnershipVerificationTxt;
|
||||
}
|
||||
if ('type' in value && value.type === 'http') {
|
||||
return CustomHostnameOwnershipVerificationHttp;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
@ObjectType()
|
||||
export class CustomHostnameDetails {
|
||||
@Field(() => String)
|
||||
id: string;
|
||||
|
||||
@Field(() => String)
|
||||
hostname: string;
|
||||
|
||||
@Field(() => [CustomHostnameOwnershipVerification])
|
||||
ownershipVerifications: Array<typeof CustomHostnameOwnershipVerification>;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
status?:
|
||||
| 'active'
|
||||
| 'pending'
|
||||
| 'active_redeploying'
|
||||
| 'moved'
|
||||
| 'pending_deletion'
|
||||
| 'deleted'
|
||||
| 'pending_blocked'
|
||||
| 'pending_migration'
|
||||
| 'pending_provisioned'
|
||||
| 'test_pending'
|
||||
| 'test_active'
|
||||
| 'test_active_apex'
|
||||
| 'test_blocked'
|
||||
| 'test_failed'
|
||||
| 'provisioned'
|
||||
| 'blocked';
|
||||
}
|
||||
@ -2,24 +2,36 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
import Cloudflare from 'cloudflare';
|
||||
|
||||
import { EnvironmentService } from 'src/engine/core-modules/environment/environment.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import {
|
||||
WorkspaceException,
|
||||
WorkspaceExceptionCode,
|
||||
} from 'src/engine/core-modules/workspace/workspace.exception';
|
||||
import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email';
|
||||
import { isDefined } from 'src/utils/is-defined';
|
||||
import { isWorkEmail } from 'src/utils/is-work-email';
|
||||
import { domainManagerValidator } from 'src/engine/core-modules/domain-manager/validator/cloudflare.validate';
|
||||
import {
|
||||
DomainManagerException,
|
||||
DomainManagerExceptionCode,
|
||||
} from 'src/engine/core-modules/domain-manager/domain-manager.exception';
|
||||
import { generateRandomSubdomain } from 'src/engine/core-modules/domain-manager/utils/generate-random-subdomain';
|
||||
import { getSubdomainNameFromDisplayName } from 'src/engine/core-modules/domain-manager/utils/get-subdomain-name-from-display-name';
|
||||
import { getSubdomainFromEmail } from 'src/engine/core-modules/domain-manager/utils/get-subdomain-from-email';
|
||||
import { CustomHostnameDetails } from 'src/engine/core-modules/domain-manager/dtos/custom-hostname-details';
|
||||
|
||||
@Injectable()
|
||||
export class DomainManagerService {
|
||||
cloudflareClient?: Cloudflare;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Workspace, 'core')
|
||||
private readonly workspaceRepository: Repository<Workspace>,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
) {}
|
||||
) {
|
||||
if (this.environmentService.get('CLOUDFLARE_API_KEY')) {
|
||||
this.cloudflareClient = new Cloudflare({
|
||||
apiToken: this.environmentService.get('CLOUDFLARE_API_KEY'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getFrontUrl() {
|
||||
let baseUrl: URL;
|
||||
@ -105,6 +117,7 @@ export class DomainManagerService {
|
||||
return url;
|
||||
}
|
||||
|
||||
// @Deprecated
|
||||
getWorkspaceSubdomainFromUrl = (url: string) => {
|
||||
const { hostname: originHostname } = new URL(url);
|
||||
|
||||
@ -119,6 +132,24 @@ export class DomainManagerService {
|
||||
return this.isDefaultSubdomain(subdomain) ? null : subdomain;
|
||||
};
|
||||
|
||||
getSubdomainAndHostnameFromUrl = (url: string) => {
|
||||
const { hostname: originHostname } = new URL(url);
|
||||
|
||||
const frontDomain = this.getFrontUrl().hostname;
|
||||
|
||||
const isFrontdomain = originHostname.endsWith(`.${frontDomain}`);
|
||||
|
||||
const subdomain = originHostname.replace(`.${frontDomain}`, '');
|
||||
|
||||
return {
|
||||
subdomain:
|
||||
isFrontdomain && !this.isDefaultSubdomain(subdomain)
|
||||
? subdomain
|
||||
: undefined,
|
||||
hostname: isFrontdomain ? undefined : originHostname,
|
||||
};
|
||||
};
|
||||
|
||||
async getWorkspaceBySubdomainOrDefaultWorkspace(subdomain?: string) {
|
||||
return subdomain
|
||||
? await this.workspaceRepository.findOne({
|
||||
@ -180,121 +211,37 @@ export class DomainManagerService {
|
||||
}
|
||||
|
||||
async getWorkspaceByOriginOrDefaultWorkspace(origin: string) {
|
||||
try {
|
||||
if (!this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')) {
|
||||
return this.getDefaultWorkspace();
|
||||
}
|
||||
|
||||
const subdomain = this.getWorkspaceSubdomainFromUrl(origin);
|
||||
|
||||
if (!isDefined(subdomain)) return;
|
||||
|
||||
return (
|
||||
(await this.workspaceRepository.findOne({
|
||||
where: { subdomain },
|
||||
relations: ['workspaceSSOIdentityProviders'],
|
||||
})) ?? undefined
|
||||
);
|
||||
} catch (e) {
|
||||
throw new WorkspaceException(
|
||||
'Workspace not found',
|
||||
WorkspaceExceptionCode.SUBDOMAIN_NOT_FOUND,
|
||||
);
|
||||
if (!this.environmentService.get('IS_MULTIWORKSPACE_ENABLED')) {
|
||||
return this.getDefaultWorkspace();
|
||||
}
|
||||
|
||||
const { subdomain, hostname } = this.getSubdomainAndHostnameFromUrl(origin);
|
||||
|
||||
if (!hostname && !subdomain) return;
|
||||
|
||||
const where = isDefined(hostname) ? { hostname } : { subdomain };
|
||||
|
||||
return (
|
||||
(await this.workspaceRepository.findOne({
|
||||
where,
|
||||
relations: ['workspaceSSOIdentityProviders'],
|
||||
})) ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
private generateRandomSubdomain(): string {
|
||||
const prefixes = [
|
||||
'cool',
|
||||
'smart',
|
||||
'fast',
|
||||
'bright',
|
||||
'shiny',
|
||||
'happy',
|
||||
'funny',
|
||||
'clever',
|
||||
'brave',
|
||||
'kind',
|
||||
'gentle',
|
||||
'quick',
|
||||
'sharp',
|
||||
'calm',
|
||||
'silent',
|
||||
'lucky',
|
||||
'fierce',
|
||||
'swift',
|
||||
'mighty',
|
||||
'noble',
|
||||
'bold',
|
||||
'wise',
|
||||
'eager',
|
||||
'joyful',
|
||||
'glad',
|
||||
'zany',
|
||||
'witty',
|
||||
'bouncy',
|
||||
'graceful',
|
||||
'colorful',
|
||||
];
|
||||
const suffixes = [
|
||||
'raccoon',
|
||||
'panda',
|
||||
'whale',
|
||||
'tiger',
|
||||
'dolphin',
|
||||
'eagle',
|
||||
'penguin',
|
||||
'owl',
|
||||
'fox',
|
||||
'wolf',
|
||||
'lion',
|
||||
'bear',
|
||||
'hawk',
|
||||
'shark',
|
||||
'sparrow',
|
||||
'moose',
|
||||
'lynx',
|
||||
'falcon',
|
||||
'rabbit',
|
||||
'hedgehog',
|
||||
'monkey',
|
||||
'horse',
|
||||
'koala',
|
||||
'kangaroo',
|
||||
'elephant',
|
||||
'giraffe',
|
||||
'panther',
|
||||
'crocodile',
|
||||
'seal',
|
||||
'octopus',
|
||||
];
|
||||
private extractSubdomain(params?: { email?: string; displayName?: string }) {
|
||||
if (params?.email) {
|
||||
return getSubdomainFromEmail(params.email);
|
||||
}
|
||||
|
||||
const randomPrefix = prefixes[Math.floor(Math.random() * prefixes.length)];
|
||||
const randomSuffix = suffixes[Math.floor(Math.random() * suffixes.length)];
|
||||
|
||||
return `${randomPrefix}-${randomSuffix}`;
|
||||
}
|
||||
|
||||
private getSubdomainNameByEmail(email?: string) {
|
||||
if (!isDefined(email) || !isWorkEmail(email)) return;
|
||||
|
||||
return getDomainNameByEmail(email);
|
||||
}
|
||||
|
||||
private getSubdomainNameByDisplayName(displayName?: string) {
|
||||
if (!isDefined(displayName)) return;
|
||||
const displayNameWords = displayName.match(/(\w| |\d)+/g);
|
||||
|
||||
if (displayNameWords) {
|
||||
return displayNameWords.join('-').replace(/ /g, '').toLowerCase();
|
||||
if (params?.displayName) {
|
||||
return getSubdomainNameFromDisplayName(params.displayName);
|
||||
}
|
||||
}
|
||||
|
||||
async generateSubdomain(params?: { email?: string; displayName?: string }) {
|
||||
const subdomain =
|
||||
this.getSubdomainNameByEmail(params?.email) ??
|
||||
this.getSubdomainNameByDisplayName(params?.displayName) ??
|
||||
this.generateRandomSubdomain();
|
||||
this.extractSubdomain(params) ?? generateRandomSubdomain();
|
||||
|
||||
const existingWorkspaceCount = await this.workspaceRepository.countBy({
|
||||
subdomain,
|
||||
@ -302,4 +249,133 @@ export class DomainManagerService {
|
||||
|
||||
return `${subdomain}${existingWorkspaceCount > 0 ? `-${Math.random().toString(36).substring(2, 10)}` : ''}`;
|
||||
}
|
||||
|
||||
async registerCustomHostname(hostname: string) {
|
||||
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
if (await this.getCustomHostnameDetails(hostname)) {
|
||||
throw new DomainManagerException(
|
||||
'Hostname already registered',
|
||||
DomainManagerExceptionCode.HOSTNAME_ALREADY_REGISTERED,
|
||||
);
|
||||
}
|
||||
|
||||
return await this.cloudflareClient.customHostnames.create({
|
||||
zone_id: this.environmentService.get('CLOUDFLARE_ZONE_ID'),
|
||||
hostname,
|
||||
ssl: {
|
||||
method: 'txt',
|
||||
type: 'dv',
|
||||
settings: {
|
||||
http2: 'on',
|
||||
min_tls_version: '1.2',
|
||||
tls_1_3: 'on',
|
||||
ciphers: ['ECDHE-RSA-AES128-GCM-SHA256', 'AES128-SHA'],
|
||||
early_hints: 'on',
|
||||
},
|
||||
bundle_method: 'ubiquitous',
|
||||
wildcard: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getCustomHostnameDetails(
|
||||
hostname: string,
|
||||
): Promise<CustomHostnameDetails | undefined> {
|
||||
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
const response = await this.cloudflareClient.customHostnames.list({
|
||||
zone_id: this.environmentService.get('CLOUDFLARE_ZONE_ID'),
|
||||
hostname,
|
||||
});
|
||||
|
||||
if (response.result.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (response.result.length === 1) {
|
||||
return {
|
||||
id: response.result[0].id,
|
||||
hostname: response.result[0].hostname,
|
||||
status: response.result[0].status,
|
||||
ownershipVerifications: [
|
||||
response.result[0].ownership_verification,
|
||||
response.result[0].ownership_verification_http,
|
||||
].reduce(
|
||||
(acc, ownershipVerification) => {
|
||||
if (!ownershipVerification) return acc;
|
||||
|
||||
if (
|
||||
'http_body' in ownershipVerification &&
|
||||
'http_url' in ownershipVerification &&
|
||||
ownershipVerification.http_body &&
|
||||
ownershipVerification.http_url
|
||||
) {
|
||||
acc.push({
|
||||
type: 'http',
|
||||
body: ownershipVerification.http_body,
|
||||
url: ownershipVerification.http_url,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
'type' in ownershipVerification &&
|
||||
ownershipVerification.type === 'txt' &&
|
||||
ownershipVerification.value &&
|
||||
ownershipVerification.name
|
||||
) {
|
||||
acc.push({
|
||||
type: 'txt',
|
||||
value: ownershipVerification.value,
|
||||
name: ownershipVerification.name,
|
||||
});
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
[] as CustomHostnameDetails['ownershipVerifications'],
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// should never append. error 5xx
|
||||
throw new Error('More than one custom hostname found in cloudflare');
|
||||
}
|
||||
|
||||
async updateCustomHostname(fromHostname: string, toHostname: string) {
|
||||
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
const fromCustomHostname =
|
||||
await this.getCustomHostnameDetails(fromHostname);
|
||||
|
||||
if (fromCustomHostname) {
|
||||
await this.deleteCustomHostname(fromCustomHostname.id);
|
||||
}
|
||||
|
||||
return await this.registerCustomHostname(toHostname);
|
||||
}
|
||||
|
||||
async deleteCustomHostnameByHostnameSilently(hostname: string) {
|
||||
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
try {
|
||||
const customHostname = await this.getCustomHostnameDetails(hostname);
|
||||
|
||||
if (customHostname) {
|
||||
await this.cloudflareClient.customHostnames.delete(customHostname.id, {
|
||||
zone_id: this.environmentService.get('CLOUDFLARE_ZONE_ID'),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteCustomHostname(customHostnameId: string) {
|
||||
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
|
||||
|
||||
return this.cloudflareClient.customHostnames.delete(customHostnameId, {
|
||||
zone_id: this.environmentService.get('CLOUDFLARE_ZONE_ID'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
import { generateRandomSubdomain } from 'src/engine/core-modules/domain-manager/utils/generate-random-subdomain';
|
||||
|
||||
describe('generateRandomSubdomain', () => {
|
||||
it('should return a string in the format "prefix-suffix"', () => {
|
||||
const result = generateRandomSubdomain();
|
||||
|
||||
expect(result).toMatch(/^[a-z]+-[a-z]+$/);
|
||||
});
|
||||
|
||||
it('should generate different results on consecutive calls', () => {
|
||||
const result1 = generateRandomSubdomain();
|
||||
const result2 = generateRandomSubdomain();
|
||||
|
||||
expect(result1).not.toEqual(result2);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,25 @@
|
||||
import { getSubdomainFromEmail } from 'src/engine/core-modules/domain-manager/utils/get-subdomain-from-email';
|
||||
|
||||
describe('getSubdomainFromEmail', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return undefined if email is not defined', () => {
|
||||
const result = getSubdomainFromEmail(undefined);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined if email is not a work email', () => {
|
||||
const result = getSubdomainFromEmail('test@gmail.com');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return the domain name if email is valid and a work email', () => {
|
||||
const result = getSubdomainFromEmail('test@twenty.com');
|
||||
|
||||
expect(result).toBe('twenty');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,41 @@
|
||||
import { getSubdomainNameFromDisplayName } from 'src/engine/core-modules/domain-manager/utils/get-subdomain-name-from-display-name';
|
||||
|
||||
describe('getSubdomainNameFromDisplayName', () => {
|
||||
it('should return a hyphen-separated, lowercase subdomain name without spaces for a valid display name', () => {
|
||||
const result = getSubdomainNameFromDisplayName('My Display Name 123');
|
||||
|
||||
expect(result).toBe('my-display-name-123');
|
||||
});
|
||||
|
||||
it('should return undefined if displayName is undefined', () => {
|
||||
const result = getSubdomainNameFromDisplayName(undefined);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle display names with special characters by removing them but keeping words and numbers', () => {
|
||||
const result = getSubdomainNameFromDisplayName('Hello!@# World$%^ 2023');
|
||||
|
||||
expect(result).toBe('hello-world-2023');
|
||||
});
|
||||
|
||||
it('should return a single word in lowercase if displayName consists of one valid word', () => {
|
||||
const result = getSubdomainNameFromDisplayName('SingleWord');
|
||||
|
||||
expect(result).toBe('singleword');
|
||||
});
|
||||
|
||||
it('should return undefined when displayName contains only special characters', () => {
|
||||
const result = getSubdomainNameFromDisplayName('!@#$%^&*()');
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle display names with multiple spaces by removing them', () => {
|
||||
const result = getSubdomainNameFromDisplayName(
|
||||
' Spaced Out Name ',
|
||||
);
|
||||
|
||||
expect(result).toBe('spaced-out-name');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,71 @@
|
||||
export const generateRandomSubdomain = () => {
|
||||
const prefixes = [
|
||||
'cool',
|
||||
'smart',
|
||||
'fast',
|
||||
'bright',
|
||||
'shiny',
|
||||
'happy',
|
||||
'funny',
|
||||
'clever',
|
||||
'brave',
|
||||
'kind',
|
||||
'gentle',
|
||||
'quick',
|
||||
'sharp',
|
||||
'calm',
|
||||
'silent',
|
||||
'lucky',
|
||||
'fierce',
|
||||
'swift',
|
||||
'mighty',
|
||||
'noble',
|
||||
'bold',
|
||||
'wise',
|
||||
'eager',
|
||||
'joyful',
|
||||
'glad',
|
||||
'zany',
|
||||
'witty',
|
||||
'bouncy',
|
||||
'graceful',
|
||||
'colorful',
|
||||
];
|
||||
const suffixes = [
|
||||
'raccoon',
|
||||
'panda',
|
||||
'whale',
|
||||
'tiger',
|
||||
'dolphin',
|
||||
'eagle',
|
||||
'penguin',
|
||||
'owl',
|
||||
'fox',
|
||||
'wolf',
|
||||
'lion',
|
||||
'bear',
|
||||
'hawk',
|
||||
'shark',
|
||||
'sparrow',
|
||||
'moose',
|
||||
'lynx',
|
||||
'falcon',
|
||||
'rabbit',
|
||||
'hedgehog',
|
||||
'monkey',
|
||||
'horse',
|
||||
'koala',
|
||||
'kangaroo',
|
||||
'elephant',
|
||||
'giraffe',
|
||||
'panther',
|
||||
'crocodile',
|
||||
'seal',
|
||||
'octopus',
|
||||
];
|
||||
|
||||
const randomPrefix = prefixes[Math.floor(Math.random() * prefixes.length)];
|
||||
const randomSuffix = suffixes[Math.floor(Math.random() * suffixes.length)];
|
||||
|
||||
return `${randomPrefix}-${randomSuffix}`;
|
||||
};
|
||||
@ -0,0 +1,11 @@
|
||||
import { isDefined } from 'src/utils/is-defined';
|
||||
import { isWorkEmail } from 'src/utils/is-work-email';
|
||||
import { getDomainNameByEmail } from 'src/utils/get-domain-name-by-email';
|
||||
|
||||
export const getSubdomainFromEmail = (email?: string) => {
|
||||
if (!isDefined(email) || !isWorkEmail(email)) return;
|
||||
|
||||
const domain = getDomainNameByEmail(email);
|
||||
|
||||
return domain.split('.')[0];
|
||||
};
|
||||
@ -0,0 +1,10 @@
|
||||
import { isDefined } from 'src/utils/is-defined';
|
||||
|
||||
export const getSubdomainNameFromDisplayName = (displayName?: string) => {
|
||||
if (!isDefined(displayName)) return;
|
||||
const displayNameWords = displayName.match(/(\w|\d)+/g);
|
||||
|
||||
if (displayNameWords) {
|
||||
return displayNameWords.join('-').replace(/ /g, '').toLowerCase();
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,23 @@
|
||||
import Cloudflare from 'cloudflare';
|
||||
|
||||
import {
|
||||
DomainManagerException,
|
||||
DomainManagerExceptionCode,
|
||||
} from 'src/engine/core-modules/domain-manager/domain-manager.exception';
|
||||
|
||||
const isCloudflareInstanceDefined = (
|
||||
cloudflareInstance: Cloudflare | undefined | null,
|
||||
): asserts cloudflareInstance is Cloudflare => {
|
||||
if (!cloudflareInstance) {
|
||||
throw new DomainManagerException(
|
||||
'Cloudflare instance is not defined',
|
||||
DomainManagerExceptionCode.CLOUDFLARE_CLIENT_NOT_INITIALIZED,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const domainManagerValidator: {
|
||||
isCloudflareInstanceDefined: typeof isCloudflareInstanceDefined;
|
||||
} = {
|
||||
isCloudflareInstanceDefined,
|
||||
};
|
||||
Reference in New Issue
Block a user