14240 extensibility ability to create multiple custom domains for each workspace 2 (#14307)

Adds a public-domain core-module. 
Reorganize custom-domain files properly
This commit is contained in:
martmull
2025-09-11 12:24:57 +02:00
committed by GitHub
parent ceffc82b9c
commit b84f4075e5
48 changed files with 1215 additions and 797 deletions
@@ -868,22 +868,6 @@ export type CursorPaging = {
last?: InputMaybe<Scalars['Int']>;
};
export type CustomDomainRecord = {
__typename?: 'CustomDomainRecord';
key: Scalars['String'];
status: Scalars['String'];
type: Scalars['String'];
validationType: Scalars['String'];
value: Scalars['String'];
};
export type CustomDomainValidRecords = {
__typename?: 'CustomDomainValidRecords';
customDomain: Scalars['String'];
id: Scalars['UUID'];
records: Array<CustomDomainRecord>;
};
/** Database Event Action */
export enum DatabaseEventAction {
CREATED = 'CREATED',
@@ -961,6 +945,22 @@ export enum DistantTableUpdate {
TABLE_DELETED = 'TABLE_DELETED'
}
export type DomainRecord = {
__typename?: 'DomainRecord';
key: Scalars['String'];
status: Scalars['String'];
type: Scalars['String'];
validationType: Scalars['String'];
value: Scalars['String'];
};
export type DomainValidRecords = {
__typename?: 'DomainValidRecords';
domain: Scalars['String'];
id: Scalars['UUID'];
records: Array<DomainRecord>;
};
export type DuplicateWorkflowVersionStepInput = {
stepId: Scalars['String'];
workflowVersionId: Scalars['String'];
@@ -1410,7 +1410,7 @@ export type Mutation = {
assignRoleToAgent: Scalars['Boolean'];
assignRoleToApiKey: Scalars['Boolean'];
authorizeApp: AuthorizeApp;
checkCustomDomainValidRecords?: Maybe<CustomDomainValidRecords>;
checkCustomDomainValidRecords?: Maybe<DomainValidRecords>;
checkoutSession: BillingSessionOutput;
computeStepOutputSchema: Scalars['JSON'];
createAgentChatThread: AgentChatThread;
@@ -1438,6 +1438,7 @@ export type Mutation = {
createPageLayout: PageLayout;
createPageLayoutTab: PageLayoutTab;
createPageLayoutWidget: PageLayoutWidget;
createPublicDomain: PublicDomain;
createSAMLIdentityProvider: SetupSsoOutput;
createWebhook: Webhook;
createWorkflowVersionEdge: WorkflowVersionStepChanges;
@@ -1462,6 +1463,7 @@ export type Mutation = {
deletePageLayout: PageLayout;
deletePageLayoutTab: Scalars['Boolean'];
deletePageLayoutWidget: PageLayoutWidget;
deletePublicDomain: Scalars['Boolean'];
deleteSSOIdentityProvider: DeleteSsoOutput;
deleteTwoFactorAuthenticationMethod: DeleteTwoFactorAuthenticationMethodOutput;
deleteUser: User;
@@ -1734,6 +1736,11 @@ export type MutationCreatePageLayoutWidgetArgs = {
};
export type MutationCreatePublicDomainArgs = {
domain: Scalars['String'];
};
export type MutationCreateSamlIdentityProviderArgs = {
input: SetupSamlSsoInput;
};
@@ -1849,6 +1856,11 @@ export type MutationDeletePageLayoutWidgetArgs = {
};
export type MutationDeletePublicDomainArgs = {
domain: Scalars['String'];
};
export type MutationDeleteSsoIdentityProviderArgs = {
input: DeleteSsoInput;
};
@@ -2593,6 +2605,14 @@ export type PostgresCredentials = {
workspaceId: Scalars['UUID'];
};
export type PublicDomain = {
__typename?: 'PublicDomain';
createdAt: Scalars['DateTime'];
domain: Scalars['String'];
id: Scalars['UUID'];
isValidated: Scalars['Boolean'];
};
export type PublicFeatureFlag = {
__typename?: 'PublicFeatureFlag';
key: FeatureFlagKey;
@@ -5403,7 +5423,7 @@ export type UploadWorkspaceLogoMutation = { __typename?: 'Mutation', uploadWorks
export type CheckCustomDomainValidRecordsMutationVariables = Exact<{ [key: string]: never; }>;
export type CheckCustomDomainValidRecordsMutation = { __typename?: 'Mutation', checkCustomDomainValidRecords?: { __typename?: 'CustomDomainValidRecords', id: string, customDomain: string, records: Array<{ __typename?: 'CustomDomainRecord', type: string, key: string, value: string, validationType: string, status: string }> } | null };
export type CheckCustomDomainValidRecordsMutation = { __typename?: 'Mutation', checkCustomDomainValidRecords?: { __typename?: 'DomainValidRecords', id: string, domain: string, records: Array<{ __typename?: 'DomainRecord', type: string, key: string, value: string, validationType: string, status: string }> } | null };
export type GetWorkspaceFromInviteHashQueryVariables = Exact<{
inviteHash: Scalars['String'];
@@ -12604,7 +12624,7 @@ export const CheckCustomDomainValidRecordsDocument = gql`
mutation CheckCustomDomainValidRecords {
checkCustomDomainValidRecords {
id
customDomain
domain
records {
type
key
+37 -17
View File
@@ -832,22 +832,6 @@ export type CursorPaging = {
last?: InputMaybe<Scalars['Int']>;
};
export type CustomDomainRecord = {
__typename?: 'CustomDomainRecord';
key: Scalars['String'];
status: Scalars['String'];
type: Scalars['String'];
validationType: Scalars['String'];
value: Scalars['String'];
};
export type CustomDomainValidRecords = {
__typename?: 'CustomDomainValidRecords';
customDomain: Scalars['String'];
id: Scalars['UUID'];
records: Array<CustomDomainRecord>;
};
/** Database Event Action */
export enum DatabaseEventAction {
CREATED = 'CREATED',
@@ -925,6 +909,22 @@ export enum DistantTableUpdate {
TABLE_DELETED = 'TABLE_DELETED'
}
export type DomainRecord = {
__typename?: 'DomainRecord';
key: Scalars['String'];
status: Scalars['String'];
type: Scalars['String'];
validationType: Scalars['String'];
value: Scalars['String'];
};
export type DomainValidRecords = {
__typename?: 'DomainValidRecords';
domain: Scalars['String'];
id: Scalars['UUID'];
records: Array<DomainRecord>;
};
export type DuplicateWorkflowVersionStepInput = {
stepId: Scalars['String'];
workflowVersionId: Scalars['String'];
@@ -1367,7 +1367,7 @@ export type Mutation = {
assignRoleToAgent: Scalars['Boolean'];
assignRoleToApiKey: Scalars['Boolean'];
authorizeApp: AuthorizeApp;
checkCustomDomainValidRecords?: Maybe<CustomDomainValidRecords>;
checkCustomDomainValidRecords?: Maybe<DomainValidRecords>;
checkoutSession: BillingSessionOutput;
computeStepOutputSchema: Scalars['JSON'];
createAgentChatThread: AgentChatThread;
@@ -1394,6 +1394,7 @@ export type Mutation = {
createPageLayout: PageLayout;
createPageLayoutTab: PageLayoutTab;
createPageLayoutWidget: PageLayoutWidget;
createPublicDomain: PublicDomain;
createSAMLIdentityProvider: SetupSsoOutput;
createWebhook: Webhook;
createWorkflowVersionEdge: WorkflowVersionStepChanges;
@@ -1417,6 +1418,7 @@ export type Mutation = {
deletePageLayout: PageLayout;
deletePageLayoutTab: Scalars['Boolean'];
deletePageLayoutWidget: PageLayoutWidget;
deletePublicDomain: Scalars['Boolean'];
deleteSSOIdentityProvider: DeleteSsoOutput;
deleteTwoFactorAuthenticationMethod: DeleteTwoFactorAuthenticationMethodOutput;
deleteUser: User;
@@ -1670,6 +1672,11 @@ export type MutationCreatePageLayoutWidgetArgs = {
};
export type MutationCreatePublicDomainArgs = {
domain: Scalars['String'];
};
export type MutationCreateSamlIdentityProviderArgs = {
input: SetupSamlSsoInput;
};
@@ -1780,6 +1787,11 @@ export type MutationDeletePageLayoutWidgetArgs = {
};
export type MutationDeletePublicDomainArgs = {
domain: Scalars['String'];
};
export type MutationDeleteSsoIdentityProviderArgs = {
input: DeleteSsoInput;
};
@@ -2504,6 +2516,14 @@ export type PostgresCredentials = {
workspaceId: Scalars['UUID'];
};
export type PublicDomain = {
__typename?: 'PublicDomain';
createdAt: Scalars['DateTime'];
domain: Scalars['String'];
id: Scalars['UUID'];
isValidated: Scalars['Boolean'];
};
export type PublicFeatureFlag = {
__typename?: 'PublicFeatureFlag';
key: FeatureFlagKey;
@@ -7,8 +7,8 @@ import styled from '@emotion/styled';
import { Button } from 'twenty-ui/input';
import { useDebouncedCallback } from 'use-debounce';
import {
type CustomDomainRecord,
type CustomDomainValidRecords,
type DomainRecord,
type DomainValidRecords,
} from '~/generated/graphql';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
import { capitalize } from 'twenty-shared/utils';
@@ -49,7 +49,7 @@ const StyledButton = styled(Button)`
export const SettingsCustomDomainRecords = ({
records,
}: {
records: CustomDomainValidRecords['records'];
records: DomainValidRecords['records'];
}) => {
const { customDomainRecords } = useRecoilValue(customDomainRecordsState);
const currentWorkspace = useRecoilValue(currentWorkspaceState);
@@ -67,7 +67,7 @@ export const SettingsCustomDomainRecords = ({
];
const defaultValues: { status: string; color: ThemeColor } =
currentWorkspace?.customDomain === customDomainRecords?.customDomain
currentWorkspace?.customDomain === customDomainRecords?.domain
? {
status: 'success',
color: 'green',
@@ -78,7 +78,7 @@ export const SettingsCustomDomainRecords = ({
};
const rows = rowsDefinitions.map<
{ name: string; status: string; color: ThemeColor } & CustomDomainRecord
{ name: string; status: string; color: ThemeColor } & DomainRecord
>((row) => {
const record = records.find(
({ validationType }) => validationType === row.validationType,
@@ -1,5 +1,5 @@
import { useCheckCustomDomainValidRecordsMutation } from '~/generated-metadata/graphql';
import { useSetRecoilState } from 'recoil';
import { useRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { customDomainRecordsState } from '@/settings/domains/states/customDomainRecordsState';
@@ -9,9 +9,14 @@ export const useCheckCustomDomainValidRecords = () => {
useCheckCustomDomainValidRecordsMutation();
const { enqueueErrorSnackBar } = useSnackBar();
const setCustomDomainRecords = useSetRecoilState(customDomainRecordsState);
const [{ isLoading }, setCustomDomainRecords] = useRecoilState(
customDomainRecordsState,
);
const checkCustomDomainRecords = () => {
if (isLoading) {
return;
}
setCustomDomainRecords((currentState) => ({
...currentState,
isLoading: true,
@@ -1,8 +1,8 @@
import { type CustomDomainValidRecords } from '~/generated/graphql';
import { type DomainValidRecords } from '~/generated/graphql';
import { createState } from 'twenty-ui/utilities';
export const customDomainRecordsState = createState<{
customDomainRecords: CustomDomainValidRecords | null;
customDomainRecords: DomainValidRecords | null;
isLoading: boolean;
}>({
key: 'customDomainRecordsState',
@@ -4,7 +4,7 @@ export const CHECK_CUSTOM_DOMAIN_VALID_RECORDS = gql`
mutation CheckCustomDomainValidRecords {
checkCustomDomainValidRecords {
id
customDomain
domain
records {
type
key
@@ -5,8 +5,6 @@ import {
import { useRedirectToWorkspaceDomain } from '@/domain-manager/hooks/useRedirectToWorkspaceDomain';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsCustomDomain } from '@/settings/domains/components/SettingsCustomDomain';
import { SettingsSubdomain } from '@/settings/domains/components/SettingsSubdomain';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
@@ -21,6 +19,9 @@ import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { z } from 'zod';
import { useUpdateWorkspaceMutation } from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { SettingsCustomDomain } from '@/settings/domains/components/SettingsCustomDomain';
import { SettingsSubdomain } from '@/settings/domains/components/SettingsSubdomain';
import { useState } from 'react';
export const SUBDOMAIN_CHANGE_CONFIRMATION_MODAL_ID =
'subdomain-change-confirmation-modal';
@@ -61,6 +62,7 @@ export const SettingsDomain = () => {
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [updateWorkspace] = useUpdateWorkspaceMutation();
const { redirectToWorkspaceDomain } = useRedirectToWorkspaceDomain();
const [isSubmitting, setIsSubmitting] = useState(false);
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
currentWorkspaceState,
@@ -88,6 +90,10 @@ export const SettingsDomain = () => {
customDomain: string | null,
currentWorkspace: CurrentWorkspace,
) => {
if (isSubmitting) {
return;
}
setIsSubmitting(true);
updateWorkspace({
variables: {
input: {
@@ -106,6 +112,7 @@ export const SettingsDomain = () => {
enqueueSuccessSnackBar({
message: t`Custom domain updated`,
});
setIsSubmitting(false);
},
onError: (error: ApolloError) => {
if (
@@ -120,6 +127,7 @@ export const SettingsDomain = () => {
enqueueErrorSnackBar({
apolloError: error,
});
setIsSubmitting(false);
},
});
};
@@ -128,6 +136,10 @@ export const SettingsDomain = () => {
subdomain: string,
currentWorkspace: CurrentWorkspace,
) => {
if (isSubmitting) {
return;
}
setIsSubmitting(true);
updateWorkspace({
variables: {
input: {
@@ -148,6 +160,7 @@ export const SettingsDomain = () => {
enqueueErrorSnackBar({
apolloError: error,
});
setIsSubmitting(false);
},
onCompleted: async () => {
const currentUrl = new URL(window.location.href);
@@ -164,6 +177,7 @@ export const SettingsDomain = () => {
enqueueSuccessSnackBar({
message: t`Subdomain updated`,
});
setIsSubmitting(false);
await redirectToWorkspaceDomain(currentUrl.toString());
},
@@ -222,7 +236,7 @@ export const SettingsDomain = () => {
actionButton={
<SaveAndCancelButtons
onCancel={() => navigate(SettingsPath.Domains)}
isSaveDisabled={form.formState.isSubmitting}
isSaveDisabled={isSubmitting}
/>
}
>
@@ -2,7 +2,6 @@ import { Logger } from '@nestjs/common';
import { Command, CommandRunner } from 'nest-commander';
import { CheckCustomDomainValidRecordsCronCommand } from 'src/engine/core-modules/domain-manager/crons/commands/check-custom-domain-valid-records.cron.command';
import { CleanupOrphanedFilesCronCommand } from 'src/engine/core-modules/file/crons/commands/cleanup-orphaned-files.cron.command';
import { CronTriggerCronCommand } from 'src/engine/metadata-modules/trigger/crons/commands/cron-trigger.cron.command';
import { CleanOnboardingWorkspacesCronCommand } from 'src/engine/workspace-manager/workspace-cleaner/commands/clean-onboarding-workspaces.cron.command';
@@ -17,6 +16,7 @@ import { WorkflowCleanWorkflowRunsCronCommand } from 'src/modules/workflow/workf
import { WorkflowHandleStaledRunsCronCommand } from 'src/modules/workflow/workflow-runner/workflow-run-queue/cron/command/workflow-handle-staled-runs.cron.command';
import { WorkflowRunEnqueueCronCommand } from 'src/modules/workflow/workflow-runner/workflow-run-queue/cron/command/workflow-run-enqueue.cron.command';
import { WorkflowCronTriggerCronCommand } from 'src/modules/workflow/workflow-trigger/automated-trigger/crons/commands/workflow-cron-trigger.cron.command';
import { CheckCustomDomainValidRecordsCronCommand } from 'src/engine/core-modules/workspace/crons/commands/check-custom-domain-valid-records.cron.command';
@Command({
name: 'cron:register:all',
@@ -7,7 +7,6 @@ import { ConfirmationQuestion } from 'src/database/commands/questions/confirmati
import { UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/upgrade-version-command.module';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -23,6 +22,7 @@ import { CalendarEventImportManagerModule } from 'src/modules/calendar/calendar-
import { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module';
import { WorkflowRunQueueModule } from 'src/modules/workflow/workflow-runner/workflow-run-queue/workflow-run-queue.module';
import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/automated-trigger/automated-trigger.module';
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
@Module({
imports: [
@@ -33,7 +33,7 @@ import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/au
CalendarEventImportManagerModule,
AutomatedTriggerModule,
FileModule,
DomainManagerModule,
WorkspaceModule,
WorkflowRunQueueModule,
// Data seeding dependencies
TypeORMModule,
@@ -0,0 +1,21 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class AddPublicDomainEntity1757013851879 implements MigrationInterface {
name = 'AddPublicDomainEntity1757013851879';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "core"."publicDomain" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "domain" character varying NOT NULL, "isValidated" boolean NOT NULL DEFAULT false, "workspaceId" uuid NOT NULL, CONSTRAINT "UQ_1311e24fbd049c561c53a274f2a" UNIQUE ("domain"), CONSTRAINT "PK_ff55a0f1bc3b6e2c32feff734b1" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`ALTER TABLE "core"."publicDomain" ADD CONSTRAINT "FK_7e9ca5fd7aa30b8396ea3d1d6be" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."publicDomain" DROP CONSTRAINT "FK_7e9ca5fd7aa30b8396ea3d1d6be"`,
);
await queryRunner.query(`DROP TABLE "core"."publicDomain"`);
}
}
@@ -169,6 +169,9 @@ export class ApprovedAccessDomainService {
throw new ApprovedAccessDomainException(
'Approved access domain already registered.',
ApprovedAccessDomainExceptionCode.APPROVED_ACCESS_DOMAIN_ALREADY_REGISTERED,
{
userFriendlyMessage: t`Approved access domain already registered.`,
},
);
}
@@ -53,6 +53,8 @@ import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.mod
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module';
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
import { PublicDomainModule } from 'src/engine/core-modules/public-domain/public-domain.module';
import { DnsManagerModule } from 'src/engine/core-modules/dns-manager/dns-manager.module';
import { AuditModule } from './audit/audit.module';
import { ClientConfigModule } from './client-config/client-config.module';
@@ -79,6 +81,8 @@ import { FileModule } from './file/file.module';
WorkspaceInvitationModule,
WorkspaceSSOModule,
ApprovedAccessDomainModule,
PublicDomainModule,
DnsManagerModule,
PostgresCredentialsModule,
WorkflowApiModule,
WorkspaceEventEmitterModule,
@@ -0,0 +1,106 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { type Request } from 'express';
import { DnsCloudflareController } from 'src/engine/core-modules/dns-manager/controllers/dns-cloudflare.controller';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
describe('DnsCloudflareController - customHostnameWebhooks', () => {
let controller: DnsCloudflareController;
let dnsManagerService: DnsManagerService;
let domainManagerService: DomainManagerService;
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
controllers: [DnsCloudflareController],
providers: [
{
provide: DomainManagerService,
useValue: {
handleCustomDomainActivation: jest.fn(),
},
},
{
provide: DnsManagerService,
useValue: {
isHostnameWorking: jest.fn(),
},
},
{
provide: HttpExceptionHandlerService,
useValue: {
handleError: jest.fn(),
},
},
{
provide: ExceptionHandlerService,
useValue: {
captureExceptions: jest.fn(),
},
},
{
provide: TwentyConfigService,
useValue: {
get: jest.fn(),
},
},
],
}).compile();
controller = module.get<DnsCloudflareController>(DnsCloudflareController);
dnsManagerService = module.get<DnsManagerService>(DnsManagerService);
domainManagerService =
module.get<DomainManagerService>(DomainManagerService);
});
it('should return if hostname is missing', async () => {
const req = {
headers: { 'cf-webhook-auth': 'correct-secret' },
body: { data: { data: {} } },
} as unknown as Request;
await controller.customHostnameWebhooks(req);
expect(dnsManagerService.isHostnameWorking).not.toHaveBeenCalled();
});
it('should return if wrong alert_type', async () => {
const req = {
headers: { 'cf-webhook-auth': 'correct-secret' },
body: { alert_type: 'wrong_alert_type', data: { data: {} } },
} as unknown as Request;
await controller.customHostnameWebhooks(req);
expect(dnsManagerService.isHostnameWorking).not.toHaveBeenCalled();
});
it('should update workspace for a valid hostname and save changes', async () => {
const req = {
headers: { 'cf-webhook-auth': 'correct-secret' },
body: {
alert_type: 'custom_ssl_certificate_event_type',
data: { data: { hostname: 'example.com' } },
},
} as unknown as Request;
jest.spyOn(dnsManagerService, 'isHostnameWorking').mockResolvedValue(true);
await controller.customHostnameWebhooks(req);
expect(dnsManagerService.isHostnameWorking).toHaveBeenCalled();
expect(
domainManagerService.handleCustomDomainActivation,
).toHaveBeenCalledWith({
customDomain: 'example.com',
isCustomDomainWorking: true,
});
});
});
@@ -0,0 +1,45 @@
/* @license Enterprise */
import { Controller, Post, Req, UseFilters, UseGuards } from '@nestjs/common';
import { Request } from 'express';
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
import { CloudflareSecretMatchGuard } from 'src/engine/core-modules/dns-manager/guards/cloudflare-secret.guard';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
import { DnsManagerExceptionFilter } from 'src/engine/core-modules/dns-manager/exceptions/dns-manager-exception-filter';
@Controller()
@UseFilters(AuthRestApiExceptionFilter, DnsManagerExceptionFilter)
export class DnsCloudflareController {
constructor(
protected readonly domainManagerService: DomainManagerService,
protected readonly dnsManagerService: DnsManagerService,
) {}
@Post(['cloudflare/custom-hostname-webhooks', 'webhooks/cloudflare'])
@UseGuards(CloudflareSecretMatchGuard, PublicEndpointGuard)
async customHostnameWebhooks(@Req() req: Request) {
const alertType = req.body?.alert_type;
const hostname = req.body?.data?.data?.hostname;
if (alertType !== 'custom_ssl_certificate_event_type' || !hostname) {
return;
}
try {
const isCustomDomainWorking =
await this.dnsManagerService.isHostnameWorking(hostname);
await this.domainManagerService.handleCustomDomainActivation({
customDomain: hostname,
isCustomDomainWorking,
});
} catch {
return;
}
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
import { DnsCloudflareController } from 'src/engine/core-modules/dns-manager/controllers/dns-cloudflare.controller';
@Module({
imports: [DomainManagerModule],
providers: [DnsManagerService],
controllers: [DnsCloudflareController],
exports: [DnsManagerService],
})
export class DnsManagerModule {}
@@ -3,7 +3,7 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType()
class CustomDomainRecord {
class DomainRecord {
@Field(() => String)
validationType: 'ssl' | 'redirection';
@@ -21,13 +21,13 @@ class CustomDomainRecord {
}
@ObjectType()
export class CustomDomainValidRecords {
export class DomainValidRecords {
@Field(() => UUIDScalarType)
id: string;
@Field(() => String)
customDomain: string;
domain: string;
@Field(() => [CustomDomainRecord])
records: Array<CustomDomainRecord>;
@Field(() => [DomainRecord])
records: Array<DomainRecord>;
}
@@ -0,0 +1,27 @@
import { Catch, type ExceptionFilter } from '@nestjs/common';
import { assertUnreachable } from 'twenty-shared/utils';
import {
DnsManagerException,
DnsManagerExceptionCode,
} from 'src/engine/core-modules/dns-manager/exceptions/dns-manager.exception';
@Catch(DnsManagerException)
export class DnsManagerExceptionFilter implements ExceptionFilter {
catch(exception: DnsManagerException) {
switch (exception.code) {
case DnsManagerExceptionCode.INTERNAL_SERVER_ERROR:
case DnsManagerExceptionCode.HOSTNAME_ALREADY_REGISTERED:
case DnsManagerExceptionCode.HOSTNAME_NOT_REGISTERED:
case DnsManagerExceptionCode.INVALID_INPUT_DATA:
case DnsManagerExceptionCode.CLOUDFLARE_CLIENT_NOT_INITIALIZED:
case DnsManagerExceptionCode.MULTIPLE_HOSTNAMES_FOUND:
case DnsManagerExceptionCode.MISSING_PUBLIC_DOMAIN_URL:
throw exception;
default: {
assertUnreachable(exception.code);
}
}
}
}
@@ -0,0 +1,18 @@
import {
appendCommonExceptionCode,
CustomException,
} from 'src/utils/custom-exception';
export class DnsManagerException extends CustomException<
keyof typeof DnsManagerExceptionCode,
true
> {}
export const DnsManagerExceptionCode = appendCommonExceptionCode({
HOSTNAME_ALREADY_REGISTERED: 'HOSTNAME_ALREADY_REGISTERED',
HOSTNAME_NOT_REGISTERED: 'HOSTNAME_NOT_REGISTERED',
INVALID_INPUT_DATA: 'INVALID_INPUT_DATA',
CLOUDFLARE_CLIENT_NOT_INITIALIZED: 'CLOUDFLARE_CLIENT_NOT_INITIALIZED',
MULTIPLE_HOSTNAMES_FOUND: 'MULTIPLE_HOSTNAMES_FOUND',
MISSING_PUBLIC_DOMAIN_URL: 'MISSING_PUBLIC_DOMAIN_URL',
} as const);
@@ -3,8 +3,7 @@ import { type ExecutionContext } from '@nestjs/common';
import * as crypto from 'crypto';
import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { CloudflareSecretMatchGuard } from './cloudflare-secret.guard';
import { CloudflareSecretMatchGuard } from 'src/engine/core-modules/dns-manager/guards/cloudflare-secret.guard';
describe('CloudflareSecretMatchGuard.canActivate', () => {
let guard: CloudflareSecretMatchGuard;
@@ -6,23 +6,23 @@ import { type CustomHostnameCreateResponse } from 'cloudflare/resources/custom-h
import { AuditContextMock } from 'test/utils/audit-context.mock';
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import { DomainManagerException } from 'src/engine/core-modules/domain-manager/domain-manager.exception';
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
import { DnsManagerException } from 'src/engine/core-modules/dns-manager/exceptions/dns-manager.exception';
jest.mock('cloudflare');
describe('CustomDomainService', () => {
let customDomainService: CustomDomainService;
describe('DnsManagerService', () => {
let dnsManagerService: DnsManagerService;
let twentyConfigService: TwentyConfigService;
let domainManagerService: DomainManagerService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
CustomDomainService,
DnsManagerService,
{
provide: TwentyConfigService,
useValue: {
@@ -39,6 +39,7 @@ describe('CustomDomainService', () => {
provide: DomainManagerService,
useValue: {
getBaseUrl: jest.fn(),
getPublicDomainUrl: jest.fn(),
},
},
{
@@ -50,12 +51,12 @@ describe('CustomDomainService', () => {
],
}).compile();
customDomainService = module.get<CustomDomainService>(CustomDomainService);
dnsManagerService = module.get<DnsManagerService>(DnsManagerService);
twentyConfigService = module.get<TwentyConfigService>(TwentyConfigService);
domainManagerService =
module.get<DomainManagerService>(DomainManagerService);
(customDomainService as any).cloudflareClient = {
(dnsManagerService as any).cloudflareClient = {
customHostnames: {
list: jest.fn(),
create: jest.fn(),
@@ -70,31 +71,28 @@ describe('CustomDomainService', () => {
jest.spyOn(twentyConfigService, 'get').mockReturnValue(mockApiKey);
const instance = new CustomDomainService(
twentyConfigService,
{} as any,
{} as any,
{} as any,
);
const instance = new DnsManagerService(twentyConfigService, {} as any);
expect(twentyConfigService.get).toHaveBeenCalledWith('CLOUDFLARE_API_KEY');
expect(Cloudflare).toHaveBeenCalledWith({ apiToken: mockApiKey });
expect(instance.cloudflareClient).toBeDefined();
});
describe('registerCustomDomain', () => {
describe('registerHostname', () => {
it('should throw an error when the hostname is already registered', async () => {
const customDomain = 'example.com';
jest
.spyOn(customDomainService, 'getCustomDomainDetails')
.mockResolvedValueOnce({} as any);
.spyOn(dnsManagerService, 'getHostnameId')
.mockResolvedValueOnce('hostname-id');
await expect(
customDomainService.registerCustomDomain(customDomain),
).rejects.toThrow(DomainManagerException);
expect(customDomainService.getCustomDomainDetails).toHaveBeenCalledWith(
dnsManagerService.registerHostname(customDomain),
).rejects.toThrow(DnsManagerException);
expect(dnsManagerService.getHostnameId).toHaveBeenCalledWith(
customDomain,
undefined,
);
});
@@ -108,12 +106,13 @@ describe('CustomDomainService', () => {
};
jest
.spyOn(customDomainService, 'getCustomDomainDetails')
.spyOn(dnsManagerService, 'getHostnameId')
.mockResolvedValueOnce(undefined);
jest.spyOn(twentyConfigService, 'get').mockReturnValue('test-zone-id');
(customDomainService as any).cloudflareClient = cloudflareMock;
await customDomainService.registerCustomDomain(customDomain);
jest.spyOn(twentyConfigService, 'get').mockReturnValue('test-zone-id');
(dnsManagerService as any).cloudflareClient = cloudflareMock;
await dnsManagerService.registerHostname(customDomain);
expect(createMock).toHaveBeenCalledWith({
zone_id: 'test-zone-id',
@@ -123,7 +122,7 @@ describe('CustomDomainService', () => {
});
});
describe('getCustomDomainDetails', () => {
describe('getHostnameWithRecords', () => {
it('should return undefined if no custom domain details are found', async () => {
const customDomain = 'example.com';
const cloudflareMock = {
@@ -133,10 +132,12 @@ describe('CustomDomainService', () => {
};
jest.spyOn(twentyConfigService, 'get').mockReturnValue('test-zone-id');
(customDomainService as any).cloudflareClient = cloudflareMock;
(dnsManagerService as any).cloudflareClient = cloudflareMock;
const result =
await customDomainService.getCustomDomainDetails(customDomain);
const result = await dnsManagerService.getHostnameWithRecords(
customDomain,
{ isPublicDomain: false },
);
expect(result).toBeUndefined();
expect(cloudflareMock.customHostnames.list).toHaveBeenCalledWith({
@@ -166,14 +167,16 @@ describe('CustomDomainService', () => {
jest
.spyOn(domainManagerService, 'getBaseUrl')
.mockReturnValue(new URL('https://front.domain'));
(customDomainService as any).cloudflareClient = cloudflareMock;
(dnsManagerService as any).cloudflareClient = cloudflareMock;
const result =
await customDomainService.getCustomDomainDetails(customDomain);
const result = await dnsManagerService.getHostnameWithRecords(
customDomain,
{ isPublicDomain: false },
);
expect(result).toEqual({
id: 'custom-id',
customDomain: customDomain,
domain: customDomain,
records: expect.any(Array),
});
});
@@ -203,16 +206,66 @@ describe('CustomDomainService', () => {
jest
.spyOn(domainManagerService, 'getBaseUrl')
.mockReturnValue(new URL('https://front.domain'));
(customDomainService as any).cloudflareClient = cloudflareMock;
(dnsManagerService as any).cloudflareClient = cloudflareMock;
const result =
await customDomainService.getCustomDomainDetails(customDomain);
const result = await dnsManagerService.getHostnameWithRecords(
customDomain,
{ isPublicDomain: false },
);
expect(result).toEqual({
id: 'custom-id',
customDomain: customDomain,
domain: customDomain,
records: expect.any(Array),
});
expect(result?.records[0].value === 'https://front.domain');
});
it('should return public domain details', async () => {
const customDomain = 'example.com';
const mockResult = {
id: 'custom-id',
hostname: customDomain,
ownership_verification: {
type: 'txt',
name: 'ownership',
value: 'value',
},
ssl: {
validation_records: [{ txt_name: 'ssl', txt_value: 'validation' }],
},
verification_errors: [],
};
const cloudflareMock = {
customHostnames: {
list: jest.fn().mockResolvedValueOnce({ result: [mockResult] }),
},
};
jest.spyOn(twentyConfigService, 'get').mockReturnValue('test-zone-id');
jest
.spyOn(domainManagerService, 'getBaseUrl')
.mockReturnValue(new URL('https://front.domain'));
jest
.spyOn(domainManagerService, 'getPublicDomainUrl')
.mockReturnValue(new URL('https://front.public-domain'));
(dnsManagerService as any).cloudflareClient = cloudflareMock;
const result = await dnsManagerService.getHostnameWithRecords(
customDomain,
{ isPublicDomain: true },
);
expect(result).toEqual({
id: 'custom-id',
domain: customDomain,
records: expect.any(Array),
});
expect(result?.records[0].value === 'https://front.public-domain');
});
it('should throw an error if multiple results are found', async () => {
@@ -224,48 +277,53 @@ describe('CustomDomainService', () => {
};
jest.spyOn(twentyConfigService, 'get').mockReturnValue('test-zone-id');
(customDomainService as any).cloudflareClient = cloudflareMock;
(dnsManagerService as any).cloudflareClient = cloudflareMock;
await expect(
customDomainService.getCustomDomainDetails(customDomain),
dnsManagerService.getHostnameWithRecords(customDomain, {
isPublicDomain: false,
}),
).rejects.toThrow(Error);
});
});
describe('updateCustomDomain', () => {
describe('updateHostname', () => {
it('should update a custom domain and register a new one', async () => {
const fromHostname = 'old.com';
const toHostname = 'new.com';
jest
.spyOn(customDomainService, 'getCustomDomainDetails')
.mockResolvedValueOnce({ id: 'old-id' } as any);
.spyOn(dnsManagerService, 'getHostnameId')
.mockResolvedValueOnce('old-id');
jest
.spyOn(customDomainService, 'deleteCustomHostname')
.spyOn(dnsManagerService, 'deleteHostname')
.mockResolvedValueOnce(undefined);
const registerSpy = jest
.spyOn(customDomainService, 'registerCustomDomain')
.spyOn(dnsManagerService, 'registerHostname')
.mockResolvedValueOnce({} as unknown as CustomHostnameCreateResponse);
await customDomainService.updateCustomDomain(fromHostname, toHostname);
await dnsManagerService.updateHostname(fromHostname, toHostname);
expect(customDomainService.getCustomDomainDetails).toHaveBeenCalledWith(
expect(dnsManagerService.getHostnameId).toHaveBeenCalledWith(
fromHostname,
undefined,
);
expect(customDomainService.deleteCustomHostname).toHaveBeenCalledWith(
expect(dnsManagerService.deleteHostname).toHaveBeenCalledWith(
'old-id',
undefined,
);
expect(registerSpy).toHaveBeenCalledWith(toHostname);
expect(registerSpy).toHaveBeenCalledWith(toHostname, undefined);
});
});
describe('deleteCustomHostnameByHostnameSilently', () => {
describe('deleteHostnameSilently', () => {
it('should delete the custom hostname silently', async () => {
const customDomain = 'example.com';
jest
.spyOn(customDomainService, 'getCustomDomainDetails')
.mockResolvedValueOnce({ id: 'custom-id' } as any);
.spyOn(dnsManagerService, 'getHostnameId')
.mockResolvedValueOnce('custom-id');
const deleteMock = jest.fn();
const cloudflareMock = {
customHostnames: {
@@ -274,13 +332,12 @@ describe('CustomDomainService', () => {
};
jest.spyOn(twentyConfigService, 'get').mockReturnValue('test-zone-id');
(customDomainService as any).cloudflareClient = cloudflareMock;
(dnsManagerService as any).cloudflareClient = cloudflareMock;
await expect(
customDomainService.deleteCustomHostnameByHostnameSilently(
customDomain,
),
dnsManagerService.deleteHostnameSilently(customDomain),
).resolves.toBeUndefined();
expect(deleteMock).toHaveBeenCalledWith('custom-id', {
zone_id: 'test-zone-id',
});
@@ -290,13 +347,11 @@ describe('CustomDomainService', () => {
const customDomain = 'example.com';
jest
.spyOn(customDomainService, 'getCustomDomainDetails')
.spyOn(dnsManagerService, 'getHostnameId')
.mockRejectedValueOnce(new Error('Failure'));
await expect(
customDomainService.deleteCustomHostnameByHostnameSilently(
customDomain,
),
dnsManagerService.deleteHostnameSilently(customDomain),
).resolves.toBeUndefined();
});
});
@@ -0,0 +1,261 @@
/* @license Enterprise */
import { Injectable } from '@nestjs/common';
import Cloudflare from 'cloudflare';
import {
type CustomHostnameCreateParams,
type CustomHostnameListResponse,
} from 'cloudflare/resources/custom-hostnames/custom-hostnames';
import { isDefined } from 'twenty-shared/utils';
import {
DnsManagerException,
DnsManagerExceptionCode,
} from 'src/engine/core-modules/dns-manager/exceptions/dns-manager.exception';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { dnsManagerValidator } from 'src/engine/core-modules/dns-manager/validator/cloudflare.validate';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { type DomainValidRecords } from 'src/engine/core-modules/dns-manager/dtos/domain-valid-records';
type DnsManagerOptions = {
isPublicDomain?: boolean;
};
@Injectable()
export class DnsManagerService {
cloudflareClient?: Cloudflare;
constructor(
private readonly twentyConfigService: TwentyConfigService,
private readonly domainManagerService: DomainManagerService,
) {
if (this.twentyConfigService.get('CLOUDFLARE_API_KEY')) {
this.cloudflareClient = new Cloudflare({
apiToken: this.twentyConfigService.get('CLOUDFLARE_API_KEY'),
});
}
}
async registerHostname(customDomain: string, options?: DnsManagerOptions) {
dnsManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
if (isDefined(await this.getHostnameId(customDomain, options))) {
throw new DnsManagerException(
'Hostname already registered',
DnsManagerExceptionCode.HOSTNAME_ALREADY_REGISTERED,
{ userFriendlyMessage: 'Domain is already registered' },
);
}
return this.cloudflareClient.customHostnames.create({
zone_id: this.getZoneId(options),
hostname: customDomain,
ssl: this.sslParams,
});
}
async getHostnameWithRecords(
domain: string,
options?: DnsManagerOptions,
): Promise<DomainValidRecords | undefined> {
if (
options?.isPublicDomain &&
!isDefined(this.domainManagerService.getPublicDomainUrl().hostname)
) {
throw new DnsManagerException(
'Missing public domain URL',
DnsManagerExceptionCode.MISSING_PUBLIC_DOMAIN_URL,
{
userFriendlyMessage:
'Public domain URL is not defined. Please set the PUBLIC_DOMAIN_URL environment variable',
},
);
}
const customHostname = await this.getHostnameDetails(domain, options);
if (!isDefined(customHostname)) {
return undefined;
}
const { hostname, id, ssl } = customHostname;
const statuses = this.getHostnameStatuses(customHostname);
// @ts-expect-error - type definition doesn't reflect the real API
const dcvRecords = ssl?.dcv_delegation_records?.[0];
return {
id: id,
domain: hostname,
records: [
{
validationType: 'redirection' as const,
type: 'cname',
status: statuses.redirection,
key: hostname,
value: options?.isPublicDomain
? this.domainManagerService.getPublicDomainUrl().hostname
: this.domainManagerService.getBaseUrl().hostname,
},
{
validationType: 'ssl' as const,
type: 'cname',
status: statuses.ssl,
key: dcvRecords?.cname ?? `_acme-challenge.${hostname}`,
value:
dcvRecords?.cname_target ??
`${hostname}.${this.twentyConfigService.get('CLOUDFLARE_DCV_DELEGATION_ID')}.dcv.cloudflare.com`,
},
],
};
}
async updateHostname(
fromHostname: string,
toHostname: string,
options?: DnsManagerOptions,
) {
dnsManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
const fromCustomHostnameId = await this.getHostnameId(
fromHostname,
options,
);
if (fromCustomHostnameId) {
await this.deleteHostname(fromCustomHostnameId, options);
}
return this.registerHostname(toHostname, options);
}
async refreshHostname(
domainValidRecords: DomainValidRecords,
options?: DnsManagerOptions,
) {
dnsManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
await this.cloudflareClient.customHostnames.edit(domainValidRecords.id, {
zone_id: this.getZoneId(options),
ssl: this.sslParams,
});
}
async deleteHostnameSilently(hostname: string, options?: DnsManagerOptions) {
dnsManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
try {
const customHostnameId = await this.getHostnameId(hostname, options);
if (customHostnameId) {
await this.deleteHostname(customHostnameId, options);
}
} catch {
return;
}
}
async isHostnameWorking(hostname: string, options?: DnsManagerOptions) {
const hostnameDetails = await this.getHostnameDetails(hostname, options);
if (!isDefined(hostnameDetails)) {
return false;
}
const statuses = this.getHostnameStatuses(hostnameDetails);
return statuses.redirection === 'success' && statuses.ssl === 'success';
}
private get sslParams(): CustomHostnameCreateParams['ssl'] {
return {
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,
};
}
private getZoneId(options?: DnsManagerOptions): string {
return options?.isPublicDomain
? this.twentyConfigService.get('CLOUDFLARE_PUBLIC_DOMAIN_ZONE_ID')
: this.twentyConfigService.get('CLOUDFLARE_ZONE_ID');
}
private async getHostnameDetails(
hostname: string,
options?: DnsManagerOptions,
) {
dnsManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
const customHostnames = await this.cloudflareClient.customHostnames.list({
zone_id: this.getZoneId(options),
hostname: hostname,
});
if (customHostnames.result.length === 0) {
return undefined;
}
if (customHostnames.result.length === 1) {
return customHostnames.result[0];
}
// should never happen. error 5xx
throw new DnsManagerException(
'More than one custom hostname found in cloudflare',
DnsManagerExceptionCode.MULTIPLE_HOSTNAMES_FOUND,
{
userFriendlyMessage: `${customHostnames.result.length} hostnames found for domain '${hostname}'. Expect 1`,
},
);
}
async getHostnameId(hostname: string, options?: DnsManagerOptions) {
const customHostname = await this.getHostnameDetails(hostname, options);
if (!isDefined(customHostname)) {
return undefined;
}
return customHostname.id;
}
private getHostnameStatuses(customHostname: CustomHostnameListResponse) {
const { ssl, verification_errors, created_at } = customHostname;
return {
// wait 10s before starting the real check
redirection:
created_at &&
new Date().getTime() - new Date(created_at).getTime() < 1000 * 10
? 'pending'
: verification_errors?.[0] ===
'custom hostname does not CNAME to this zone.'
? 'error'
: 'success',
ssl:
!ssl.status || ssl.status.startsWith('pending')
? 'pending'
: ssl.status === 'active'
? 'success'
: ssl.status,
};
}
async deleteHostname(customHostnameId: string, options?: DnsManagerOptions) {
dnsManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
await this.cloudflareClient.customHostnames.delete(customHostnameId, {
zone_id: this.getZoneId(options),
});
}
}
@@ -3,25 +3,25 @@ import { t } from '@lingui/core/macro';
import type Cloudflare from 'cloudflare';
import {
DomainManagerException,
DomainManagerExceptionCode,
} from 'src/engine/core-modules/domain-manager/domain-manager.exception';
DnsManagerException,
DnsManagerExceptionCode,
} from 'src/engine/core-modules/dns-manager/exceptions/dns-manager.exception';
const isCloudflareInstanceDefined = (
cloudflareInstance: Cloudflare | undefined | null,
): asserts cloudflareInstance is Cloudflare => {
if (!cloudflareInstance) {
throw new DomainManagerException(
throw new DnsManagerException(
'Cloudflare instance is not defined',
DomainManagerExceptionCode.CLOUDFLARE_CLIENT_NOT_INITIALIZED,
DnsManagerExceptionCode.CLOUDFLARE_CLIENT_NOT_INITIALIZED,
{
userFriendlyMessage: t`Environnement variable CLOUDFLARE_API_KEY must be defined to use this feature.`,
userFriendlyMessage: t`Environment variable CLOUDFLARE_API_KEY must be defined to use this feature.`,
},
);
}
};
export const domainManagerValidator: {
export const dnsManagerValidator: {
isCloudflareInstanceDefined: typeof isCloudflareInstanceDefined;
} = {
isCloudflareInstanceDefined,
@@ -1,123 +0,0 @@
/* @license Enterprise */
import {
Controller,
Post,
Req,
Res,
UseFilters,
UseGuards,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Request, Response } from 'express';
import { Repository } from 'typeorm';
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-activated';
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
import {
DomainManagerException,
DomainManagerExceptionCode,
} from 'src/engine/core-modules/domain-manager/domain-manager.exception';
import { CloudflareSecretMatchGuard } from 'src/engine/core-modules/domain-manager/guards/cloudflare-secret.guard';
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
import { handleException } from 'src/engine/utils/global-exception-handler.util';
@Controller()
@UseFilters(AuthRestApiExceptionFilter)
export class CloudflareController {
constructor(
@InjectRepository(Workspace)
protected readonly workspaceRepository: Repository<Workspace>,
private readonly domainManagerService: DomainManagerService,
private readonly customDomainService: CustomDomainService,
private readonly exceptionHandlerService: ExceptionHandlerService,
private readonly auditService: AuditService,
) {}
@Post(['cloudflare/custom-hostname-webhooks', 'webhooks/cloudflare'])
@UseGuards(CloudflareSecretMatchGuard, PublicEndpointGuard)
async customHostnameWebhooks(@Req() req: Request, @Res() res: Response) {
try {
// Cloudflare documentation is inaccurate - some webhooks lack the hostname field.
// Fallback to extracting hostname from validation_records.
const hostname =
req.body?.data?.data?.hostname ??
req.body?.data?.data?.ssl?.validation_records?.[0]?.txt_name?.replace(
/^_acme-challenge\./,
'',
);
if (!hostname) {
handleException({
exception: new DomainManagerException(
'Hostname missing',
DomainManagerExceptionCode.INVALID_INPUT_DATA,
{ userFriendlyMessage: 'Hostname missing' },
),
exceptionHandlerService: this.exceptionHandlerService,
});
return res.status(200).send();
}
const workspace = await this.workspaceRepository.findOneBy({
customDomain: hostname,
});
if (!workspace) return;
const auditService = this.auditService.createContext({
workspaceId: workspace.id,
});
const customDomainDetails =
await this.customDomainService.getCustomDomainDetails(hostname);
const workspaceUpdated: Partial<Workspace> = {
customDomain: workspace.customDomain,
};
if (!customDomainDetails) {
workspaceUpdated.customDomain = null;
}
workspaceUpdated.isCustomDomainEnabled = customDomainDetails
? this.domainManagerService.isCustomDomainWorking(customDomainDetails)
: false;
if (
workspaceUpdated.isCustomDomainEnabled !==
workspace.isCustomDomainEnabled ||
workspaceUpdated.customDomain !== workspace.customDomain
) {
await this.workspaceRepository.save({
...workspace,
...workspaceUpdated,
});
await auditService.insertWorkspaceEvent(
CUSTOM_DOMAIN_ACTIVATED_EVENT,
{},
);
}
return res.status(200).send();
} catch (err) {
handleException({
exception: new DomainManagerException(
err.message ?? 'Unknown error occurred',
DomainManagerExceptionCode.INTERNAL_SERVER_ERROR,
{ userFriendlyMessage: 'Unknown error occurred' },
),
exceptionHandlerService: this.exceptionHandlerService,
});
return res.status(200).send();
}
}
}
@@ -1,218 +0,0 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { type Request, type Response } from 'express';
import { AuditContextMock } from 'test/utils/audit-context.mock';
import { type Repository } from 'typeorm';
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import { CloudflareController } from 'src/engine/core-modules/domain-manager/controllers/cloudflare.controller';
import { type CustomDomainValidRecords } from 'src/engine/core-modules/domain-manager/dtos/custom-domain-valid-records';
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
describe('CloudflareController - customHostnameWebhooks', () => {
let controller: CloudflareController;
let WorkspaceRepository: Repository<Workspace>;
let twentyConfigService: TwentyConfigService;
let domainManagerService: DomainManagerService;
let customDomainService: CustomDomainService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [CloudflareController],
providers: [
{
provide: getRepositoryToken(Workspace),
useValue: {
findOneBy: jest.fn(),
save: jest.fn(),
},
},
{
provide: DomainManagerService,
useValue: {
isCustomDomainWorking: jest.fn(),
},
},
{
provide: CustomDomainService,
useValue: {
getCustomDomainDetails: jest.fn(),
},
},
{
provide: HttpExceptionHandlerService,
useValue: {
handleError: jest.fn(),
},
},
{
provide: ExceptionHandlerService,
useValue: {
captureExceptions: jest.fn(),
},
},
{
provide: TwentyConfigService,
useValue: {
get: jest.fn(),
},
},
{
provide: AuditService,
useValue: {
createContext: AuditContextMock,
},
},
],
}).compile();
controller = module.get<CloudflareController>(CloudflareController);
WorkspaceRepository = module.get(getRepositoryToken(Workspace));
twentyConfigService = module.get<TwentyConfigService>(TwentyConfigService);
domainManagerService =
module.get<DomainManagerService>(DomainManagerService);
customDomainService = module.get<CustomDomainService>(CustomDomainService);
});
it('should handle exception and return status 200 if hostname is missing', async () => {
const req = {
headers: { 'cf-webhook-auth': 'correct-secret' },
body: { data: { data: {} } },
} as unknown as Request;
const sendMock = jest.fn();
const res = {
status: jest.fn().mockReturnThis(),
send: sendMock,
} as unknown as Response;
jest.spyOn(twentyConfigService, 'get').mockReturnValue('correct-secret');
await controller.customHostnameWebhooks(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(sendMock).toHaveBeenCalled();
});
it('should update workspace for a valid hostname and save changes', async () => {
const req = {
headers: { 'cf-webhook-auth': 'correct-secret' },
body: { data: { data: { hostname: 'example.com' } } },
} as unknown as Request;
const sendMock = jest.fn();
const res = {
status: jest.fn().mockReturnThis(),
send: sendMock,
} as unknown as Response;
jest.spyOn(twentyConfigService, 'get').mockReturnValue('correct-secret');
jest
.spyOn(customDomainService, 'getCustomDomainDetails')
.mockResolvedValue({
records: [
{
success: true,
},
],
} as unknown as CustomDomainValidRecords);
jest
.spyOn(domainManagerService, 'isCustomDomainWorking')
.mockReturnValue(true);
jest.spyOn(WorkspaceRepository, 'findOneBy').mockResolvedValue({
customDomain: 'example.com',
isCustomDomainEnabled: false,
} as Workspace);
await controller.customHostnameWebhooks(req, res);
expect(WorkspaceRepository.findOneBy).toHaveBeenCalledWith({
customDomain: 'example.com',
});
expect(customDomainService.getCustomDomainDetails).toHaveBeenCalledWith(
'example.com',
);
expect(WorkspaceRepository.save).toHaveBeenCalledWith({
customDomain: 'example.com',
isCustomDomainEnabled: true,
});
expect(res.status).toHaveBeenCalledWith(200);
expect(sendMock).toHaveBeenCalled();
});
it('should remove customDomain if no hostname found', async () => {
const req = {
headers: { 'cf-webhook-auth': 'correct-secret' },
body: { data: { data: { hostname: 'notfound.com' } } },
} as unknown as Request;
const sendMock = jest.fn();
const res = {
status: jest.fn().mockReturnThis(),
send: sendMock,
} as unknown as Response;
jest.spyOn(twentyConfigService, 'get').mockReturnValue('correct-secret');
jest.spyOn(WorkspaceRepository, 'findOneBy').mockResolvedValue({
customDomain: 'notfound.com',
isCustomDomainEnabled: true,
} as Workspace);
jest
.spyOn(customDomainService, 'getCustomDomainDetails')
.mockResolvedValue(undefined);
await controller.customHostnameWebhooks(req, res);
expect(WorkspaceRepository.findOneBy).toHaveBeenCalledWith({
customDomain: 'notfound.com',
});
expect(WorkspaceRepository.save).toHaveBeenCalledWith({
customDomain: null,
isCustomDomainEnabled: false,
});
expect(res.status).toHaveBeenCalledWith(200);
expect(sendMock).toHaveBeenCalled();
});
it('should do nothing if nothing changes', async () => {
const req = {
headers: { 'cf-webhook-auth': 'correct-secret' },
body: { data: { data: { hostname: 'nothing-change.com' } } },
} as unknown as Request;
const sendMock = jest.fn();
const res = {
status: jest.fn().mockReturnThis(),
send: sendMock,
} as unknown as Response;
jest.spyOn(twentyConfigService, 'get').mockReturnValue('correct-secret');
jest.spyOn(WorkspaceRepository, 'findOneBy').mockResolvedValue({
customDomain: 'nothing-change.com',
isCustomDomainEnabled: true,
} as Workspace);
jest
.spyOn(customDomainService, 'getCustomDomainDetails')
.mockResolvedValue({
records: [
{
success: true,
},
],
} as unknown as CustomDomainValidRecords);
jest
.spyOn(domainManagerService, 'isCustomDomainWorking')
.mockReturnValue(true);
await controller.customHostnameWebhooks(req, res);
expect(WorkspaceRepository.findOneBy).toHaveBeenCalledWith({
customDomain: 'nothing-change.com',
});
expect(WorkspaceRepository.save).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(200);
expect(sendMock).toHaveBeenCalled();
});
});
@@ -1,15 +0,0 @@
import {
appendCommonExceptionCode,
CustomException,
} from 'src/utils/custom-exception';
export class DomainManagerException extends CustomException<
keyof typeof DomainManagerExceptionCode,
true
> {}
export const DomainManagerExceptionCode = appendCommonExceptionCode({
CLOUDFLARE_CLIENT_NOT_INITIALIZED: 'CLOUDFLARE_CLIENT_NOT_INITIALIZED',
HOSTNAME_ALREADY_REGISTERED: 'HOSTNAME_ALREADY_REGISTERED',
INVALID_INPUT_DATA: 'INVALID_INPUT_DATA',
} as const);
@@ -1,29 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
import { CloudflareController } from 'src/engine/core-modules/domain-manager/controllers/cloudflare.controller';
import { CheckCustomDomainValidRecordsCronCommand } from 'src/engine/core-modules/domain-manager/crons/commands/check-custom-domain-valid-records.cron.command';
import { CheckCustomDomainValidRecordsCronJob } from 'src/engine/core-modules/domain-manager/crons/jobs/check-custom-domain-valid-records.cron.job';
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { DomainManagerResolver } from 'src/engine/core-modules/domain-manager/domain-manager.resolver';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
@Module({
imports: [AuditModule, TypeOrmModule.forFeature([Workspace])],
providers: [
DomainManagerResolver,
DomainManagerService,
CustomDomainService,
CheckCustomDomainValidRecordsCronJob,
CheckCustomDomainValidRecordsCronCommand,
],
exports: [
DomainManagerService,
CustomDomainService,
CheckCustomDomainValidRecordsCronCommand,
],
controllers: [CloudflareController],
imports: [TypeOrmModule.forFeature([Workspace]), AuditModule],
providers: [DomainManagerService],
exports: [DomainManagerService],
})
export class DomainManagerModule {}
@@ -1,23 +0,0 @@
import { Mutation, Resolver } from '@nestjs/graphql';
import { UseGuards, UsePipes } from '@nestjs/common';
import { CustomDomainValidRecords } from 'src/engine/core-modules/domain-manager/dtos/custom-domain-valid-records';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
@UsePipes(ResolverValidationPipe)
@Resolver()
export class DomainManagerResolver {
constructor(private readonly customDomainService: CustomDomainService) {}
@Mutation(() => CustomDomainValidRecords, { nullable: true })
@UseGuards(WorkspaceAuthGuard)
async checkCustomDomainValidRecords(
@AuthWorkspace() workspace: Workspace,
): Promise<CustomDomainValidRecords | undefined> {
return this.customDomainService.checkCustomDomainValidRecords(workspace);
}
}
@@ -1,216 +0,0 @@
/* @license Enterprise */
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import Cloudflare from 'cloudflare';
import { type CustomHostnameCreateParams } from 'cloudflare/resources/custom-hostnames/custom-hostnames';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-activated';
import { CUSTOM_DOMAIN_DEACTIVATED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-deactivated';
import {
DomainManagerException,
DomainManagerExceptionCode,
} from 'src/engine/core-modules/domain-manager/domain-manager.exception';
import { type CustomDomainValidRecords } from 'src/engine/core-modules/domain-manager/dtos/custom-domain-valid-records';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { domainManagerValidator } from 'src/engine/core-modules/domain-manager/validator/cloudflare.validate';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Injectable()
export class CustomDomainService {
cloudflareClient?: Cloudflare;
constructor(
private readonly twentyConfigService: TwentyConfigService,
private readonly domainManagerService: DomainManagerService,
private readonly auditService: AuditService,
@InjectRepository(Workspace)
private readonly workspaceRepository: Repository<Workspace>,
) {
if (this.twentyConfigService.get('CLOUDFLARE_API_KEY')) {
this.cloudflareClient = new Cloudflare({
apiToken: this.twentyConfigService.get('CLOUDFLARE_API_KEY'),
});
}
}
private get sslParams(): CustomHostnameCreateParams['ssl'] {
return {
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 registerCustomDomain(customDomain: string) {
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
if (isDefined(await this.getCustomDomainDetails(customDomain))) {
throw new DomainManagerException(
'Hostname already registered',
DomainManagerExceptionCode.HOSTNAME_ALREADY_REGISTERED,
{ userFriendlyMessage: 'Hostname already registered' },
);
}
return await this.cloudflareClient.customHostnames.create({
zone_id: this.twentyConfigService.get('CLOUDFLARE_ZONE_ID'),
hostname: customDomain,
ssl: this.sslParams,
});
}
async getCustomDomainDetails(
customDomain: string,
): Promise<CustomDomainValidRecords | undefined> {
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
const response = await this.cloudflareClient.customHostnames.list({
zone_id: this.twentyConfigService.get('CLOUDFLARE_ZONE_ID'),
hostname: customDomain,
});
if (response.result.length === 0) {
return undefined;
}
if (response.result.length === 1) {
const { hostname, id, ssl, verification_errors, created_at } =
response.result[0];
// @ts-expect-error - type definition doesn't reflect the real API
const dcvRecords = ssl?.dcv_delegation_records?.[0];
return {
id: id,
customDomain: hostname,
records: [
{
validationType: 'redirection' as const,
type: 'cname',
status:
// wait 10s before starting the real check
created_at &&
new Date().getTime() - new Date(created_at).getTime() < 1000 * 10
? 'pending'
: verification_errors?.[0] ===
'custom hostname does not CNAME to this zone.'
? 'error'
: 'success',
key: hostname,
value: this.domainManagerService.getBaseUrl().hostname,
},
{
validationType: 'ssl' as const,
type: 'cname',
status:
!ssl.status || ssl.status.startsWith('pending')
? 'pending'
: ssl.status === 'active'
? 'success'
: ssl.status,
key: dcvRecords?.cname ?? `_acme-challenge.${hostname}`,
value:
dcvRecords?.cname_target ??
`${hostname}.${this.twentyConfigService.get('CLOUDFLARE_DCV_DELEGATION_ID')}.dcv.cloudflare.com`,
},
],
};
}
// should never append. error 5xx
throw new Error('More than one custom hostname found in cloudflare');
}
async updateCustomDomain(fromHostname: string, toHostname: string) {
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
const fromCustomHostname = await this.getCustomDomainDetails(fromHostname);
if (fromCustomHostname) {
await this.deleteCustomHostname(fromCustomHostname.id);
}
return this.registerCustomDomain(toHostname);
}
async deleteCustomHostnameByHostnameSilently(customDomain: string) {
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
try {
const customHostname = await this.getCustomDomainDetails(customDomain);
if (customHostname) {
await this.cloudflareClient.customHostnames.delete(customHostname.id, {
zone_id: this.twentyConfigService.get('CLOUDFLARE_ZONE_ID'),
});
}
} catch {
return;
}
}
async deleteCustomHostname(customHostnameId: string) {
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
await this.cloudflareClient.customHostnames.delete(customHostnameId, {
zone_id: this.twentyConfigService.get('CLOUDFLARE_ZONE_ID'),
});
}
private async refreshCustomDomain(
customDomainDetails: CustomDomainValidRecords,
) {
domainManagerValidator.isCloudflareInstanceDefined(this.cloudflareClient);
await this.cloudflareClient.customHostnames.edit(customDomainDetails.id, {
zone_id: this.twentyConfigService.get('CLOUDFLARE_ZONE_ID'),
ssl: this.sslParams,
});
}
async checkCustomDomainValidRecords(workspace: Workspace) {
if (!workspace.customDomain) return;
const customDomainDetails = await this.getCustomDomainDetails(
workspace.customDomain,
);
if (!customDomainDetails) return;
await this.refreshCustomDomain(customDomainDetails);
const isCustomDomainWorking =
this.domainManagerService.isCustomDomainWorking(customDomainDetails);
if (workspace.isCustomDomainEnabled !== isCustomDomainWorking) {
workspace.isCustomDomainEnabled = isCustomDomainWorking;
await this.workspaceRepository.save(workspace);
const auditService = this.auditService.createContext({
workspaceId: workspace.id,
});
auditService.insertWorkspaceEvent(
workspace.isCustomDomainEnabled
? CUSTOM_DOMAIN_ACTIVATED_EVENT
: CUSTOM_DOMAIN_DEACTIVATED_EVENT,
{},
);
}
return customDomainDetails;
}
}
@@ -5,6 +5,7 @@ import { Repository } from 'typeorm';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import { DomainManagerService } from './domain-manager.service';
@@ -76,6 +77,12 @@ describe('DomainManagerService', () => {
get: jest.fn(),
},
},
{
provide: AuditService,
useValue: {
createContext: jest.fn(),
},
},
],
}).compile();
@@ -5,13 +5,14 @@ import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { type WorkspaceSubdomainCustomDomainAndIsCustomDomainEnabledType } from 'src/engine/core-modules/domain-manager/domain-manager.type';
import { type CustomDomainValidRecords } from 'src/engine/core-modules/domain-manager/dtos/custom-domain-valid-records';
import { generateRandomSubdomain } from 'src/engine/core-modules/domain-manager/utils/generate-random-subdomain';
import { getSubdomainFromEmail } from 'src/engine/core-modules/domain-manager/utils/get-subdomain-from-email';
import { getSubdomainNameFromDisplayName } from 'src/engine/core-modules/domain-manager/utils/get-subdomain-name-from-display-name';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { workspaceValidator } from 'src/engine/core-modules/workspace/workspace.validate';
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-activated';
@Injectable()
export class DomainManagerService {
@@ -19,6 +20,7 @@ export class DomainManagerService {
@InjectRepository(Workspace)
private readonly workspaceRepository: Repository<Workspace>,
private readonly twentyConfigService: TwentyConfigService,
private readonly auditService: AuditService,
) {}
getFrontUrl() {
@@ -41,6 +43,10 @@ export class DomainManagerService {
return baseUrl;
}
getPublicDomainUrl(): URL {
return new URL(this.twentyConfigService.get('PUBLIC_DOMAIN_URL'));
}
private appendSearchParams(
url: URL,
searchParams: Record<string, string | number | boolean>,
@@ -236,12 +242,6 @@ export class DomainManagerService {
return workspace;
}
isCustomDomainWorking(customDomainDetails: CustomDomainValidRecords) {
return customDomainDetails.records.every(
({ status }) => status === 'success',
);
}
getWorkspaceUrls({
subdomain,
customDomain,
@@ -255,4 +255,45 @@ export class DomainManagerService {
subdomainUrl: this.getTwentyWorkspaceUrl(subdomain),
};
}
async handleCustomDomainActivation({
customDomain,
isCustomDomainWorking,
}: {
customDomain: string;
isCustomDomainWorking: boolean;
}) {
const workspace = await this.workspaceRepository.findOneBy({
customDomain,
});
if (!workspace) return;
const analytics = this.auditService.createContext({
workspaceId: workspace.id,
});
const workspaceUpdated: Partial<Workspace> = {
customDomain: workspace.customDomain,
};
if (!isCustomDomainWorking) {
workspaceUpdated.customDomain = null;
}
workspaceUpdated.isCustomDomainEnabled = isCustomDomainWorking;
if (
workspaceUpdated.isCustomDomainEnabled !==
workspace.isCustomDomainEnabled ||
workspaceUpdated.customDomain !== workspace.customDomain
) {
await this.workspaceRepository.save({
...workspace,
...workspaceUpdated,
});
await analytics.insertWorkspaceEvent(CUSTOM_DOMAIN_ACTIVATED_EVENT, {});
}
}
}
@@ -0,0 +1,20 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('PublicDomain')
export class PublicDomainDTO {
@IDField(() => UUIDScalarType)
id: string;
@Field({ nullable: false })
domain: string;
@Field({ nullable: false })
isValidated: boolean;
@Field()
createdAt: Date;
}
@@ -0,0 +1,11 @@
import { Field, ArgsType } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
@ArgsType()
export class PublicDomainInput {
@Field(() => String)
@IsString()
@IsNotEmpty()
domain: string;
}
@@ -0,0 +1,22 @@
import { Catch, ExceptionFilter } from '@nestjs/common';
import { assertUnreachable } from 'twenty-shared/utils';
import {
PublicDomainException,
PublicDomainExceptionCode,
} from 'src/engine/core-modules/public-domain/public-domain.exception';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
@Catch(PublicDomainException)
export class PublicDomainExceptionFilter implements ExceptionFilter {
catch(exception: PublicDomainException) {
switch (exception.code) {
case PublicDomainExceptionCode.PUBLIC_DOMAIN_ALREADY_REGISTERED:
case PublicDomainExceptionCode.DOMAIN_ALREADY_REGISTERED_AS_CUSTOM_DOMAIN:
throw new UserInputError(exception);
default:
assertUnreachable(exception.code);
}
}
}
@@ -0,0 +1,43 @@
import { ObjectType } from '@nestjs/graphql';
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Entity({ name: 'publicDomain', schema: 'core' })
@ObjectType()
export class PublicDomain {
@PrimaryGeneratedColumn('uuid')
id: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@Column({ type: 'varchar', nullable: false, unique: true })
domain: string;
@Column({ type: 'boolean', default: false, nullable: false })
isValidated: boolean;
@Column({ nullable: false, type: 'uuid' })
workspaceId: string;
@ManyToOne(() => Workspace, (workspace) => workspace.publicDomains, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'workspaceId' })
workspace: Relation<Workspace>;
}
@@ -0,0 +1,8 @@
import { CustomException } from 'src/utils/custom-exception';
export class PublicDomainException extends CustomException<PublicDomainExceptionCode> {}
export enum PublicDomainExceptionCode {
PUBLIC_DOMAIN_ALREADY_REGISTERED = 'PUBLIC_DOMAIN_ALREADY_REGISTERED',
DOMAIN_ALREADY_REGISTERED_AS_CUSTOM_DOMAIN = 'DOMAIN_ALREADY_REGISTERED_AS_CUSTOM_DOMAIN',
}
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { PublicDomainService } from 'src/engine/core-modules/public-domain/public-domain.service';
import { PublicDomain } from 'src/engine/core-modules/public-domain/public-domain.entity';
import { PublicDomainResolver } from 'src/engine/core-modules/public-domain/public-domain.resolver';
import { DnsManagerModule } from 'src/engine/core-modules/dns-manager/dns-manager.module';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Module({
imports: [
NestjsQueryTypeOrmModule.forFeature([PublicDomain, Workspace]),
DnsManagerModule,
],
providers: [PublicDomainService, PublicDomainResolver],
})
export class PublicDomainModule {}
@@ -0,0 +1,47 @@
import { Args, Mutation, Resolver } from '@nestjs/graphql';
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { PublicDomainExceptionFilter } from 'src/engine/core-modules/public-domain/public-domain-exception-filter';
import { PublicDomainService } from 'src/engine/core-modules/public-domain/public-domain.service';
import { PublicDomainDTO } from 'src/engine/core-modules/public-domain/dtos/public-domain.dto';
import { PublicDomainInput } from 'src/engine/core-modules/public-domain/dtos/public-domain.input';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@UseGuards(WorkspaceAuthGuard)
@UsePipes(ResolverValidationPipe)
@UseFilters(
PublicDomainExceptionFilter,
PreventNestToAutoLogGraphqlErrorsFilter,
)
@Resolver()
export class PublicDomainResolver {
constructor(private readonly publicDomainService: PublicDomainService) {}
@Mutation(() => PublicDomainDTO)
async createPublicDomain(
@Args() { domain }: PublicDomainInput,
@AuthWorkspace() currentWorkspace: Workspace,
): Promise<PublicDomainDTO> {
return this.publicDomainService.createPublicDomain({
domain,
workspace: currentWorkspace,
});
}
@Mutation(() => Boolean)
async deletePublicDomain(
@Args() { domain }: PublicDomainInput,
@AuthWorkspace() currentWorkspace: Workspace,
): Promise<boolean> {
await this.publicDomainService.deletePublicDomain({
domain,
workspace: currentWorkspace,
});
return true;
}
}
@@ -0,0 +1,104 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { t } from '@lingui/core/macro';
import { Repository } from 'typeorm';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { PublicDomainDTO } from 'src/engine/core-modules/public-domain/dtos/public-domain.dto';
import { PublicDomain } from 'src/engine/core-modules/public-domain/public-domain.entity';
import {
PublicDomainException,
PublicDomainExceptionCode,
} from 'src/engine/core-modules/public-domain/public-domain.exception';
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
@Injectable()
export class PublicDomainService {
constructor(
private readonly dnsManagerService: DnsManagerService,
@InjectRepository(PublicDomain)
private readonly publicDomainRepository: Repository<PublicDomain>,
@InjectRepository(Workspace)
private readonly workspaceRepository: Repository<Workspace>,
) {}
async deletePublicDomain({
domain,
workspace,
}: {
domain: string;
workspace: Workspace;
}): Promise<void> {
const formattedDomain = domain.trim().toLowerCase();
await this.dnsManagerService.deleteHostnameSilently(formattedDomain, {
isPublicDomain: true,
});
await this.publicDomainRepository.delete({
domain: formattedDomain,
workspaceId: workspace.id,
});
}
async createPublicDomain({
domain,
workspace,
}: {
domain: string;
workspace: Workspace;
}): Promise<PublicDomainDTO> {
const formattedDomain = domain.trim().toLowerCase();
if (
await this.workspaceRepository.findOneBy({
customDomain: formattedDomain,
})
) {
throw new PublicDomainException(
'Domain already used for workspace custom domain',
PublicDomainExceptionCode.DOMAIN_ALREADY_REGISTERED_AS_CUSTOM_DOMAIN,
{
userFriendlyMessage: t`Domain already used for workspace custom domain`,
},
);
}
if (
await this.publicDomainRepository.findOneBy({
domain: formattedDomain,
workspaceId: workspace.id,
})
) {
throw new PublicDomainException(
'Public domain already registered',
PublicDomainExceptionCode.PUBLIC_DOMAIN_ALREADY_REGISTERED,
{
userFriendlyMessage: t`Public domain already registered`,
},
);
}
const publicDomain = this.publicDomainRepository.create({
domain: formattedDomain,
workspaceId: workspace.id,
});
await this.dnsManagerService.registerHostname(formattedDomain, {
isPublicDomain: true,
});
try {
await this.publicDomainRepository.insert(publicDomain);
} catch (error) {
await this.dnsManagerService.deleteHostnameSilently(formattedDomain, {
isPublicDomain: true,
});
throw error;
}
return publicDomain;
}
}
@@ -892,6 +892,15 @@ export class ConfigVariables {
@IsOptional()
SERVER_URL = 'http://localhost:3000';
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ServerConfig,
description: 'Base URL for public domains',
type: ConfigVariableType.STRING,
})
@IsUrl({ require_tld: false, require_protocol: true })
@IsOptional()
PUBLIC_DOMAIN_URL: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ServerConfig,
isSensitive: true,
@@ -963,6 +972,15 @@ export class ConfigVariables {
@ValidateIf((env) => env.CLOUDFLARE_API_KEY)
CLOUDFLARE_ZONE_ID: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ServerConfig,
description: 'Base URL for public domains',
type: ConfigVariableType.STRING,
})
@IsUrl({ require_tld: false, require_protocol: true })
@ValidateIf((env) => env.PUBLIC_DOMAIN_URL)
CLOUDFLARE_PUBLIC_DOMAIN_ZONE_ID: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.Other,
description: 'Random string to validate queries from Cloudflare',
@@ -6,7 +6,6 @@ import { type Repository } from 'typeorm';
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { EmailService } from 'src/engine/core-modules/email/email.service';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
@@ -26,6 +25,8 @@ import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
import { PublicDomain } from 'src/engine/core-modules/public-domain/public-domain.entity';
describe('WorkspaceService', () => {
let service: WorkspaceService;
@@ -34,7 +35,7 @@ describe('WorkspaceService', () => {
let workspaceRepository: Repository<Workspace>;
let workspaceCacheStorageService: WorkspaceCacheStorageService;
let messageQueueService: MessageQueueService;
let customDomainService: CustomDomainService;
let dnsManagerService: DnsManagerService;
let billingSubscriptionService: BillingSubscriptionService;
beforeEach(async () => {
@@ -49,6 +50,12 @@ describe('WorkspaceService', () => {
delete: jest.fn(),
},
},
{
provide: getRepositoryToken(PublicDomain),
useValue: {
findOneBy: jest.fn(),
},
},
{
provide: getRepositoryToken(UserWorkspace),
useValue: {
@@ -86,7 +93,7 @@ describe('WorkspaceService', () => {
UserWorkspaceService,
UserService,
DomainManagerService,
CustomDomainService,
DnsManagerService,
TwentyConfigService,
EmailService,
OnboardingService,
@@ -128,8 +135,8 @@ describe('WorkspaceService', () => {
messageQueueService = module.get<MessageQueueService>(
getQueueToken(MessageQueue.deleteCascadeQueue),
);
customDomainService = module.get<CustomDomainService>(CustomDomainService);
customDomainService.deleteCustomHostnameByHostnameSilently = jest.fn();
dnsManagerService = module.get<DnsManagerService>(DnsManagerService);
dnsManagerService.deleteHostnameSilently = jest.fn();
billingSubscriptionService = module.get<BillingSubscriptionService>(
BillingSubscriptionService,
);
@@ -267,9 +274,9 @@ describe('WorkspaceService', () => {
await service.deleteWorkspace(mockWorkspace.id, false);
expect(
customDomainService.deleteCustomHostnameByHostnameSilently,
).toHaveBeenCalledWith(customDomain);
expect(dnsManagerService.deleteHostnameSilently).toHaveBeenCalledWith(
customDomain,
);
expect(workspaceRepository.delete).toHaveBeenCalledWith(mockWorkspace.id);
});
@@ -288,9 +295,7 @@ describe('WorkspaceService', () => {
await service.deleteWorkspace(mockWorkspace.id, true);
expect(
customDomainService.deleteCustomHostnameByHostnameSilently,
).not.toHaveBeenCalled();
expect(dnsManagerService.deleteHostnameSilently).not.toHaveBeenCalled();
expect(workspaceRepository.softDelete).toHaveBeenCalledWith({
id: mockWorkspace.id,
});
@@ -1,16 +1,17 @@
import { Command, CommandRunner } from 'nest-commander';
import {
CHECK_CUSTOM_DOMAIN_VALID_RECORDS_CRON_PATTERN,
CheckCustomDomainValidRecordsCronJob,
} from 'src/engine/core-modules/domain-manager/crons/jobs/check-custom-domain-valid-records.cron.job';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import {
CHECK_CUSTOM_DOMAIN_VALID_RECORDS_CRON_PATTERN,
CheckCustomDomainValidRecordsCronJob,
} from 'src/engine/core-modules/workspace/crons/jobs/check-custom-domain-valid-records.cron.job';
@Command({
name: 'cron:domain-manager:check-custom-domain-valid-records',
description: 'Starts a cron job to check custom domain valid records hourly',
name: 'cron:workspace:check-custom-domain-valid-records',
description:
'Starts a cron job to check workspace custom domain valid records hourly',
})
export class CheckCustomDomainValidRecordsCronCommand extends CommandRunner {
constructor(
@@ -7,8 +7,8 @@ import { Process } from 'src/engine/core-modules/message-queue/decorators/proces
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
export const CHECK_CUSTOM_DOMAIN_VALID_RECORDS_CRON_PATTERN = '0 * * * *';
@@ -17,7 +17,7 @@ export class CheckCustomDomainValidRecordsCronJob {
constructor(
@InjectRepository(Workspace)
private readonly workspaceRepository: Repository<Workspace>,
private readonly customDomainService: CustomDomainService,
private readonly workspaceService: WorkspaceService,
) {}
@Process(CheckCustomDomainValidRecordsCronJob.name)
@@ -39,7 +39,7 @@ export class CheckCustomDomainValidRecordsCronJob {
for (const workspace of workspaces) {
try {
await this.customDomainService.checkCustomDomainValidRecords(workspace);
await this.workspaceService.checkCustomDomainValidRecords(workspace);
} catch (error) {
throw new Error(
`[${CheckCustomDomainValidRecordsCronJob.name}] Cannot check custom domain for workspaces: ${error.message}`,
@@ -7,11 +7,11 @@ import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { isDefined } from 'twenty-shared/utils';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { Repository } from 'typeorm';
import { t } from '@lingui/core/macro';
import { BillingEntitlementKey } from 'src/engine/core-modules/billing/enums/billing-entitlement-key.enum';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { CustomDomainService } from 'src/engine/core-modules/domain-manager/services/custom-domain.service';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import {
@@ -43,6 +43,11 @@ import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage
import { WorkspaceManagerService } from 'src/engine/workspace-manager/workspace-manager.service';
import { DEFAULT_FEATURE_FLAGS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/default-feature-flags';
import { extractVersionMajorMinorPatch } from 'src/utils/version/extract-version-major-minor-patch';
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
import { CUSTOM_DOMAIN_ACTIVATED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-activated';
import { CUSTOM_DOMAIN_DEACTIVATED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/custom-domain/custom-domain-deactivated';
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import { PublicDomain } from 'src/engine/core-modules/public-domain/public-domain.entity';
@Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
@@ -53,6 +58,8 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
constructor(
@InjectRepository(Workspace)
private readonly workspaceRepository: Repository<Workspace>,
@InjectRepository(PublicDomain)
private readonly publicDomainRepository: Repository<PublicDomain>,
@InjectRepository(User)
private readonly userRepository: Repository<User>,
@InjectRepository(UserWorkspace)
@@ -65,8 +72,9 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
private readonly twentyConfigService: TwentyConfigService,
private readonly exceptionHandlerService: ExceptionHandlerService,
private readonly permissionsService: PermissionsService,
private readonly customDomainService: CustomDomainService,
private readonly dnsManagerService: DnsManagerService,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
private readonly auditService: AuditService,
@InjectMessageQueue(MessageQueue.deleteCascadeQueue)
private readonly messageQueueService: MessageQueueService,
) {
@@ -117,22 +125,30 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
}
if (
customDomain &&
workspace.customDomain !== customDomain &&
isDefined(workspace.customDomain)
await this.publicDomainRepository.findOneBy({
domain: customDomain,
})
) {
await this.customDomainService.updateCustomDomain(
workspace.customDomain,
customDomain,
throw new WorkspaceException(
'Domain is already registered as public domain',
WorkspaceExceptionCode.DOMAIN_ALREADY_TAKEN,
{
userFriendlyMessage: t`Domain is already registered as public domain`,
},
);
}
if (
customDomain &&
workspace.customDomain !== customDomain &&
!isDefined(workspace.customDomain)
) {
await this.customDomainService.registerCustomDomain(customDomain);
if (!isDefined(customDomain) || workspace.customDomain === customDomain) {
return;
}
if (isDefined(workspace.customDomain)) {
await this.dnsManagerService.updateHostname(
workspace.customDomain,
customDomain,
);
} else {
await this.dnsManagerService.registerHostname(customDomain);
}
}
@@ -173,7 +189,7 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
let customDomainRegistered = false;
if (payload.customDomain === null && isDefined(workspace.customDomain)) {
await this.customDomainService.deleteCustomHostnameByHostnameSilently(
await this.dnsManagerService.deleteHostnameSilently(
workspace.customDomain,
);
workspace.isCustomDomainEnabled = false;
@@ -220,8 +236,8 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
} catch (error) {
// revert custom domain registration on error
if (payload.customDomain && customDomainRegistered) {
this.customDomainService
.deleteCustomHostnameByHostnameSilently(payload.customDomain)
this.dnsManagerService
.deleteHostnameSilently(payload.customDomain)
.catch((err) => {
this.exceptionHandlerService.captureExceptions([err]);
});
@@ -347,7 +363,7 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
);
if (workspace.customDomain) {
await this.customDomainService.deleteCustomHostnameByHostnameSilently(
await this.dnsManagerService.deleteHostnameSilently(
workspace.customDomain,
);
this.logger.log(`workspace ${id} custom domain deleted`);
@@ -487,4 +503,39 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
}
}
}
async checkCustomDomainValidRecords(workspace: Workspace) {
if (!workspace.customDomain) return;
const customDomainWithRecords =
await this.dnsManagerService.getHostnameWithRecords(
workspace.customDomain,
);
if (!customDomainWithRecords) return;
await this.dnsManagerService.refreshHostname(customDomainWithRecords);
const isCustomDomainWorking =
await this.dnsManagerService.isHostnameWorking(workspace.customDomain);
if (workspace.isCustomDomainEnabled !== isCustomDomainWorking) {
workspace.isCustomDomainEnabled = isCustomDomainWorking;
await this.workspaceRepository.save(workspace);
const analytics = this.auditService.createContext({
workspaceId: workspace.id,
});
analytics.insertWorkspaceEvent(
workspace.isCustomDomainEnabled
? CUSTOM_DOMAIN_ACTIVATED_EVENT
: CUSTOM_DOMAIN_DEACTIVATED_EVENT,
{},
);
}
return customDomainWithRecords;
}
}
@@ -6,8 +6,7 @@ import {
import { UpdateWorkspaceInput } from 'src/engine/core-modules/workspace/dtos/update-workspace-input';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { Workspace } from './workspace.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
export const workspaceAutoResolverOpts: AutoResolverOpts<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -41,6 +41,7 @@ import { AgentHandoffEntity } from 'src/engine/metadata-modules/agent/agent-hand
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { AgentDTO } from 'src/engine/metadata-modules/agent/dtos/agent.dto';
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
import { PublicDomain } from 'src/engine/core-modules/public-domain/public-domain.entity';
registerEnumType(WorkspaceActivationStatus, {
name: 'WorkspaceActivationStatus',
@@ -115,6 +116,9 @@ export class Workspace {
)
approvedAccessDomains: Relation<ApprovedAccessDomain[]>;
@OneToMany(() => PublicDomain, (publicDomain) => publicDomain.workspace)
publicDomains: Relation<PublicDomain[]>;
@Field({ nullable: true })
workspaceMembersCount: number;
@@ -8,7 +8,6 @@ import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
@@ -26,11 +25,15 @@ import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module';
import { workspaceAutoResolverOpts } from './workspace.auto-resolver-opts';
import { Workspace } from './workspace.entity';
import { WorkspaceService } from './services/workspace.service';
import { DnsManagerModule } from 'src/engine/core-modules/dns-manager/dns-manager.module';
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
import { CheckCustomDomainValidRecordsCronJob } from 'src/engine/core-modules/workspace/crons/jobs/check-custom-domain-valid-records.cron.job';
import { CheckCustomDomainValidRecordsCronCommand } from 'src/engine/core-modules/workspace/crons/commands/check-custom-domain-valid-records.cron.command';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { workspaceAutoResolverOpts } from 'src/engine/core-modules/workspace/workspace.auto-resolver-opts';
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
import { PublicDomain } from 'src/engine/core-modules/public-domain/public-domain.entity';
@Module({
imports: [
@@ -38,12 +41,18 @@ import { WorkspaceService } from './services/workspace.service';
TypeOrmModule.forFeature([BillingSubscription]),
NestjsQueryGraphQLModule.forFeature({
imports: [
AuditModule,
BillingModule,
FileModule,
TokenModule,
FileUploadModule,
WorkspaceMetadataCacheModule,
NestjsQueryTypeOrmModule.forFeature([User, Workspace, UserWorkspace]),
NestjsQueryTypeOrmModule.forFeature([
User,
Workspace,
UserWorkspace,
PublicDomain,
]),
UserWorkspaceModule,
WorkspaceManagerModule,
FeatureFlagModule,
@@ -54,6 +63,7 @@ import { WorkspaceService } from './services/workspace.service';
WorkspaceCacheStorageModule,
RoleModule,
AgentModule,
DnsManagerModule,
DomainManagerModule,
CoreViewModule,
],
@@ -61,11 +71,13 @@ import { WorkspaceService } from './services/workspace.service';
resolvers: workspaceAutoResolverOpts,
}),
],
exports: [WorkspaceService],
exports: [WorkspaceService, CheckCustomDomainValidRecordsCronCommand],
providers: [
WorkspaceResolver,
WorkspaceService,
WorkspaceWorkspaceMemberListener,
CheckCustomDomainValidRecordsCronCommand,
CheckCustomDomainValidRecordsCronJob,
],
})
export class WorkspaceModule {}
@@ -65,10 +65,9 @@ import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
import { RoleService } from 'src/engine/metadata-modules/role/role.service';
import { getRequest } from 'src/utils/extract-request';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
import { Workspace } from './workspace.entity';
import { WorkspaceService } from './services/workspace.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
import { DomainValidRecords } from 'src/engine/core-modules/dns-manager/dtos/domain-valid-records';
const OriginHeader = createParamDecorator(
(_: unknown, ctx: ExecutionContext) => {
@@ -387,4 +386,12 @@ export class WorkspaceResolver {
workspaceGraphqlApiExceptionHandler(err);
}
}
@Mutation(() => DomainValidRecords, { nullable: true })
@UseGuards(WorkspaceAuthGuard)
async checkCustomDomainValidRecords(
@AuthWorkspace() workspace: Workspace,
): Promise<DomainValidRecords | undefined> {
return this.workspaceService.checkCustomDomainValidRecords(workspace);
}
}