feat(domain-manager): refactor custom domain validation and improve c… (#13388)

This commit is contained in:
Antoine Moreaux 2025-08-01 09:01:27 +02:00 committed by GitHub
parent 51340f2b0e
commit 23353e31e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
87 changed files with 779 additions and 901 deletions

View file

@ -4,7 +4,6 @@ import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { Controller, useFormContext } from 'react-hook-form';
import { SettingsCustomDomainRecords } from '~/pages/settings/workspace/SettingsCustomDomainRecords';
import { SettingsCustomDomainRecordsStatus } from '~/pages/settings/workspace/SettingsCustomDomainRecordsStatus';
import { customDomainRecordsState } from '~/pages/settings/workspace/states/customDomainRecordsState';
import { useRecoilValue } from 'recoil';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
@ -79,33 +78,31 @@ export const SettingsCustomDomain = () => {
/>
)}
/>
<StyledButtonGroup>
<StyledButton
isLoading={isLoading}
Icon={IconReload}
title={t`Reload`}
variant="primary"
onClick={checkCustomDomainRecords}
type="button"
/>
<StyledButton
Icon={IconTrash}
variant="primary"
onClick={deleteCustomDomain}
/>
</StyledButtonGroup>
{currentWorkspace?.customDomain && (
<StyledButtonGroup>
<StyledButton
isLoading={isLoading}
Icon={IconReload}
title={t`Reload`}
variant="primary"
onClick={checkCustomDomainRecords}
type="button"
/>
<StyledButton
Icon={IconTrash}
variant="primary"
onClick={deleteCustomDomain}
/>
</StyledButtonGroup>
)}
</StyledDomainFormWrapper>
{currentWorkspace?.customDomain && (
<StyledRecordsWrapper>
<SettingsCustomDomainRecordsStatus />
{customDomainRecords &&
customDomainRecords.records.some(
(record) => record.status !== 'success',
) && (
<SettingsCustomDomainRecords
records={customDomainRecords.records}
/>
)}
{customDomainRecords && (
<SettingsCustomDomainRecords
records={customDomainRecords.records}
/>
)}
</StyledRecordsWrapper>
)}
</Section>

View file

@ -6,8 +6,17 @@ import { TableRow } from '@/ui/layout/table/components/TableRow';
import styled from '@emotion/styled';
import { Button } from 'twenty-ui/input';
import { useDebouncedCallback } from 'use-debounce';
import { CustomDomainValidRecords } from '~/generated/graphql';
import {
CustomDomainRecord,
CustomDomainValidRecords,
} from '~/generated/graphql';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
import { capitalize } from 'twenty-shared/utils';
import { useRecoilValue } from 'recoil';
import { customDomainRecordsState } from '~/pages/settings/workspace/states/customDomainRecordsState';
import { ThemeColor } from 'twenty-ui/theme';
import { Status } from 'twenty-ui/display';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
const StyledTable = styled(Table)`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
@ -42,6 +51,9 @@ export const SettingsCustomDomainRecords = ({
}: {
records: CustomDomainValidRecords['records'];
}) => {
const { customDomainRecords } = useRecoilValue(customDomainRecordsState);
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const { copyToClipboard } = useCopyToClipboard();
const copyToClipboardDebounced = useDebouncedCallback(
@ -49,43 +61,82 @@ export const SettingsCustomDomainRecords = ({
200,
);
const rowsDefinitions = [
{ name: 'Domain Setup', validationType: 'redirection' as const },
{ name: 'Secure Connection', validationType: 'ssl' as const },
];
const defaultValues: { status: string; color: ThemeColor } =
currentWorkspace?.customDomain === customDomainRecords?.customDomain
? {
status: 'success',
color: 'green',
}
: {
status: 'loading',
color: 'gray',
};
const rows = rowsDefinitions.map<
{ name: string; status: string; color: ThemeColor } & CustomDomainRecord
>((row) => {
const record = records.find(
({ validationType }) => validationType === row.validationType,
);
if (!record) {
throw new Error(`Record ${row.name} not found`);
}
return {
name: row.name,
color:
record && record.status === 'error'
? 'red'
: record && record.status === 'pending'
? 'yellow'
: defaultValues.color,
...record,
};
});
return (
<StyledTable>
<TableRow gridAutoColumns="35% 16% auto">
<TableRow gridAutoColumns="30% 16% 38% 16%">
<TableHeader>Name</TableHeader>
<TableHeader>Type</TableHeader>
<TableHeader>Value</TableHeader>
<TableHeader></TableHeader>
</TableRow>
<TableBody>
{records
.filter((record) => record.status !== 'success')
.map((record) => (
<TableRow gridAutoColumns="30% 16% auto" key={record.key}>
<StyledTableCell>
<StyledButton
title={record.key}
onClick={() => copyToClipboardDebounced(record.key)}
type="button"
/>
</StyledTableCell>
<StyledTableCell>
<StyledButton
title={record.type.toUpperCase()}
onClick={() =>
copyToClipboardDebounced(record.type.toUpperCase())
}
type="button"
/>
</StyledTableCell>
<StyledTableCell>
<StyledButton
title={record.value}
onClick={() => copyToClipboardDebounced(record.value)}
type="button"
/>
</StyledTableCell>
</TableRow>
))}
{rows.map((row) => (
<TableRow gridAutoColumns="30% 16% 38% 16%" key={row.name}>
<StyledTableCell>
<StyledButton
title={row.key}
onClick={() => copyToClipboardDebounced(row.key)}
type="button"
/>
</StyledTableCell>
<StyledTableCell>
<StyledButton
title={row.type.toUpperCase()}
onClick={() => copyToClipboardDebounced(row.type.toUpperCase())}
type="button"
/>
</StyledTableCell>
<StyledTableCell>
<StyledButton
title={row.value}
onClick={() => copyToClipboardDebounced(row.value)}
type="button"
/>
</StyledTableCell>
<StyledTableCell>
<Status color={row.color} text={capitalize(row.status)} />
</StyledTableCell>
</TableRow>
))}
</TableBody>
</StyledTable>
);

View file

@ -1,88 +0,0 @@
import { Table } from '@/ui/layout/table/components/Table';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import styled from '@emotion/styled';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { customDomainRecordsState } from '~/pages/settings/workspace/states/customDomainRecordsState';
import { useRecoilValue } from 'recoil';
import { capitalize } from 'twenty-shared/utils';
import { Status } from 'twenty-ui/display';
import { ThemeColor } from 'twenty-ui/theme';
const StyledTable = styled(Table)`
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.light};
padding: 0 ${({ theme }) => theme.spacing(2)};
border-radius: ${({ theme }) => theme.border.radius.sm};
`;
const StyledTableRow = styled(TableRow)`
display: flex;
border-bottom: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: 0;
align-items: center;
justify-content: space-between;
padding: 0;
font-size: ${({ theme }) => theme.font.size.sm};
&:last-child {
border-bottom: none;
}
`;
const StyledTableCell = styled(TableCell)`
padding: 0;
`;
const records = [
{ name: 'CNAME', validationType: 'redirection' as const },
{ name: 'TXT Validation', validationType: 'ownership' as const },
{ name: 'SSL Certificate Generation', validationType: 'ssl' as const },
];
export const SettingsCustomDomainRecordsStatus = () => {
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const { customDomainRecords } = useRecoilValue(customDomainRecordsState);
const defaultValues: { status: string; color: ThemeColor } =
currentWorkspace?.customDomain === customDomainRecords?.customDomain
? {
status: 'success',
color: 'green',
}
: {
status: 'loading',
color: 'gray',
};
const rows = records.map<{ name: string; status: string; color: ThemeColor }>(
(record) => {
const foundRecord = customDomainRecords?.records.find(
({ validationType }) => validationType === record.validationType,
);
return {
name: record.name,
status: foundRecord ? foundRecord.status : defaultValues.status,
color:
foundRecord && foundRecord.status === 'error'
? 'red'
: foundRecord && foundRecord.status === 'pending'
? 'yellow'
: defaultValues.color,
};
},
);
return (
<StyledTable>
{rows.map((row) => (
<StyledTableRow key={row.name}>
<StyledTableCell>{row.name}</StyledTableCell>
<StyledTableCell>
<Status color={row.color} text={capitalize(row.status)} />
</StyledTableCell>
</StyledTableRow>
))}
</StyledTable>
);
};

View file

@ -2,10 +2,12 @@ import { customDomainRecordsState } from '~/pages/settings/workspace/states/cust
import { useCheckCustomDomainValidRecordsMutation } from '~/generated-metadata/graphql';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
export const useCheckCustomDomainValidRecords = () => {
const [checkCustomDomainValidRecords] =
useCheckCustomDomainValidRecordsMutation();
const { enqueueErrorSnackBar } = useSnackBar();
const setCustomDomainRecords = useSetRecoilState(customDomainRecordsState);
@ -24,6 +26,13 @@ export const useCheckCustomDomainValidRecords = () => {
: {}),
}));
},
onError: (error) => {
enqueueErrorSnackBar({ apolloError: error });
setCustomDomainRecords((currentState) => ({
...currentState,
isLoading: false,
}));
},
});
};

View file

@ -41,7 +41,7 @@
"cache-manager-redis-yet": "^4.1.2",
"class-validator": "patch:class-validator@0.14.0#./patches/class-validator+0.14.0.patch",
"class-validator-jsonschema": "^5.0.2",
"cloudflare": "^4.0.0",
"cloudflare": "^4.5.0",
"connect-redis": "^7.1.1",
"express-session": "^1.18.1",
"graphql-middleware": "^6.1.35",

View file

@ -10,6 +10,7 @@ import { MessagingMessageListFetchCronCommand } from 'src/modules/messaging/mess
import { MessagingMessagesImportCronCommand } from 'src/modules/messaging/message-import-manager/crons/commands/messaging-messages-import.cron.command';
import { MessagingOngoingStaleCronCommand } from 'src/modules/messaging/message-import-manager/crons/commands/messaging-ongoing-stale.cron.command';
import { CronTriggerCronCommand } from 'src/modules/workflow/workflow-trigger/automated-trigger/crons/commands/cron-trigger.cron.command';
import { CheckCustomDomainValidRecordsCronCommand } from 'src/engine/core-modules/domain-manager/crons/commands/check-custom-domain-valid-records.cron.command';
@Command({
name: 'cron:register:all',
@ -27,6 +28,7 @@ export class CronRegisterAllCommand extends CommandRunner {
private readonly calendarOngoingStaleCronCommand: CalendarOngoingStaleCronCommand,
private readonly cronTriggerCronCommand: CronTriggerCronCommand,
private readonly cleanupOrphanedFilesCronCommand: CleanupOrphanedFilesCronCommand,
private readonly checkCustomDomainValidRecordsCronCommand: CheckCustomDomainValidRecordsCronCommand,
) {
super();
}
@ -64,6 +66,10 @@ export class CronRegisterAllCommand extends CommandRunner {
name: 'CleanupOrphanedFiles',
command: this.cleanupOrphanedFilesCronCommand,
},
{
name: 'CheckCustomDomainValidRecords',
command: this.checkCustomDomainValidRecordsCronCommand,
},
];
let successCount = 0;

View file

@ -19,6 +19,7 @@ import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-m
import { CalendarEventImportManagerModule } from 'src/modules/calendar/calendar-event-import-manager/calendar-event-import-manager.module';
import { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module';
import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/automated-trigger/automated-trigger.module';
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
import { DataSeedWorkspaceCommand } from './data-seed-dev-workspace.command';
@ -33,6 +34,7 @@ import { DataSeedWorkspaceCommand } from './data-seed-dev-workspace.command';
CalendarEventImportManagerModule,
AutomatedTriggerModule,
FileModule,
DomainManagerModule,
// Data seeding dependencies
TypeORMModule,

View file

@ -1,11 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class GraphqlQueryRunnerException extends CustomException {
declare code: GraphqlQueryRunnerExceptionCode;
constructor(message: string, code: GraphqlQueryRunnerExceptionCode) {
super(message, code);
}
}
export class GraphqlQueryRunnerException extends CustomException<GraphqlQueryRunnerExceptionCode> {}
export enum GraphqlQueryRunnerExceptionCode {
INVALID_QUERY_INPUT = 'INVALID_QUERY_INPUT',

View file

@ -1,20 +1,19 @@
import { CustomException } from 'src/utils/custom-exception';
import {
appendCommonExceptionCode,
CustomException,
} from 'src/utils/custom-exception';
export class WorkspaceQueryRunnerException extends CustomException {
code: WorkspaceQueryRunnerExceptionCode;
constructor(message: string, code: WorkspaceQueryRunnerExceptionCode) {
super(message, code);
this.code = code;
}
}
export class WorkspaceQueryRunnerException extends CustomException<
keyof typeof WorkspaceQueryRunnerExceptionCode
> {}
export enum WorkspaceQueryRunnerExceptionCode {
INVALID_QUERY_INPUT = 'INVALID_QUERY_INPUT',
DATA_NOT_FOUND = 'DATA_NOT_FOUND',
QUERY_TIMEOUT = 'QUERY_TIMEOUT',
QUERY_VIOLATES_UNIQUE_CONSTRAINT = 'QUERY_VIOLATES_UNIQUE_CONSTRAINT',
QUERY_VIOLATES_FOREIGN_KEY_CONSTRAINT = 'QUERY_VIOLATES_FOREIGN_KEY_CONSTRAINT',
TOO_MANY_ROWS_AFFECTED = 'TOO_MANY_ROWS_AFFECTED',
NO_ROWS_AFFECTED = 'NO_ROWS_AFFECTED',
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
}
export const WorkspaceQueryRunnerExceptionCode = appendCommonExceptionCode({
INVALID_QUERY_INPUT: 'INVALID_QUERY_INPUT',
DATA_NOT_FOUND: 'DATA_NOT_FOUND',
QUERY_TIMEOUT: 'QUERY_TIMEOUT',
QUERY_VIOLATES_UNIQUE_CONSTRAINT: 'QUERY_VIOLATES_UNIQUE_CONSTRAINT',
QUERY_VIOLATES_FOREIGN_KEY_CONSTRAINT:
'QUERY_VIOLATES_FOREIGN_KEY_CONSTRAINT',
TOO_MANY_ROWS_AFFECTED: 'TOO_MANY_ROWS_AFFECTED',
NO_ROWS_AFFECTED: 'NO_ROWS_AFFECTED',
} as const);

View file

@ -1,15 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class ApiKeyException extends CustomException {
declare code: ApiKeyExceptionCode;
constructor(
message: string,
code: ApiKeyExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
) {
super(message, code, userFriendlyMessage);
}
}
export class ApiKeyException extends CustomException<ApiKeyExceptionCode> {}
export enum ApiKeyExceptionCode {
API_KEY_NOT_FOUND = 'API_KEY_NOT_FOUND',

View file

@ -1,15 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class ApprovedAccessDomainException extends CustomException {
declare code: ApprovedAccessDomainExceptionCode;
constructor(
message: string,
code: ApprovedAccessDomainExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
) {
super(message, code, userFriendlyMessage);
}
}
export class ApprovedAccessDomainException extends CustomException<ApprovedAccessDomainExceptionCode> {}
export enum ApprovedAccessDomainExceptionCode {
APPROVED_ACCESS_DOMAIN_NOT_FOUND = 'APPROVED_ACCESS_DOMAIN_NOT_FOUND',

View file

@ -1,11 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class AuditException extends CustomException {
declare code: AuditExceptionCode;
constructor(message: string, code: AuditExceptionCode) {
super(message, code);
}
}
export class AuditException extends CustomException<AuditExceptionCode> {}
export enum AuditExceptionCode {
INVALID_TYPE = 'INVALID_TYPE',

View file

@ -1,36 +1,33 @@
import { CustomException } from 'src/utils/custom-exception';
import {
appendCommonExceptionCode,
CustomException,
} from 'src/utils/custom-exception';
export class AuthException extends CustomException {
declare code: AuthExceptionCode;
constructor(
message: string,
code: AuthExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
) {
super(message, code, userFriendlyMessage);
}
}
export class AuthException extends CustomException<
keyof typeof AuthExceptionCode
> {}
export enum AuthExceptionCode {
USER_NOT_FOUND = 'USER_NOT_FOUND',
USER_WORKSPACE_NOT_FOUND = 'USER_WORKSPACE_NOT_FOUND',
EMAIL_NOT_VERIFIED = 'EMAIL_NOT_VERIFIED',
CLIENT_NOT_FOUND = 'CLIENT_NOT_FOUND',
WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',
INVALID_INPUT = 'INVALID_INPUT',
FORBIDDEN_EXCEPTION = 'FORBIDDEN_EXCEPTION',
INSUFFICIENT_SCOPES = 'INSUFFICIENT_SCOPES',
UNAUTHENTICATED = 'UNAUTHENTICATED',
INVALID_DATA = 'INVALID_DATA',
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
OAUTH_ACCESS_DENIED = 'OAUTH_ACCESS_DENIED',
SSO_AUTH_FAILED = 'SSO_AUTH_FAILED',
USE_SSO_AUTH = 'USE_SSO_AUTH',
SIGNUP_DISABLED = 'SIGNUP_DISABLED',
GOOGLE_API_AUTH_DISABLED = 'GOOGLE_API_AUTH_DISABLED',
MICROSOFT_API_AUTH_DISABLED = 'MICROSOFT_API_AUTH_DISABLED',
MISSING_ENVIRONMENT_VARIABLE = 'MISSING_ENVIRONMENT_VARIABLE',
INVALID_JWT_TOKEN_TYPE = 'INVALID_JWT_TOKEN_TYPE',
TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED = 'TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED',
TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED = 'TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED',
}
export const AuthExceptionCode = appendCommonExceptionCode({
USER_NOT_FOUND: 'USER_NOT_FOUND',
USER_WORKSPACE_NOT_FOUND: 'USER_WORKSPACE_NOT_FOUND',
EMAIL_NOT_VERIFIED: 'EMAIL_NOT_VERIFIED',
CLIENT_NOT_FOUND: 'CLIENT_NOT_FOUND',
WORKSPACE_NOT_FOUND: 'WORKSPACE_NOT_FOUND',
INVALID_INPUT: 'INVALID_INPUT',
FORBIDDEN_EXCEPTION: 'FORBIDDEN_EXCEPTION',
INSUFFICIENT_SCOPES: 'INSUFFICIENT_SCOPES',
UNAUTHENTICATED: 'UNAUTHENTICATED',
INVALID_DATA: 'INVALID_DATA',
OAUTH_ACCESS_DENIED: 'OAUTH_ACCESS_DENIED',
SSO_AUTH_FAILED: 'SSO_AUTH_FAILED',
USE_SSO_AUTH: 'USE_SSO_AUTH',
SIGNUP_DISABLED: 'SIGNUP_DISABLED',
GOOGLE_API_AUTH_DISABLED: 'GOOGLE_API_AUTH_DISABLED',
MICROSOFT_API_AUTH_DISABLED: 'MICROSOFT_API_AUTH_DISABLED',
MISSING_ENVIRONMENT_VARIABLE: 'MISSING_ENVIRONMENT_VARIABLE',
INVALID_JWT_TOKEN_TYPE: 'INVALID_JWT_TOKEN_TYPE',
TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED:
'TWO_FACTOR_AUTHENTICATION_PROVISION_REQUIRED',
TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED:
'TWO_FACTOR_AUTHENTICATION_VERIFICATION_REQUIRED',
} as const);

View file

@ -2,11 +2,7 @@
import { CustomException } from 'src/utils/custom-exception';
export class BillingException extends CustomException {
constructor(message: string, code: BillingExceptionCode) {
super(message, code);
}
}
export class BillingException extends CustomException<BillingExceptionCode> {}
export enum BillingExceptionCode {
BILLING_CUSTOMER_NOT_FOUND = 'BILLING_CUSTOMER_NOT_FOUND',

View file

@ -1,15 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class CaptchaException extends CustomException {
declare code: CaptchaExceptionCode;
constructor(
message: string,
code: CaptchaExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
) {
super(message, code, userFriendlyMessage);
}
}
export class CaptchaException extends CustomException<CaptchaExceptionCode> {}
export enum CaptchaExceptionCode {
INVALID_CAPTCHA = 'INVALID_CAPTCHA',

View file

@ -42,58 +42,79 @@ export class CloudflareController {
@Post(['cloudflare/custom-hostname-webhooks', 'webhooks/cloudflare'])
@UseGuards(CloudflareSecretMatchGuard, PublicEndpointGuard)
async customHostnameWebhooks(@Req() req: Request, @Res() res: Response) {
if (!req.body?.data?.data?.hostname) {
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 analytics = 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 analytics.insertWorkspaceEvent(CUSTOM_DOMAIN_ACTIVATED_EVENT, {});
}
return res.status(200).send();
} catch (err) {
handleException({
exception: new DomainManagerException(
'Hostname missing',
DomainManagerExceptionCode.INVALID_INPUT_DATA,
err.message ?? 'Unknown error occurred',
DomainManagerExceptionCode.INTERNAL_SERVER_ERROR,
{ userFriendlyMessage: 'Unknown error occurred' },
),
exceptionHandlerService: this.exceptionHandlerService,
});
return res.status(200).send();
}
const workspace = await this.workspaceRepository.findOneBy({
customDomain: req.body.data.data.hostname,
});
if (!workspace) return;
const analytics = this.auditService.createContext({
workspaceId: workspace.id,
});
const customDomainDetails =
await this.customDomainService.getCustomDomainDetails(
req.body.data.data.hostname,
);
const workspaceUpdated: Partial<Workspace> = {
customDomain: workspace.customDomain,
};
if (!customDomainDetails && workspace) {
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 analytics.insertWorkspaceEvent(CUSTOM_DOMAIN_ACTIVATED_EVENT, {});
}
return res.status(200).send();
}
}

View file

@ -0,0 +1,32 @@
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';
@Command({
name: 'cron:domain-manager:check-custom-domain-valid-records',
description: 'Starts a cron job to check custom domain valid records hourly',
})
export class CheckCustomDomainValidRecordsCronCommand extends CommandRunner {
constructor(
@InjectMessageQueue(MessageQueue.cronQueue)
private readonly messageQueueService: MessageQueueService,
) {
super();
}
async run(): Promise<void> {
await this.messageQueueService.addCron<undefined>({
jobName: CheckCustomDomainValidRecordsCronJob.name,
data: undefined,
options: {
repeat: { pattern: CHECK_CUSTOM_DOMAIN_VALID_RECORDS_CRON_PATTERN },
},
});
}
}

View file

@ -0,0 +1,50 @@
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Not, Repository, Raw } from 'typeorm';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
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';
export const CHECK_CUSTOM_DOMAIN_VALID_RECORDS_CRON_PATTERN = '0 * * * *';
@Processor(MessageQueue.cronQueue)
export class CheckCustomDomainValidRecordsCronJob {
constructor(
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
private readonly customDomainService: CustomDomainService,
) {}
@Process(CheckCustomDomainValidRecordsCronJob.name)
@SentryCronMonitor(
CheckCustomDomainValidRecordsCronJob.name,
CHECK_CUSTOM_DOMAIN_VALID_RECORDS_CRON_PATTERN,
)
async handle(): Promise<void> {
const workspaces = await this.workspaceRepository.find({
where: {
activationStatus: WorkspaceActivationStatus.ACTIVE,
customDomain: Not(IsNull()),
createdAt: Raw(
(alias) => `EXTRACT(HOUR FROM ${alias}) = EXTRACT(HOUR FROM NOW())`,
),
},
select: ['id', 'customDomain', 'isCustomDomainEnabled'],
});
for (const workspace of workspaces) {
try {
await this.customDomainService.checkCustomDomainValidRecords(workspace);
} catch (error) {
throw new Error(
`[${CheckCustomDomainValidRecordsCronJob.name}] Cannot check custom domain for workspaces: ${error.message}`,
);
}
}
}
}

View file

@ -1,14 +1,15 @@
import { CustomException } from 'src/utils/custom-exception';
import {
appendCommonExceptionCode,
CustomException,
} from 'src/utils/custom-exception';
export class DomainManagerException extends CustomException {
constructor(message: string, code: DomainManagerExceptionCode) {
super(message, code);
}
}
export class DomainManagerException extends CustomException<
keyof typeof DomainManagerExceptionCode,
true
> {}
export enum DomainManagerExceptionCode {
CLOUDFLARE_CLIENT_NOT_INITIALIZED = 'CLOUDFLARE_CLIENT_NOT_INITIALIZED',
HOSTNAME_ALREADY_REGISTERED = 'HOSTNAME_ALREADY_REGISTERED',
SUBDOMAIN_REQUIRED = 'SUBDOMAIN_REQUIRED',
INVALID_INPUT_DATA = 'INVALID_INPUT_DATA',
}
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);

View file

@ -3,14 +3,27 @@ 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';
@Module({
imports: [AuditModule, TypeOrmModule.forFeature([Workspace], 'core')],
providers: [DomainManagerService, CustomDomainService],
exports: [DomainManagerService, CustomDomainService],
providers: [
DomainManagerResolver,
DomainManagerService,
CustomDomainService,
CheckCustomDomainValidRecordsCronJob,
CheckCustomDomainValidRecordsCronCommand,
],
exports: [
DomainManagerService,
CustomDomainService,
CheckCustomDomainValidRecordsCronCommand,
],
controllers: [CloudflareController],
})
export class DomainManagerModule {}

View file

@ -0,0 +1,23 @@
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);
}
}

View file

@ -3,17 +3,17 @@ import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
class CustomDomainRecord {
@Field(() => String)
validationType: 'ownership' | 'ssl' | 'redirection';
validationType: 'ssl' | 'redirection';
@Field(() => String)
type: 'txt' | 'cname';
@Field(() => String)
key: string;
type: 'cname';
@Field(() => String)
status: string;
@Field(() => String)
key: string;
@Field(() => String)
value: string;
}

View file

@ -1,4 +1,5 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import Cloudflare from 'cloudflare';
import { CustomHostnameCreateResponse } from 'cloudflare/resources/custom-hostnames/custom-hostnames';
@ -9,6 +10,7 @@ import { DomainManagerException } from 'src/engine/core-modules/domain-manager/d
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';
jest.mock('cloudflare');
@ -39,6 +41,12 @@ describe('CustomDomainService', () => {
getBaseUrl: jest.fn(),
},
},
{
provide: getRepositoryToken(Workspace, 'core'),
useValue: {
save: jest.fn(),
},
},
],
}).compile();
@ -62,7 +70,12 @@ describe('CustomDomainService', () => {
jest.spyOn(twentyConfigService, 'get').mockReturnValue(mockApiKey);
const instance = new CustomDomainService(twentyConfigService, {} as any);
const instance = new CustomDomainService(
twentyConfigService,
{} as any,
{} as any,
{} as any,
);
expect(twentyConfigService.get).toHaveBeenCalledWith('CLOUDFLARE_API_KEY');
expect(Cloudflare).toHaveBeenCalledWith({ apiToken: mockApiKey });
@ -138,6 +151,9 @@ describe('CustomDomainService', () => {
hostname: customDomain,
ownership_verification: undefined,
verification_errors: [],
ssl: {
dcv_delegation_records: [],
},
};
const cloudflareMock = {
customHostnames: {
@ -284,26 +300,4 @@ describe('CustomDomainService', () => {
).resolves.toBeUndefined();
});
});
describe('isCustomDomainWorking', () => {
it('should return true if all records have success status', () => {
const customDomainDetails = {
records: [{ status: 'success' }, { status: 'success' }],
} as any;
expect(
customDomainService.isCustomDomainWorking(customDomainDetails),
).toBe(true);
});
it('should return false if any record does not have success status', () => {
const customDomainDetails = {
records: [{ status: 'success' }, { status: 'pending' }],
} as any;
expect(
customDomainService.isCustomDomainWorking(customDomainDetails),
).toBe(false);
});
});
});

View file

@ -1,8 +1,11 @@
/* @license Enterprise */
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import Cloudflare from 'cloudflare';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { CustomHostnameCreateParams } from 'cloudflare/resources/custom-hostnames/custom-hostnames';
import {
DomainManagerException,
@ -12,6 +15,10 @@ import { CustomDomainValidRecords } from 'src/engine/core-modules/domain-manager
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';
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';
@Injectable()
export class CustomDomainService {
@ -20,6 +27,9 @@ export class CustomDomainService {
constructor(
private readonly twentyConfigService: TwentyConfigService,
private readonly domainManagerService: DomainManagerService,
private readonly auditService: AuditService,
@InjectRepository(Workspace, 'core')
private readonly workspaceRepository: Repository<Workspace>,
) {
if (this.twentyConfigService.get('CLOUDFLARE_API_KEY')) {
this.cloudflareClient = new Cloudflare({
@ -28,6 +38,22 @@ export class CustomDomainService {
}
}
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);
@ -35,25 +61,14 @@ export class CustomDomainService {
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: {
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,
},
ssl: this.sslParams,
});
}
@ -72,70 +87,45 @@ export class CustomDomainService {
}
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: response.result[0].id,
customDomain: response.result[0].hostname,
id: id,
customDomain: hostname,
records: [
response.result[0].ownership_verification,
...(response.result[0].ssl?.validation_records ?? []),
]
.map<CustomDomainValidRecords['records'][0] | undefined>((record) => {
if (!record) return;
if (
'txt_name' in record &&
'txt_value' in record &&
record.txt_name &&
record.txt_value
) {
return {
validationType: 'ssl' as const,
type: 'txt' as const,
status:
!response.result[0].ssl.status ||
response.result[0].ssl.status.startsWith('pending')
? 'pending'
: response.result[0].ssl.status,
key: record.txt_name,
value: record.txt_value,
};
}
if (
'type' in record &&
record.type === 'txt' &&
record.value &&
record.name
) {
return {
validationType: 'ownership' as const,
type: 'txt' as const,
status: response.result[0].status ?? 'pending',
key: record.name,
value: record.value,
};
}
})
.filter(isDefined)
.concat([
{
validationType: 'redirection' as const,
type: 'cname' as const,
status:
// wait 10s before starting the real check
response.result[0].created_at &&
new Date().getTime() -
new Date(response.result[0].created_at).getTime() <
1000 * 10
? 'pending'
: response.result[0].verification_errors?.[0] ===
'custom hostname does not CNAME to this zone.'
? 'error'
: 'success',
key: response.result[0].hostname,
value: this.domainManagerService.getBaseUrl().hostname,
},
]),
{
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`,
},
],
};
}
@ -179,9 +169,48 @@ export class CustomDomainService {
});
}
isCustomDomainWorking(customDomainDetails: CustomDomainValidRecords) {
return customDomainDetails.records.every(
({ status }) => status === 'success',
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 analytics = this.auditService.createContext({
workspaceId: workspace.id,
});
analytics.insertWorkspaceEvent(
workspace.isCustomDomainEnabled
? CUSTOM_DOMAIN_ACTIVATED_EVENT
: CUSTOM_DOMAIN_DEACTIVATED_EVENT,
{},
);
}
return customDomainDetails;
}
}

View file

@ -1,4 +1,5 @@
import Cloudflare from 'cloudflare';
import { t } from '@lingui/core/macro';
import {
DomainManagerException,
@ -12,6 +13,9 @@ const isCloudflareInstanceDefined = (
throw new DomainManagerException(
'Cloudflare instance is not defined',
DomainManagerExceptionCode.CLOUDFLARE_CLIENT_NOT_INITIALIZED,
{
userFriendlyMessage: t`Environnement variable CLOUDFLARE_API_KEY must be defined to use this feature.`,
},
);
}
};

View file

@ -1,15 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class EmailVerificationException extends CustomException {
declare code: EmailVerificationExceptionCode;
constructor(
message: string,
code: EmailVerificationExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
) {
super(message, code, userFriendlyMessage ?? message);
}
}
export class EmailVerificationException extends CustomException<EmailVerificationExceptionCode> {}
export enum EmailVerificationExceptionCode {
EMAIL_VERIFICATION_NOT_REQUIRED = 'EMAIL_VERIFICATION_NOT_REQUIRED',

View file

@ -1,13 +1,7 @@
import { CustomException } from 'src/utils/custom-exception';
export class FeatureFlagException extends CustomException {
constructor(message: string, code: FeatureFlagExceptionCode) {
super(message, code);
}
}
export class FeatureFlagException extends CustomException<FeatureFlagExceptionCode> {}
export enum FeatureFlagExceptionCode {
INVALID_FEATURE_FLAG_KEY = 'INVALID_FEATURE_FLAG_KEY',
FEATURE_FLAG_IS_NOT_PUBLIC = 'FEATURE_FLAG_IS_NOT_PUBLIC',
FEATURE_FLAG_NOT_FOUND = 'FEATURE_FLAG_NOT_FOUND',
}

View file

@ -1,5 +1,5 @@
import { featureFlagValidator } from 'src/engine/core-modules/feature-flag/validates/feature-flag.validate';
import { CustomException } from 'src/utils/custom-exception';
import { UnknownException } from 'src/utils/custom-exception';
describe('featureFlagValidator', () => {
describe('assertIsFeatureFlagKey', () => {
@ -7,7 +7,7 @@ describe('featureFlagValidator', () => {
expect(() =>
featureFlagValidator.assertIsFeatureFlagKey(
'IS_AI_ENABLED',
new CustomException('Error', 'Error'),
new UnknownException('Error', 'Error'),
),
).not.toThrow();
});
@ -16,14 +16,14 @@ describe('featureFlagValidator', () => {
expect(() =>
featureFlagValidator.assertIsFeatureFlagKey(
'IS_WORKFLOW_FILTERING_ENABLED',
new CustomException('Error', 'Error'),
new UnknownException('Error', 'Error'),
),
).not.toThrow();
});
it('should throw error if featureFlagKey is invalid', () => {
const invalidKey = 'InvalidKey';
const exception = new CustomException('Error', 'Error');
const exception = new UnknownException('Error', 'Error');
expect(() =>
featureFlagValidator.assertIsFeatureFlagKey(invalidKey, exception),

View file

@ -4,7 +4,6 @@ import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { IsNull, LessThan, Repository } from 'typeorm';
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileMetadataService } from 'src/engine/core-modules/file/services/file-metadata.service';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
@ -22,7 +21,6 @@ export class CleanupOrphanedFilesCronJob {
@InjectRepository(FileEntity, 'core')
private readonly fileRepository: Repository<FileEntity>,
private readonly fileMetadataService: FileMetadataService,
private readonly exceptionHandlerService: ExceptionHandlerService,
) {}
@Process(CleanupOrphanedFilesCronJob.name)

View file

@ -1,13 +1,13 @@
import { CustomException } from 'src/utils/custom-exception';
import {
appendCommonExceptionCode,
CustomException,
} from 'src/utils/custom-exception';
export enum FileExceptionCode {
UNAUTHENTICATED = 'UNAUTHENTICATED',
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
FILE_NOT_FOUND = 'FILE_NOT_FOUND',
}
export class FileException extends CustomException<
keyof typeof FileExceptionCode
> {}
export class FileException extends CustomException {
constructor(message: string, code: FileExceptionCode) {
super(message, code);
}
}
export const FileExceptionCode = appendCommonExceptionCode({
UNAUTHENTICATED: 'UNAUTHENTICATED',
FILE_NOT_FOUND: 'FILE_NOT_FOUND',
} as const);

View file

@ -170,42 +170,6 @@ export class ForbiddenError extends BaseGraphQLError {
}
}
export class PersistedQueryNotFoundError extends BaseGraphQLError {
constructor(customException: CustomException);
constructor(message?: string, extensions?: RestrictedGraphQLErrorExtensions);
constructor(
messageOrException?: string | CustomException,
extensions?: RestrictedGraphQLErrorExtensions,
) {
super(
messageOrException || 'PersistedQueryNotFound',
ErrorCode.PERSISTED_QUERY_NOT_FOUND,
extensions,
);
Object.defineProperty(this, 'name', {
value: 'PersistedQueryNotFoundError',
});
}
}
export class PersistedQueryNotSupportedError extends BaseGraphQLError {
constructor(
messageOrException?: string | CustomException,
extensions?: RestrictedGraphQLErrorExtensions,
) {
super(
messageOrException || 'PersistedQueryNotSupported',
ErrorCode.PERSISTED_QUERY_NOT_SUPPORTED,
extensions,
);
Object.defineProperty(this, 'name', {
value: 'PersistedQueryNotSupportedError',
});
}
}
export class UserInputError extends BaseGraphQLError {
constructor(exception: CustomException);

View file

@ -1,15 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class RecordTransformerException extends CustomException {
declare code: RecordTransformerExceptionCode;
constructor(
message: string,
code: RecordTransformerExceptionCode,
userFriendlyMessage?: string,
) {
super(message, code, userFriendlyMessage);
}
}
export class RecordTransformerException extends CustomException<RecordTransformerExceptionCode> {}
export enum RecordTransformerExceptionCode {
INVALID_URL = 'INVALID_URL',

View file

@ -43,7 +43,7 @@ const validatePrimaryPhoneCountryCodeAndCallingCode = ({
throw new RecordTransformerException(
`Invalid country code ${countryCode}`,
RecordTransformerExceptionCode.INVALID_PHONE_COUNTRY_CODE,
t`Invalid country code ${countryCode}`,
{ userFriendlyMessage: t`Invalid country code ${countryCode}` },
);
}
@ -57,7 +57,7 @@ const validatePrimaryPhoneCountryCodeAndCallingCode = ({
throw new RecordTransformerException(
`Invalid calling code ${callingCode}`,
RecordTransformerExceptionCode.INVALID_PHONE_CALLING_CODE,
t`Invalid calling code ${callingCode}`,
{ userFriendlyMessage: t`Invalid calling code ${callingCode}` },
);
}
@ -70,7 +70,9 @@ const validatePrimaryPhoneCountryCodeAndCallingCode = ({
throw new RecordTransformerException(
`Provided country code and calling code are conflicting`,
RecordTransformerExceptionCode.CONFLICTING_PHONE_CALLING_CODE_AND_COUNTRY_CODE,
t`Provided country code and calling code are conflicting`,
{
userFriendlyMessage: t`Provided country code and calling code are conflicting`,
},
);
}
};
@ -91,7 +93,7 @@ const parsePhoneNumberExceptionWrapper = ({
throw new RecordTransformerException(
`Provided phone number is invalid ${number}`,
RecordTransformerExceptionCode.INVALID_PHONE_NUMBER,
t`Provided phone number is invalid ${number}`,
{ userFriendlyMessage: t`Provided phone number is invalid ${number}` },
);
}
};
@ -115,7 +117,9 @@ const validateAndInferMetadataFromPrimaryPhoneNumber = ({
throw new RecordTransformerException(
'Provided and inferred country code are conflicting',
RecordTransformerExceptionCode.CONFLICTING_PHONE_COUNTRY_CODE,
t`Provided and inferred country code are conflicting`,
{
userFriendlyMessage: t`Provided and inferred country code are conflicting`,
},
);
}
@ -127,7 +131,9 @@ const validateAndInferMetadataFromPrimaryPhoneNumber = ({
throw new RecordTransformerException(
'Provided and inferred calling code are conflicting',
RecordTransformerExceptionCode.CONFLICTING_PHONE_CALLING_CODE,
t`Provided and inferred calling code are conflicting`,
{
userFriendlyMessage: t`Provided and inferred calling code are conflicting`,
},
);
}

View file

@ -1,11 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class SearchException extends CustomException {
declare code: SearchExceptionCode;
constructor(message: string, code: SearchExceptionCode) {
super(message, code);
}
}
export class SearchException extends CustomException<SearchExceptionCode> {}
export enum SearchExceptionCode {
LABEL_IDENTIFIER_FIELD_NOT_FOUND = 'LABEL_IDENTIFIER_FIELD_NOT_FOUND',

View file

@ -2,15 +2,10 @@
import { CustomException } from 'src/utils/custom-exception';
export class SSOException extends CustomException {
constructor(message: string, code: SSOExceptionCode) {
super(message, code);
}
}
export class SSOException extends CustomException<SSOExceptionCode> {}
export enum SSOExceptionCode {
USER_NOT_FOUND = 'USER_NOT_FOUND',
INVALID_SSO_CONFIGURATION = 'INVALID_SSO_CONFIGURATION',
IDENTITY_PROVIDER_NOT_FOUND = 'IDENTITY_PROVIDER_NOT_FOUND',
INVALID_ISSUER_URL = 'INVALID_ISSUER_URL',
INVALID_IDP_TYPE = 'INVALID_IDP_TYPE',

View file

@ -1,10 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class ThrottlerException extends CustomException {
constructor(message: string, code: ThrottlerExceptionCode) {
super(message, code);
}
}
export class ThrottlerException extends CustomException<ThrottlerExceptionCode> {}
export enum ThrottlerExceptionCode {
LIMIT_REACHED = 'LIMIT_REACHED',

View file

@ -1,10 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class SendEmailToolException extends CustomException {
constructor(message: string, code: SendEmailToolExceptionCode) {
super(message, code);
}
}
export class SendEmailToolException extends CustomException<SendEmailToolExceptionCode> {}
export enum SendEmailToolExceptionCode {
INVALID_CONNECTED_ACCOUNT_ID = 'INVALID_CONNECTED_ACCOUNT_ID',

View file

@ -972,6 +972,15 @@ export class ConfigVariables {
@IsOptional()
CLOUDFLARE_WEBHOOK_SECRET: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.Other,
description:
'Id to generate value for CNAME record to validate ownership and manage ssl for custom hostname with Cloudflare',
type: ConfigVariableType.STRING,
})
@IsOptional()
CLOUDFLARE_DCV_DELEGATION_ID: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM,
description:

View file

@ -1,11 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class ConfigVariableException extends CustomException {
declare code: ConfigVariableExceptionCode;
constructor(message: string, code: ConfigVariableExceptionCode) {
super(message, code);
}
}
export class ConfigVariableException extends CustomException<ConfigVariableExceptionCode> {}
export enum ConfigVariableExceptionCode {
DATABASE_CONFIG_DISABLED = 'DATABASE_CONFIG_DISABLED',

View file

@ -1,11 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class TwoFactorAuthenticationException extends CustomException {
declare code: TwoFactorAuthenticationExceptionCode;
constructor(message: string, code: TwoFactorAuthenticationExceptionCode) {
super(message, code);
}
}
export class TwoFactorAuthenticationException extends CustomException<TwoFactorAuthenticationExceptionCode> {}
export enum TwoFactorAuthenticationExceptionCode {
INVALID_CONFIGURATION = 'INVALID_CONFIGURATION',

View file

@ -1,11 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class UserWorkspaceException extends CustomException {
declare code: UserWorkspaceExceptionCode;
constructor(message: string, code: UserWorkspaceExceptionCode) {
super(message, code);
}
}
export class UserWorkspaceException extends CustomException<UserWorkspaceExceptionCode> {}
export enum UserWorkspaceExceptionCode {
USER_WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',

View file

@ -1,10 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class UserException extends CustomException {
constructor(message: string, code: UserExceptionCode) {
super(message, code);
}
}
export class UserException extends CustomException<UserExceptionCode> {}
export enum UserExceptionCode {
USER_NOT_FOUND = 'USER_NOT_FOUND',

View file

@ -1,15 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class WebhookException extends CustomException {
declare code: WebhookExceptionCode;
constructor(
message: string,
code: WebhookExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
) {
super(message, code, userFriendlyMessage);
}
}
export class WebhookException extends CustomException<WebhookExceptionCode> {}
export enum WebhookExceptionCode {
WEBHOOK_NOT_FOUND = 'WEBHOOK_NOT_FOUND',

View file

@ -1,10 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class WorkspaceInvitationException extends CustomException {
constructor(message: string, code: WorkspaceInvitationExceptionCode) {
super(message, code);
}
}
export class WorkspaceInvitationException extends CustomException<WorkspaceInvitationExceptionCode> {}
export enum WorkspaceInvitationExceptionCode {
INVALID_APP_TOKEN_TYPE = 'INVALID_APP_TOKEN_TYPE',

View file

@ -8,14 +8,10 @@ import { isDefined } from 'twenty-shared/utils';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
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 { 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 { 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 { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import {
@ -67,10 +63,8 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
private readonly billingService: BillingService,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly twentyConfigService: TwentyConfigService,
private readonly domainManagerService: DomainManagerService,
private readonly exceptionHandlerService: ExceptionHandlerService,
private readonly permissionsService: PermissionsService,
private readonly auditService: AuditService,
private readonly customDomainService: CustomDomainService,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
@InjectMessageQueue(MessageQueue.deleteCascadeQueue)
@ -182,6 +176,7 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
await this.customDomainService.deleteCustomHostnameByHostnameSilently(
workspace.customDomain,
);
workspace.isCustomDomainEnabled = false;
}
if (
@ -401,38 +396,6 @@ export class WorkspaceService extends TypeOrmQueryService<Workspace> {
return !existingWorkspace;
}
async checkCustomDomainValidRecords(workspace: Workspace) {
if (!workspace.customDomain) return;
const customDomainDetails =
await this.customDomainService.getCustomDomainDetails(
workspace.customDomain,
);
if (!customDomainDetails) return;
const isCustomDomainWorking =
this.domainManagerService.isCustomDomainWorking(customDomainDetails);
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 customDomainDetails;
}
private async validateSecurityPermissions({
payload,
userWorkspaceId,

View file

@ -7,7 +7,6 @@ import {
WorkspaceException,
WorkspaceExceptionCode,
} from 'src/engine/core-modules/workspace/workspace.exception';
import { CustomException } from 'src/utils/custom-exception';
describe('workspaceGraphqlApiExceptionHandler', () => {
it('should throw NotFoundError when WorkspaceExceptionCode is SUBDOMAIN_NOT_FOUND', () => {
@ -48,7 +47,7 @@ describe('workspaceGraphqlApiExceptionHandler', () => {
const error = new WorkspaceException('Unknown error', 'UNKNOWN_CODE');
expect(() => workspaceGraphqlApiExceptionHandler(error)).toThrow(
CustomException,
WorkspaceException,
);
});

View file

@ -1,11 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class WorkspaceException extends CustomException {
declare code: WorkspaceExceptionCode;
constructor(message: string, code: WorkspaceExceptionCode) {
super(message, code);
}
}
export class WorkspaceException extends CustomException<WorkspaceExceptionCode> {}
export enum WorkspaceExceptionCode {
SUBDOMAIN_NOT_FOUND = 'SUBDOMAIN_NOT_FOUND',

View file

@ -5,11 +5,9 @@ import { NestjsQueryGraphQLModule } from '@ptc-org/nestjs-query-graphql';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { AuditModule } from 'src/engine/core-modules/audit/audit.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,6 +24,7 @@ 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 { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
import { workspaceAutoResolverOpts } from './workspace.auto-resolver-opts';
import { Workspace } from './workspace.entity';
@ -38,7 +37,6 @@ import { WorkspaceService } from './services/workspace.service';
TypeOrmModule.forFeature([BillingSubscription], 'core'),
NestjsQueryGraphQLModule.forFeature({
imports: [
DomainManagerModule,
BillingModule,
FileModule,
TokenModule,
@ -56,9 +54,9 @@ import { WorkspaceService } from './services/workspace.service';
TypeORMModule,
PermissionsModule,
WorkspaceCacheStorageModule,
AuditModule,
RoleModule,
AgentModule,
DomainManagerModule,
],
services: [WorkspaceService],
resolvers: workspaceAutoResolverOpts,

View file

@ -25,7 +25,6 @@ import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.
import { BillingSubscription } from 'src/engine/core-modules/billing/entities/billing-subscription.entity';
import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service';
import { 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 { FeatureFlagDTO } from 'src/engine/core-modules/feature-flag/dtos/feature-flag-dto';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
@ -70,7 +69,7 @@ import { Workspace } from './workspace.entity';
import { WorkspaceService } from './services/workspace.service';
const OriginHeader = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
(_: unknown, ctx: ExecutionContext) => {
const request = getRequest(ctx);
return request.headers['origin'];
@ -318,14 +317,6 @@ export class WorkspaceResolver {
);
}
@Mutation(() => CustomDomainValidRecords, { nullable: true })
@UseGuards(WorkspaceAuthGuard)
async checkCustomDomainValidRecords(
@AuthWorkspace() workspace: Workspace,
): Promise<CustomDomainValidRecords | undefined> {
return this.workspaceService.checkCustomDomainValidRecords(workspace);
}
@Query(() => PublicWorkspaceDataOutput)
@UseGuards(PublicEndpointGuard)
async getPublicWorkspaceDataByDomain(

View file

@ -1,11 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class AgentException extends CustomException {
declare code: AgentExceptionCode;
constructor(message: string, code: AgentExceptionCode) {
super(message, code);
}
}
export class AgentException extends CustomException<AgentExceptionCode> {}
export enum AgentExceptionCode {
AGENT_NOT_FOUND = 'AGENT_NOT_FOUND',

View file

@ -1,10 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class DataSourceException extends CustomException {
constructor(message: string, code: DataSourceExceptionCode) {
super(message, code);
}
}
export class DataSourceException extends CustomException<DataSourceExceptionCode> {}
export enum DataSourceExceptionCode {
DATA_SOURCE_NOT_FOUND = 'DATA_SOURCE_NOT_FOUND',

View file

@ -1,25 +1,22 @@
import { CustomException } from 'src/utils/custom-exception';
import {
appendCommonExceptionCode,
CustomException,
} from 'src/utils/custom-exception';
export class FieldMetadataException extends CustomException {
declare code: FieldMetadataExceptionCode;
constructor(
message: string,
code: FieldMetadataExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
) {
super(message, code, userFriendlyMessage);
}
}
export class FieldMetadataException extends CustomException<
keyof typeof FieldMetadataExceptionCode
> {}
export enum FieldMetadataExceptionCode {
FIELD_METADATA_NOT_FOUND = 'FIELD_METADATA_NOT_FOUND',
INVALID_FIELD_INPUT = 'INVALID_FIELD_INPUT',
FIELD_MUTATION_NOT_ALLOWED = 'FIELD_MUTATION_NOT_ALLOWED',
FIELD_ALREADY_EXISTS = 'FIELD_ALREADY_EXISTS',
OBJECT_METADATA_NOT_FOUND = 'OBJECT_METADATA_NOT_FOUND',
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
FIELD_METADATA_RELATION_NOT_ENABLED = 'FIELD_METADATA_RELATION_NOT_ENABLED',
FIELD_METADATA_RELATION_MALFORMED = 'FIELD_METADATA_RELATION_MALFORMED',
LABEL_IDENTIFIER_FIELD_METADATA_ID_NOT_FOUND = 'LABEL_IDENTIFIER_FIELD_METADATA_ID_NOT_FOUND',
UNCOVERED_FIELD_METADATA_TYPE_VALIDATION = 'UNCOVERED_FIELD_METADATA_TYPE_VALIDATION',
}
export const FieldMetadataExceptionCode = appendCommonExceptionCode({
FIELD_METADATA_NOT_FOUND: 'FIELD_METADATA_NOT_FOUND',
INVALID_FIELD_INPUT: 'INVALID_FIELD_INPUT',
FIELD_MUTATION_NOT_ALLOWED: 'FIELD_MUTATION_NOT_ALLOWED',
FIELD_ALREADY_EXISTS: 'FIELD_ALREADY_EXISTS',
OBJECT_METADATA_NOT_FOUND: 'OBJECT_METADATA_NOT_FOUND',
FIELD_METADATA_RELATION_NOT_ENABLED: 'FIELD_METADATA_RELATION_NOT_ENABLED',
FIELD_METADATA_RELATION_MALFORMED: 'FIELD_METADATA_RELATION_MALFORMED',
LABEL_IDENTIFIER_FIELD_METADATA_ID_NOT_FOUND:
'LABEL_IDENTIFIER_FIELD_METADATA_ID_NOT_FOUND',
UNCOVERED_FIELD_METADATA_TYPE_VALIDATION:
'UNCOVERED_FIELD_METADATA_TYPE_VALIDATION',
} as const);

View file

@ -1,15 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class ObjectMetadataException extends CustomException {
declare code: ObjectMetadataExceptionCode;
constructor(
message: string,
code: ObjectMetadataExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
) {
super(message, code, userFriendlyMessage);
}
}
export class ObjectMetadataException extends CustomException<ObjectMetadataExceptionCode> {}
export enum ObjectMetadataExceptionCode {
OBJECT_METADATA_NOT_FOUND = 'OBJECT_METADATA_NOT_FOUND',

View file

@ -1,15 +1,12 @@
import { CustomException } from 'src/utils/custom-exception';
export class PermissionsException extends CustomException {
declare code: PermissionsExceptionCode;
constructor(
message: string,
code: PermissionsExceptionCode,
userFriendlyMessage?: string,
) {
super(message, code, userFriendlyMessage);
}
}
// TODO: It would be usefull to enable typed message like below. More refactorisation is necessary to use it.
// export class PermissionsException extends CustomException<
// PermissionsExceptionCode,
// false,
// PermissionsExceptionMessage
// > {}
export class PermissionsException extends CustomException<PermissionsExceptionCode> {}
export enum PermissionsExceptionCode {
PERMISSION_DENIED = 'PERMISSION_DENIED',
@ -54,17 +51,11 @@ export enum PermissionsExceptionCode {
export enum PermissionsExceptionMessage {
PERMISSION_DENIED = 'User does not have permission',
ADMIN_ROLE_NOT_FOUND = 'Admin role not found',
USER_WORKSPACE_NOT_FOUND = 'User workspace not found',
WORKSPACE_ID_ROLE_USER_WORKSPACE_MISMATCH = 'Workspace id role user workspace mismatch',
TOO_MANY_ADMIN_CANDIDATES = 'Too many admin candidates',
USER_WORKSPACE_ALREADY_HAS_ROLE = 'User workspace already has role',
WORKSPACE_MEMBER_NOT_FOUND = 'Workspace member not found',
ROLE_NOT_FOUND = 'Role not found',
CANNOT_UNASSIGN_LAST_ADMIN = 'Cannot unassign admin role from last admin of the workspace',
CANNOT_DELETE_LAST_ADMIN_USER = 'Cannot delete account: user is the unique admin of a workspace',
UNKNOWN_OPERATION_NAME = 'Unknown operation name, cannot determine required permission',
UNKNOWN_REQUIRED_PERMISSION = 'Unknown required permission',
CANNOT_UPDATE_SELF_ROLE = 'Cannot update self role',
NO_ROLE_FOUND_FOR_USER_WORKSPACE = 'No role found for userWorkspace',
ROLE_LABEL_ALREADY_EXISTS = 'A role with this label already exists',
@ -73,7 +64,6 @@ export enum PermissionsExceptionMessage {
INVALID_SETTING = 'Invalid permission setting (unknown value)',
ROLE_NOT_EDITABLE = 'Role is not editable',
DEFAULT_ROLE_CANNOT_BE_DELETED = 'Default role cannot be deleted',
NO_PERMISSIONS_FOUND_IN_DATASOURCE = 'No permissions found in datasource',
CANNOT_ADD_OBJECT_PERMISSION_ON_SYSTEM_OBJECT = 'Cannot add object permission on system object',
CANNOT_ADD_FIELD_PERMISSION_ON_SYSTEM_OBJECT = 'Cannot add field permission on system object',
CANNOT_GIVE_WRITING_PERMISSION_ON_NON_READABLE_OBJECT = 'Cannot give update permission to non-readable object',
@ -82,8 +72,6 @@ export enum PermissionsExceptionMessage {
ONLY_FIELD_RESTRICTION_ALLOWED = 'Field permission can only introduce a restriction',
FIELD_RESTRICTION_ONLY_ALLOWED_ON_READABLE_OBJECT = 'Field restriction only makes sense on readable object',
FIELD_RESTRICTION_ON_UPDATE_ONLY_ALLOWED_ON_UPDATABLE_OBJECT = 'Field restriction on update only makes sense on updatable object',
UPSERT_FIELD_PERMISSION_FAILED = 'Failed to upsert field permission',
PERMISSION_NOT_FOUND = 'Permission not found',
OBJECT_PERMISSION_NOT_FOUND = 'Object permission not found',
EMPTY_FIELD_PERMISSION_NOT_ALLOWED = 'Empty field permission not allowed',
}

View file

@ -1,11 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class RemoteServerException extends CustomException {
declare code: RemoteServerExceptionCode;
constructor(message: string, code: RemoteServerExceptionCode) {
super(message, code);
}
}
export class RemoteServerException extends CustomException<RemoteServerExceptionCode> {}
export enum RemoteServerExceptionCode {
REMOTE_SERVER_NOT_FOUND = 'REMOTE_SERVER_NOT_FOUND',

View file

@ -1,12 +1,12 @@
import { CustomException } from 'src/utils/custom-exception';
import {
appendCommonExceptionCode,
CustomException,
} from 'src/utils/custom-exception';
export class DistantTableException extends CustomException {
constructor(message: string, code: DistantTableExceptionCode) {
super(message, code);
}
}
export class DistantTableException extends CustomException<
keyof typeof DistantTableExceptionCode
> {}
export enum DistantTableExceptionCode {
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
TIMEOUT_ERROR = 'TIMEOUT_ERROR',
}
export const DistantTableExceptionCode = appendCommonExceptionCode({
TIMEOUT_ERROR: 'TIMEOUT_ERROR',
} as const);

View file

@ -1,10 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class ForeignTableException extends CustomException {
constructor(message: string, code: ForeignTableExceptionCode) {
super(message, code);
}
}
export class ForeignTableException extends CustomException<ForeignTableExceptionCode> {}
export enum ForeignTableExceptionCode {
FOREIGN_TABLE_MUTATION_NOT_ALLOWED = 'FOREIGN_TABLE_MUTATION_NOT_ALLOWED',

View file

@ -1,11 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class RemoteTableException extends CustomException {
declare code: RemoteTableExceptionCode;
constructor(message: string, code: RemoteTableExceptionCode) {
super(message, code);
}
}
export class RemoteTableException extends CustomException<RemoteTableExceptionCode> {}
export enum RemoteTableExceptionCode {
REMOTE_TABLE_NOT_FOUND = 'REMOTE_TABLE_NOT_FOUND',

View file

@ -292,7 +292,7 @@ export class RoleService {
throw new PermissionsException(
PermissionsExceptionMessage.ROLE_LABEL_ALREADY_EXISTS,
PermissionsExceptionCode.ROLE_LABEL_ALREADY_EXISTS,
t`A role with this label already exists.`,
{ userFriendlyMessage: t`A role with this label already exists.` },
);
}
}

View file

@ -1,11 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class ServerlessFunctionException extends CustomException {
declare code: ServerlessFunctionExceptionCode;
constructor(message: string, code: ServerlessFunctionExceptionCode) {
super(message, code);
}
}
export class ServerlessFunctionException extends CustomException<ServerlessFunctionExceptionCode> {}
export enum ServerlessFunctionExceptionCode {
SERVERLESS_FUNCTION_NOT_FOUND = 'SERVERLESS_FUNCTION_NOT_FOUND',

View file

@ -1,14 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class InvalidMetadataException extends CustomException {
constructor(
message: string,
code: InvalidMetadataExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
) {
super(message, code, userFriendlyMessage);
}
}
export class InvalidMetadataException extends CustomException<InvalidMetadataExceptionCode> {}
export enum InvalidMetadataExceptionCode {
LABEL_REQUIRED = 'Label required',
@ -16,7 +8,6 @@ export enum InvalidMetadataExceptionCode {
EXCEEDS_MAX_LENGTH = 'Exceeds max length',
RESERVED_KEYWORD = 'Reserved keyword',
NOT_CAMEL_CASE = 'Not camel case',
NOT_FIRST_LETTER_UPPER_CASE = 'Not first letter upper case',
INVALID_LABEL = 'Invalid label',
NAME_NOT_SYNCED_WITH_LABEL = 'Name not synced with label',
INVALID_STRING = 'Invalid string',

View file

@ -1,10 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class WorkspaceMetadataCacheException extends CustomException {
constructor(message: string, code: WorkspaceMetadataCacheExceptionCode) {
super(message, code);
}
}
export class WorkspaceMetadataCacheException extends CustomException<WorkspaceMetadataCacheExceptionCode> {}
export enum WorkspaceMetadataCacheExceptionCode {
OBJECT_METADATA_MAP_NOT_FOUND = 'Object Metadata map not found',

View file

@ -1,10 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class WorkspaceMetadataVersionException extends CustomException {
constructor(message: string, code: WorkspaceMetadataVersionExceptionCode) {
super(message, code);
}
}
export class WorkspaceMetadataVersionException extends CustomException<WorkspaceMetadataVersionExceptionCode> {}
export enum WorkspaceMetadataVersionExceptionCode {
METADATA_VERSION_NOT_FOUND = 'METADATA_VERSION_NOT_FOUND',

View file

@ -1,10 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class WorkspaceMigrationException extends CustomException {
constructor(message: string, code: WorkspaceMigrationExceptionCode) {
super(message, code);
}
}
export class WorkspaceMigrationException extends CustomException<WorkspaceMigrationExceptionCode> {}
export enum WorkspaceMigrationExceptionCode {
NO_FACTORY_FOUND = 'NO_FACTORY_FOUND',

View file

@ -1,17 +1,10 @@
import { CustomException } from 'src/utils/custom-exception';
export class RelationException extends CustomException {
declare code: RelationExceptionCode;
constructor(message: string, code: RelationExceptionCode) {
super(message, code);
}
}
export class RelationException extends CustomException<RelationExceptionCode> {}
export enum RelationExceptionCode {
RELATION_OBJECT_METADATA_NOT_FOUND = 'RELATION_OBJECT_METADATA_NOT_FOUND',
RELATION_TARGET_FIELD_METADATA_ID_NOT_FOUND = 'RELATION_TARGET_FIELD_METADATA_ID_NOT_FOUND',
RELATION_TARGET_FIELD_METADATA_NOT_FOUND = 'RELATION_TARGET_FIELD_METADATA_NOT_FOUND',
INVALID_RELATION_TYPE = 'INVALID_RELATION_TYPE',
RELATION_JOIN_COLUMN_ON_BOTH_SIDES = 'RELATION_JOIN_COLUMN_ON_BOTH_SIDES',
MISSING_RELATION_JOIN_COLUMN = 'MISSING_RELATION_JOIN_COLUMN',
MULTIPLE_JOIN_COLUMNS_FOUND = 'MULTIPLE_JOIN_COLUMNS_FOUND',

View file

@ -1,14 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class TwentyORMException extends CustomException {
constructor(
message: string,
code: TwentyORMExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
) {
super(message, code, userFriendlyMessage);
}
}
export class TwentyORMException extends CustomException<TwentyORMExceptionCode> {}
export enum TwentyORMExceptionCode {
METADATA_VERSION_MISMATCH = 'METADATA_VERSION_MISMATCH',

View file

@ -1,10 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class WorkspaceCleanerException extends CustomException {
constructor(message: string, code: WorkspaceCleanerExceptionCode) {
super(message, code);
}
}
export class WorkspaceCleanerException extends CustomException<WorkspaceCleanerExceptionCode> {}
export enum WorkspaceCleanerExceptionCode {
BILLING_SUBSCRIPTION_NOT_FOUND = 'BILLING_SUBSCRIPTION_NOT_FOUND',

View file

@ -1,10 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class CalendarEventImportDriverException extends CustomException {
constructor(message: string, code: CalendarEventImportDriverExceptionCode) {
super(message, code);
}
}
export class CalendarEventImportDriverException extends CustomException<CalendarEventImportDriverExceptionCode> {}
export enum CalendarEventImportDriverExceptionCode {
NOT_FOUND = 'NOT_FOUND',

View file

@ -1,13 +1,8 @@
import { CustomException } from 'src/utils/custom-exception';
export class CalendarEventImportException extends CustomException {
constructor(message: string, code: CalendarEventImportExceptionCode) {
super(message, code);
}
}
export class CalendarEventImportException extends CustomException<CalendarEventImportExceptionCode> {}
export enum CalendarEventImportExceptionCode {
PROVIDER_NOT_SUPPORTED = 'PROVIDER_NOT_SUPPORTED',
UNKNOWN = 'UNKNOWN',
CALENDAR_CHANNEL_NOT_FOUND = 'CALENDAR_CHANNEL_NOT_FOUND',
}

View file

@ -1,13 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class ConnectedAccountRefreshAccessTokenException extends CustomException {
constructor(
message: string,
code: ConnectedAccountRefreshAccessTokenExceptionCode,
) {
super(message, code);
}
}
export class ConnectedAccountRefreshAccessTokenException extends CustomException<ConnectedAccountRefreshAccessTokenExceptionCode> {}
export enum ConnectedAccountRefreshAccessTokenExceptionCode {
REFRESH_TOKEN_NOT_FOUND = 'REFRESH_TOKEN_NOT_FOUND',

View file

@ -1,10 +1,9 @@
import { CustomException } from 'src/utils/custom-exception';
import { MessageNetworkExceptionCode } from 'src/modules/messaging/message-import-manager/drivers/exceptions/message-network.exception';
export class MessageImportDriverException extends CustomException {
constructor(message: string, code: MessageImportDriverExceptionCode) {
super(message, code);
}
}
export class MessageImportDriverException extends CustomException<
MessageImportDriverExceptionCode | MessageNetworkExceptionCode
> {}
export enum MessageImportDriverExceptionCode {
NOT_FOUND = 'NOT_FOUND',

View file

@ -1,10 +1,16 @@
import { CustomException } from 'src/utils/custom-exception';
export class MicrosoftImportDriverException extends CustomException {
export class MicrosoftImportDriverException extends CustomException<string> {
statusCode: number;
constructor(message: string, code: string, statusCode: number) {
super(message, code);
constructor(
message: string,
code: string,
statusCode: number,
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
) {
super(message, code, {
userFriendlyMessage: userFriendlyMessage ?? message,
});
this.statusCode = statusCode;
this.code = code;
}
}

View file

@ -1,14 +1,8 @@
import { CustomException } from 'src/utils/custom-exception';
export class MessageImportException extends CustomException {
constructor(message: string, code: MessageImportExceptionCode) {
super(message, code);
}
}
export class MessageImportException extends CustomException<MessageImportExceptionCode> {}
export enum MessageImportExceptionCode {
UNKNOWN = 'UNKNOWN',
PROVIDER_NOT_SUPPORTED = 'PROVIDER_NOT_SUPPORTED',
MESSAGE_CHANNEL_NOT_FOUND = 'MESSAGE_CHANNEL_NOT_FOUND',
FOLDER_ID_REQUIRED = 'FOLDER_ID_REQUIRED',
}

View file

@ -22,7 +22,6 @@ import {
export enum MessageImportSyncStep {
FULL_MESSAGE_LIST_FETCH = 'FULL_MESSAGE_LIST_FETCH', // TODO: deprecate to only use MESSAGE_LIST_FETCH
PARTIAL_MESSAGE_LIST_FETCH = 'PARTIAL_MESSAGE_LIST_FETCH', // TODO: deprecate to only use MESSAGE_LIST_FETCH
MESSAGE_LIST_FETCH = 'MESSAGE_LIST_FETCH',
MESSAGES_IMPORT_PENDING = 'MESSAGES_IMPORT_PENDING',
MESSAGES_IMPORT_ONGOING = 'MESSAGES_IMPORT_ONGOING',

View file

@ -1,21 +1,7 @@
import { CustomException } from 'src/utils/custom-exception';
export class ViewException extends CustomException {
constructor(message: string, code: ViewExceptionCode) {
super(message, code);
}
}
export class ViewException extends CustomException<ViewExceptionCode> {}
export enum ViewExceptionCode {
VIEW_NOT_FOUND = 'VIEW_NOT_FOUND',
CANNOT_DELETE_INDEX_VIEW = 'CANNOT_DELETE_INDEX_VIEW',
METHOD_NOT_IMPLEMENTED = 'METHOD_NOT_IMPLEMENTED',
CORE_VIEW_SYNC_ERROR = 'CORE_VIEW_SYNC_ERROR',
}
export enum ViewExceptionMessage {
VIEW_NOT_FOUND = 'View not found',
CANNOT_DELETE_INDEX_VIEW = 'Cannot delete index view',
METHOD_NOT_IMPLEMENTED = 'Method not implemented',
CORE_VIEW_SYNC_ERROR = 'Failed to sync view data to core',
}

View file

@ -1,12 +1,7 @@
import { CustomException } from 'src/utils/custom-exception';
export class WorkflowCommonException extends CustomException {
constructor(message: string, code: WorkflowCommonExceptionCode) {
super(message, code);
}
}
export class WorkflowCommonException extends CustomException<WorkflowCommonExceptionCode> {}
export enum WorkflowCommonExceptionCode {
OBJECT_METADATA_NOT_FOUND = 'OBJECT_METADATA_NOT_FOUND',
INVALID_CACHE_VERSION = 'INVALID_CACHE_VERSION',
}

View file

@ -1,14 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class WorkflowQueryValidationException extends CustomException {
constructor(
message: string,
code: WorkflowQueryValidationExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
) {
super(message, code, userFriendlyMessage);
}
}
export class WorkflowQueryValidationException extends CustomException<WorkflowQueryValidationExceptionCode> {}
export enum WorkflowQueryValidationExceptionCode {
FORBIDDEN = 'FORBIDDEN',

View file

@ -1,14 +1,7 @@
import { CustomException } from 'src/utils/custom-exception';
export class WorkflowVersionStepException extends CustomException {
constructor(
message: string,
code: WorkflowVersionStepExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
) {
super(message, code, userFriendlyMessage);
}
}
export class WorkflowVersionStepException extends CustomException<WorkflowVersionStepExceptionCode> {}
export enum WorkflowVersionStepExceptionCode {
UNKNOWN = 'UNKNOWN',
NOT_FOUND = 'NOT_FOUND',

View file

@ -1,11 +0,0 @@
import { CustomException } from 'src/utils/custom-exception';
export class WorkflowExecutorException extends CustomException {
constructor(message: string, code: string) {
super(message, code);
}
}
export enum WorkflowExecutorExceptionCode {
WORKFLOW_FAILED = 'WORKFLOW_FAILED',
}

View file

@ -1,20 +1,10 @@
import { CustomException } from 'src/utils/custom-exception';
export class WorkflowStepExecutorException extends CustomException {
constructor(
message: string,
code: WorkflowStepExecutorExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
) {
super(message, code, userFriendlyMessage);
}
}
export class WorkflowStepExecutorException extends CustomException<WorkflowStepExecutorExceptionCode> {}
export enum WorkflowStepExecutorExceptionCode {
SCOPED_WORKSPACE_NOT_FOUND = 'SCOPED_WORKSPACE_NOT_FOUND',
INVALID_STEP_TYPE = 'INVALID_STEP_TYPE',
STEP_NOT_FOUND = 'STEP_NOT_FOUND',
INVALID_STEP_SETTINGS = 'INVALID_STEP_SETTINGS',
INTERNAL_ERROR = 'INTERNAL_ERROR',
FAILED_TO_EXECUTE_STEP = 'FAILED_TO_EXECUTE_STEP',
}

View file

@ -1,14 +0,0 @@
import { CustomException } from 'src/utils/custom-exception';
export class SendEmailActionException extends CustomException {
constructor(message: string, code: SendEmailActionExceptionCode) {
super(message, code);
}
}
export enum SendEmailActionExceptionCode {
PROVIDER_NOT_SUPPORTED = 'PROVIDER_NOT_SUPPORTED',
CONNECTED_ACCOUNT_NOT_FOUND = 'CONNECTED_ACCOUNT_NOT_FOUND',
INVALID_EMAIL = 'INVALID_EMAIL',
INVALID_CONNECTED_ACCOUNT_ID = 'INVALID_CONNECTED_ACCOUNT_ID',
}

View file

@ -1,10 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class RecordCRUDActionException extends CustomException {
constructor(message: string, code: RecordCRUDActionExceptionCode) {
super(message, code);
}
}
export class RecordCRUDActionException extends CustomException<RecordCRUDActionExceptionCode> {}
export enum RecordCRUDActionExceptionCode {
INVALID_REQUEST = 'INVALID_REQUEST',

View file

@ -1,10 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class WorkflowRunException extends CustomException {
constructor(message: string, code: WorkflowRunExceptionCode) {
super(message, code);
}
}
export class WorkflowRunException extends CustomException<WorkflowRunExceptionCode> {}
export enum WorkflowRunExceptionCode {
WORKFLOW_RUN_NOT_FOUND = 'WORKFLOW_RUN_NOT_FOUND',
@ -13,5 +9,4 @@ export enum WorkflowRunExceptionCode {
INVALID_INPUT = 'INVALID_INPUT',
WORKFLOW_RUN_LIMIT_REACHED = 'WORKFLOW_RUN_LIMIT_REACHED',
WORKFLOW_RUN_INVALID = 'WORKFLOW_RUN_INVALID',
FAILURE = 'FAILURE',
}

View file

@ -1,15 +1,6 @@
import { CustomException } from 'src/utils/custom-exception';
export class WorkflowTriggerException extends CustomException {
declare code: WorkflowTriggerExceptionCode;
constructor(
message: string,
code: WorkflowTriggerExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: string } = {},
) {
super(message, code, userFriendlyMessage);
}
}
export class WorkflowTriggerException extends CustomException<WorkflowTriggerExceptionCode> {}
export enum WorkflowTriggerExceptionCode {
INVALID_INPUT = 'INVALID_INPUT',

View file

@ -0,0 +1,93 @@
import {
appendCommonExceptionCode,
CustomException,
UnknownException,
} from 'src/utils/custom-exception';
describe('appendCommonExceptionCode', () => {
it('should merge CommonExceptionCode with specific exception code', () => {
const specificExceptionCode = {
SPECIFIC_ERROR: 'SPECIFIC_ERROR',
};
const result = appendCommonExceptionCode(specificExceptionCode);
expect(result).toEqual({
INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR',
SPECIFIC_ERROR: 'SPECIFIC_ERROR',
});
});
it('should return CommonExceptionCode when empty object is provided', () => {
const result = appendCommonExceptionCode({});
expect(result).toEqual({
INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR',
});
});
});
describe('CustomException', () => {
// Create a concrete implementation of the abstract class for testing
class TestException extends CustomException {}
it('should set message and code correctly', () => {
const message = 'Test error message';
const code = 'TEST_ERROR';
const exception = new TestException(message, code);
expect(exception.message).toBe(message);
expect(exception.code).toBe(code);
expect(exception.userFriendlyMessage).toBeUndefined();
});
it('should set userFriendlyMessage when provided', () => {
const message = 'Test error message';
const code = 'TEST_ERROR';
const userFriendlyMessage = 'User friendly error message';
const exception = new TestException(message, code, {
userFriendlyMessage,
});
expect(exception.message).toBe(message);
expect(exception.code).toBe(code);
expect(exception.userFriendlyMessage).toBe(userFriendlyMessage);
});
it('should extend Error', () => {
const exception = new TestException('Test error', 'TEST_ERROR');
expect(exception).toBeInstanceOf(Error);
});
});
describe('UnknownException', () => {
it('should extend CustomException', () => {
const exception = new UnknownException('Test error', 'TEST_ERROR');
expect(exception).toBeInstanceOf(CustomException);
});
it('should set message and code correctly', () => {
const message = 'Test error message';
const code = 'TEST_ERROR';
const exception = new UnknownException(message, code);
expect(exception.message).toBe(message);
expect(exception.code).toBe(code);
expect(exception.userFriendlyMessage).toBeUndefined();
});
it('should set userFriendlyMessage when provided', () => {
const message = 'Test error message';
const code = 'TEST_ERROR';
const userFriendlyMessage = 'User friendly error message';
const exception = new UnknownException(message, code, {
userFriendlyMessage,
});
expect(exception.message).toBe(message);
expect(exception.code).toBe(code);
expect(exception.userFriendlyMessage).toBe(userFriendlyMessage);
});
});

View file

@ -1,10 +1,44 @@
export class CustomException extends Error {
code: string;
userFriendlyMessage?: string;
const CommonExceptionCode = {
INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR',
} as const;
constructor(message: string, code: string, userFriendlyMessage?: string) {
export const appendCommonExceptionCode = <
SpecificExceptionCode = Record<string, string>,
>(
specificExceptionCode: SpecificExceptionCode,
) => {
return {
...CommonExceptionCode,
...specificExceptionCode,
} as const;
};
export abstract class CustomException<
ExceptionCode extends string = string,
ForceFriendlyMessage = false,
ExceptionMessage extends string = string,
ExceptionFriendlyMessage extends string = string,
> extends Error {
code: ExceptionCode;
userFriendlyMessage?: ExceptionFriendlyMessage;
constructor(
message: ExceptionMessage,
code: ExceptionCode,
...userFriendlyMessage: ForceFriendlyMessage extends true
? [{ userFriendlyMessage: ExceptionFriendlyMessage }]
: [{ userFriendlyMessage?: ExceptionFriendlyMessage }?]
) {
super(message);
this.code = code;
this.userFriendlyMessage = userFriendlyMessage;
this.userFriendlyMessage = userFriendlyMessage
? userFriendlyMessage?.[0]?.userFriendlyMessage
: undefined;
}
}
/**
* Exception class for test scenarios and edge cases.
* Prefer domain-specific exceptions in production code.
*/
export class UnknownException extends CustomException {}

View file

@ -30759,9 +30759,9 @@ __metadata:
languageName: node
linkType: hard
"cloudflare@npm:^4.0.0":
version: 4.0.0
resolution: "cloudflare@npm:4.0.0"
"cloudflare@npm:^4.5.0":
version: 4.5.0
resolution: "cloudflare@npm:4.5.0"
dependencies:
"@types/node": "npm:^18.11.18"
"@types/node-fetch": "npm:^2.6.4"
@ -30770,7 +30770,7 @@ __metadata:
form-data-encoder: "npm:1.7.2"
formdata-node: "npm:^4.3.2"
node-fetch: "npm:^2.6.7"
checksum: 10c0/d83a4d0544de5794935acb802c875c6f718713d8c7613b2a74d6145b922f18415e514cc57bbef9249726d83c0788ac8a72517a69efb1fd5b5af58b654403c375
checksum: 10c0/376ca8cde08878e383a151626c40f702160565776cdb455f6091009b6733628bae266bf47d61c8e2ff21fb94388f0a609af24872dea7c01a1b3e26d76deec8db
languageName: node
linkType: hard
@ -57194,7 +57194,7 @@ __metadata:
cache-manager-redis-yet: "npm:^4.1.2"
class-validator: "patch:class-validator@0.14.0#./patches/class-validator+0.14.0.patch"
class-validator-jsonschema: "npm:^5.0.2"
cloudflare: "npm:^4.0.0"
cloudflare: "npm:^4.5.0"
connect-redis: "npm:^7.1.1"
express-session: "npm:^1.18.1"
graphql-middleware: "npm:^6.1.35"