feat: serve HTTP logic functions on isolated *.withtwenty.com domain (#22045)
## Summary Implements [core-team-issues#2473](https://github.com/twentyhq/core-team-issues/issues/2473): serve HTTP-triggered logic functions from a dedicated, **cookieless** public domain (`{workspaceSubdomain}.withtwenty.com`) instead of the same-site `/s/` route, so functions can safely return **arbitrary headers** — custom headers, `Permissions-Policy` (camera/mic/geolocation), `Cross-Origin-Opener-Policy: same-origin`, `Cross-Origin-Embedder-Policy: require-corp`, `Set-Cookie`, etc. The `/s/` route stays the strict, same-site path it is today. **Self-hosting is unchanged** — everything new is gated on `PUBLIC_DOMAIN_URL` being set. ### Why Today user-authored function responses are served same-site with the Twenty app, so the response-header allow-list is restricted to 5 safe headers and request headers are limited to a per-function allow-list. Serving from an origin that shares nothing with `*.twenty.com` removes that constraint safely — the same "user content domain" pattern as GitHub (`*.githubusercontent.com`) and CodeSandbox (`*.csb.app`). ## What's in here **Routing** - The **root-path → `/s` rewrite happens at the nginx ingress**, not in app code. The existing `api-ingress.yaml` already rewrites root paths onto `/s` (host-agnostically) when the edge sets `X-Twenty-Public-Domain: true`, so `*.withtwenty.com` and registered custom public domains are handled by the same mechanism. (An earlier in-app middleware was removed as a redundant, wrong-layer duplicate.) - `WorkspaceDomainsService.resolveWorkspaceAndPublicDomain` recognizes `*.` subdomains, resolves the workspace by subdomain, and returns `isIsolatedOrigin`. Explicitly registered public-domain rows still take precedence and keep their application scoping. The ingress preserves the `Host` header, so this resolution still fires. **Headers (server)** - Isolated origin → all response headers pass through and all request headers are forwarded. Same-site `/s/` keeps the strict allow-lists. (Global CORS already handles preflight/ACAO.) **`/s/` deprecation for new routes (cloud only)** - New `LOGIC_FUNCTION_LEGACY_ROUTE_CUTOFF` config var (ISO date, optional). When `PUBLIC_DOMAIN_URL` is set, functions created on/after the cutoff return **410 Gone** on `/s/` with the new URL. Existing routes and self-hosted instances are untouched. **Frontend education** - `publicFunctionDomain` added to `ClientConfig` (from `PUBLIC_DOMAIN_URL`). - The logic-function **Live URL** now resolves to `https://{workspaceSubdomain}.{publicFunctionDomain}{path}` on cloud, falling back to `/s/` for self-hosting. - Front components call their functions through the SDK (`RestApiClient`), which now targets the isolated domain via the injected `TWENTY_FUNCTIONS_URL`. - New **"Public URL"** section on the application **Settings** tab explaining the isolated domain (shown when the app exposes HTTP-triggered functions). **Docs**: note the `withtwenty.com` domain for external callers in the apps guide. ## Infra prerequisites (not code — needs dashboard work) - Wildcard DNS `*.withtwenty.com` (proxied) + wildcard TLS in the public-domain Cloudflare zone. - Edge (Cloudflare) sets `X-Twenty-Public-Domain: true` for `*.withtwenty.com` requests, so the existing nginx ingress rewrites them onto `/s` (same header the custom-domain flow already relies on). - Set `PUBLIC_DOMAIN_URL=https://withtwenty.com` on cloud. - Submit `withtwenty.com` to the **Public Suffix List** (required for cross-tenant cookie isolation before relying on `Set-Cookie`). ## Test plan - [x] `nx typecheck twenty-server`, `nx typecheck twenty-front` - [x] `lint:diff-with-main` + oxfmt clean (server + front) - [x] `npx jest route-trigger public-function-domain domain-server-config workspace-domains build-logic-function-event client-config` → server unit tests passing (resolution tiers, header passthrough vs allow-list, `/s/` cutoff 410) - [x] `npx jest getLogicFunctionHttpUrl` (front) and `nx test twenty-client-sdk` (RestApiClient routing) passing - [x] CI green (server, front, sdk, renderer, ui, zapier, example apps) - [ ] Manual: hit `{subdomain}.withtwenty.com/` end-to-end once infra is provisioned <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22045?utm_source=github" rel="nofollow noreferrer noopener" target="_blank">``<img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg">``</a>
This commit is contained in:
@@ -178,6 +178,7 @@ export const useClientConfig = (): UseClientConfigResult => {
|
||||
setDomainConfiguration({
|
||||
defaultSubdomain: clientConfig?.defaultSubdomain,
|
||||
frontDomain: clientConfig?.frontDomain,
|
||||
publicFunctionDomain: clientConfig?.publicFunctionDomain,
|
||||
});
|
||||
setCanManageFeatureFlags(clientConfig?.canManageFeatureFlags);
|
||||
setLabPublicFeatureFlags(clientConfig?.publicFeatureFlags);
|
||||
|
||||
@@ -22,6 +22,7 @@ export type ClientConfig = {
|
||||
captcha: Captcha;
|
||||
defaultSubdomain?: string;
|
||||
frontDomain: string;
|
||||
publicFunctionDomain?: string | null;
|
||||
isAttachmentPreviewEnabled: boolean;
|
||||
isConfigVariablesInDbEnabled: boolean;
|
||||
isEmailVerificationRequired: boolean;
|
||||
|
||||
@@ -2,11 +2,15 @@ import { type ClientConfig } from '@/client-config/types/ClientConfig';
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const domainConfigurationState = createAtomState<
|
||||
Pick<ClientConfig, 'frontDomain' | 'defaultSubdomain'>
|
||||
Pick<
|
||||
ClientConfig,
|
||||
'frontDomain' | 'defaultSubdomain' | 'publicFunctionDomain'
|
||||
>
|
||||
>({
|
||||
key: 'domainConfiguration',
|
||||
defaultValue: {
|
||||
frontDomain: '',
|
||||
defaultSubdomain: undefined,
|
||||
publicFunctionDomain: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
+15
@@ -1,5 +1,9 @@
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
|
||||
import { FrontComponentRendererProvider } from '@/front-components/components/FrontComponentRendererProvider';
|
||||
import { FrontComponentRendererWithSdkClient } from '@/front-components/components/FrontComponentRendererWithSdkClient';
|
||||
import { getFunctionsBaseUrl } from '@/settings/logic-functions/utils/getLogicFunctionHttpUrl';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useFrontComponentExecutionContext } from '@/front-components/hooks/useFrontComponentExecutionContext';
|
||||
import { useOnFrontComponentUpdated } from '@/front-components/hooks/useOnFrontComponentUpdated';
|
||||
import { frontComponentApplicationTokenPairComponentState } from '@/front-components/states/frontComponentApplicationTokenPairComponentState';
|
||||
@@ -29,6 +33,9 @@ export const FrontComponentRenderer = ({
|
||||
const { colorScheme } = useContext(ThemeContext);
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
|
||||
const { publicFunctionDomain } = useAtomStateValue(domainConfigurationState);
|
||||
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
|
||||
|
||||
const setFrontComponentApplicationTokenPair = useSetAtomComponentState(
|
||||
frontComponentApplicationTokenPairComponentState,
|
||||
frontComponentId,
|
||||
@@ -104,6 +111,12 @@ export const FrontComponentRenderer = ({
|
||||
const applicationVariables =
|
||||
data.frontComponent.applicationVariables ?? undefined;
|
||||
|
||||
const functionsBaseUrl =
|
||||
getFunctionsBaseUrl({
|
||||
publicFunctionDomain,
|
||||
workspaceSubdomain: currentWorkspace?.subdomain,
|
||||
}) ?? `${REACT_APP_SERVER_BASE_URL}/s`;
|
||||
|
||||
if (usesSdkClient) {
|
||||
return (
|
||||
<FrontComponentRendererProvider frontComponentId={frontComponentId}>
|
||||
@@ -112,6 +125,7 @@ export const FrontComponentRenderer = ({
|
||||
componentUrl={componentUrl}
|
||||
applicationAccessToken={accessToken}
|
||||
applicationId={data.frontComponent.applicationId}
|
||||
functionsBaseUrl={functionsBaseUrl}
|
||||
executionContext={executionContext}
|
||||
frontComponentHostCommunicationApi={
|
||||
frontComponentHostCommunicationApi
|
||||
@@ -130,6 +144,7 @@ export const FrontComponentRenderer = ({
|
||||
componentUrl={componentUrl}
|
||||
applicationAccessToken={accessToken}
|
||||
apiUrl={REACT_APP_SERVER_BASE_URL}
|
||||
functionsBaseUrl={functionsBaseUrl}
|
||||
executionContext={executionContext}
|
||||
frontComponentHostCommunicationApi={frontComponentHostCommunicationApi}
|
||||
applicationVariables={applicationVariables}
|
||||
|
||||
+3
@@ -14,6 +14,7 @@ type FrontComponentRendererWithSdkClientProps = {
|
||||
componentUrl: string;
|
||||
applicationAccessToken: string;
|
||||
applicationId: string;
|
||||
functionsBaseUrl?: string;
|
||||
executionContext: FrontComponentExecutionContext;
|
||||
frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi;
|
||||
applicationVariables?: Record<string, string>;
|
||||
@@ -25,6 +26,7 @@ export const FrontComponentRendererWithSdkClient = ({
|
||||
componentUrl,
|
||||
applicationAccessToken,
|
||||
applicationId,
|
||||
functionsBaseUrl,
|
||||
executionContext,
|
||||
frontComponentHostCommunicationApi,
|
||||
applicationVariables,
|
||||
@@ -47,6 +49,7 @@ export const FrontComponentRendererWithSdkClient = ({
|
||||
componentUrl={componentUrl}
|
||||
applicationAccessToken={applicationAccessToken}
|
||||
apiUrl={REACT_APP_SERVER_BASE_URL}
|
||||
functionsBaseUrl={functionsBaseUrl}
|
||||
sdkClientUrls={sdkClientState.blobUrls}
|
||||
executionContext={executionContext}
|
||||
frontComponentHostCommunicationApi={
|
||||
|
||||
+11
-76
@@ -6,10 +6,9 @@ import { getSettingsPath, isDefined } from 'twenty-shared/utils';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { SettingsPageLayout } from '@/settings/components/layout/SettingsPageLayout';
|
||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||
import { Button, ButtonGroup, type SelectOption } from 'twenty-ui/input';
|
||||
import { Button, ButtonGroup } from 'twenty-ui/input';
|
||||
import { styled } from '@linaria/react';
|
||||
import { SettingsDomainRecords } from '@/settings/domains/components/SettingsDomainRecords';
|
||||
import { useCheckPublicDomainValidRecords } from '@/settings/domains/hooks/useCheckPublicDomainValidRecords';
|
||||
@@ -17,14 +16,14 @@ import { useMutation, useQuery } from '@apollo/client/react';
|
||||
import {
|
||||
CreatePublicDomainDocument,
|
||||
DeletePublicDomainDocument,
|
||||
FindManyApplicationsDocument,
|
||||
FindManyPublicDomainsDocument,
|
||||
UpdatePublicDomainDocument,
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { CheckPublicDomainValidRecordsEffect } from '@/settings/domains/components/CheckPublicDomainValidRecordsEffect';
|
||||
import { selectedApplicationIdForPublicDomainState } from '@/settings/domains/states/selectedApplicationIdForPublicDomainState';
|
||||
import { selectedPublicDomainState } from '@/settings/domains/states/selectedPublicDomainState';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { useState } from 'react';
|
||||
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
|
||||
import { getDomainValidationSchema } from '@/settings/domains/utils/getDomainValidationSchema';
|
||||
@@ -57,6 +56,9 @@ export const SettingPublicDomain = () => {
|
||||
const [selectedPublicDomain, setSelectedPublicDomain] = useAtomState(
|
||||
selectedPublicDomainState,
|
||||
);
|
||||
const selectedApplicationIdForPublicDomain = useAtomStateValue(
|
||||
selectedApplicationIdForPublicDomainState,
|
||||
);
|
||||
const { t } = useLingui();
|
||||
const navigate = useNavigateSettings();
|
||||
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
|
||||
@@ -65,22 +67,10 @@ export const SettingPublicDomain = () => {
|
||||
CreatePublicDomainDocument,
|
||||
);
|
||||
|
||||
const [updatePublicDomain] = useMutation(UpdatePublicDomainDocument);
|
||||
|
||||
const [newPublicDomain, setNewPublicDomain] = useState<string | undefined>(
|
||||
selectedPublicDomain?.domain ?? '',
|
||||
);
|
||||
|
||||
// Holds the chosen application before the public domain is created.
|
||||
// Once selectedPublicDomain exists, the dropdown reads from it directly.
|
||||
const [draftApplicationId, setDraftApplicationId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const selectedApplicationId = isDefined(selectedPublicDomain)
|
||||
? (selectedPublicDomain.applicationId ?? null)
|
||||
: draftApplicationId;
|
||||
|
||||
const [newPublicDomainError, setNewPublicDomainError] = useState<
|
||||
string | undefined
|
||||
>(undefined);
|
||||
@@ -89,20 +79,6 @@ export const SettingPublicDomain = () => {
|
||||
FindManyPublicDomainsDocument,
|
||||
);
|
||||
|
||||
const { data: applicationsData } = useQuery(FindManyApplicationsDocument);
|
||||
|
||||
const applicationPinnedOption: SelectOption<string | null> = {
|
||||
value: null,
|
||||
label: t`Workspace (all apps)`,
|
||||
};
|
||||
|
||||
const applicationOptions: SelectOption<string | null>[] = (
|
||||
applicationsData?.findManyApplications ?? []
|
||||
).map((application) => ({
|
||||
value: application.id,
|
||||
label: application.name,
|
||||
}));
|
||||
|
||||
const [deletePublicDomain] = useMutation(DeletePublicDomainDocument);
|
||||
|
||||
const { isLoading, publicDomainRecords, checkPublicDomainRecords } =
|
||||
@@ -132,7 +108,10 @@ export const SettingPublicDomain = () => {
|
||||
const validationSchema = getDomainValidationSchema();
|
||||
|
||||
const onCreate = async () => {
|
||||
if (!isDefined(newPublicDomain)) {
|
||||
if (
|
||||
!isDefined(newPublicDomain) ||
|
||||
!isDefined(selectedApplicationIdForPublicDomain)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -148,7 +127,7 @@ export const SettingPublicDomain = () => {
|
||||
await createPublicDomain({
|
||||
variables: {
|
||||
domain: newPublicDomain,
|
||||
applicationId: draftApplicationId,
|
||||
applicationId: selectedApplicationIdForPublicDomain,
|
||||
},
|
||||
onCompleted: (data) => {
|
||||
setSelectedPublicDomain(data.createPublicDomain);
|
||||
@@ -165,35 +144,6 @@ export const SettingPublicDomain = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const onApplicationChange = async (nextApplicationId: string | null) => {
|
||||
if (!isDefined(selectedPublicDomain)) {
|
||||
setDraftApplicationId(nextApplicationId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextApplicationId === (selectedPublicDomain.applicationId ?? null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await updatePublicDomain({
|
||||
variables: {
|
||||
domain: selectedPublicDomain.domain,
|
||||
applicationId: nextApplicationId,
|
||||
},
|
||||
onCompleted: (data) => {
|
||||
setSelectedPublicDomain(data.updatePublicDomain);
|
||||
enqueueSuccessSnackBar({
|
||||
message: t`Public domain updated successfully`,
|
||||
});
|
||||
refetchPublicDomains();
|
||||
},
|
||||
onError: (error) =>
|
||||
enqueueErrorSnackBar({
|
||||
apolloError: error,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsPageLayout
|
||||
title={t`Public domain`}
|
||||
@@ -271,21 +221,6 @@ export const SettingPublicDomain = () => {
|
||||
</StyledRecordsWrapper>
|
||||
)}
|
||||
</Section>
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Bound App`}
|
||||
description={t`Restrict this domain to the HTTP routes of a specific app. Leave empty to expose all workspace HTTP routes.`}
|
||||
/>
|
||||
<Select
|
||||
dropdownId="public-domain-application"
|
||||
label={t`Application`}
|
||||
fullWidth
|
||||
value={selectedApplicationId}
|
||||
pinnedOption={applicationPinnedOption}
|
||||
options={applicationOptions}
|
||||
onChange={onApplicationChange}
|
||||
/>
|
||||
</Section>
|
||||
</SettingsPageContainer>
|
||||
</SettingsPageLayout>
|
||||
);
|
||||
|
||||
+21
-10
@@ -1,6 +1,7 @@
|
||||
import { SettingsCard } from '@/settings/components/SettingsCard';
|
||||
import { SettingsListCard } from '@/settings/components/SettingsListCard';
|
||||
import { SettingPublicDomainRowDropdownMenu } from '@/settings/domains/components/SettingPublicDomainRowDropdownMenu';
|
||||
import { selectedApplicationIdForPublicDomainState } from '@/settings/domains/states/selectedApplicationIdForPublicDomainState';
|
||||
import { selectedPublicDomainState } from '@/settings/domains/states/selectedPublicDomainState';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
@@ -14,30 +15,42 @@ import {
|
||||
} from '~/generated-metadata/graphql';
|
||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||
|
||||
export const SettingsPublicDomainsListCard = () => {
|
||||
export const SettingsPublicDomainsListCard = ({
|
||||
applicationId,
|
||||
}: {
|
||||
applicationId: string;
|
||||
}) => {
|
||||
const navigate = useNavigateSettings();
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
const setSelectedPublicDomain = useSetAtomState(selectedPublicDomainState);
|
||||
const setSelectedApplicationIdForPublicDomain = useSetAtomState(
|
||||
selectedApplicationIdForPublicDomainState,
|
||||
);
|
||||
|
||||
const { data, loading } = useQuery(FindManyPublicDomainsDocument);
|
||||
|
||||
const publicDomains = data?.findManyPublicDomains;
|
||||
const publicDomains = data?.findManyPublicDomains?.filter(
|
||||
(publicDomain) => publicDomain.applicationId === applicationId,
|
||||
);
|
||||
|
||||
if (loading || !publicDomains) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const navigateToCreate = () => {
|
||||
setSelectedPublicDomain(undefined);
|
||||
setSelectedApplicationIdForPublicDomain(applicationId);
|
||||
navigate(SettingsPath.PublicDomain);
|
||||
};
|
||||
|
||||
if (publicDomains.length === 0) {
|
||||
return (
|
||||
<SettingsCard
|
||||
title={t`Add Public Domain`}
|
||||
Icon={<IconMailCog />}
|
||||
onClick={() => {
|
||||
setSelectedPublicDomain(undefined);
|
||||
navigate(SettingsPath.PublicDomain);
|
||||
}}
|
||||
onClick={navigateToCreate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -50,6 +63,7 @@ export const SettingsPublicDomainsListCard = () => {
|
||||
RowIcon={IconAt}
|
||||
onRowClick={(publicDomain: PublicDomain) => {
|
||||
setSelectedPublicDomain(publicDomain);
|
||||
setSelectedApplicationIdForPublicDomain(applicationId);
|
||||
navigate(SettingsPath.PublicDomain);
|
||||
}}
|
||||
RowRightComponent={({ item: publicDomain }) => (
|
||||
@@ -62,10 +76,7 @@ export const SettingsPublicDomainsListCard = () => {
|
||||
)}
|
||||
hasFooter
|
||||
footerButtonLabel={t`Add Public Domain`}
|
||||
onFooterButtonClick={() => {
|
||||
setSelectedPublicDomain(undefined);
|
||||
navigate(SettingsPath.PublicDomain);
|
||||
}}
|
||||
onFooterButtonClick={navigateToCreate}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const CREATE_PUBLIC_DOMAIN = gql`
|
||||
mutation CreatePublicDomain($domain: String!, $applicationId: String) {
|
||||
mutation CreatePublicDomain($domain: String!, $applicationId: String!) {
|
||||
createPublicDomain(domain: $domain, applicationId: $applicationId) {
|
||||
id
|
||||
domain
|
||||
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const UPDATE_PUBLIC_DOMAIN = gql`
|
||||
mutation UpdatePublicDomain($domain: String!, $applicationId: String) {
|
||||
updatePublicDomain(domain: $domain, applicationId: $applicationId) {
|
||||
id
|
||||
domain
|
||||
isValidated
|
||||
applicationId
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const selectedApplicationIdForPublicDomainState = createAtomState<
|
||||
string | undefined
|
||||
>({
|
||||
key: 'selectedApplicationIdForPublicDomainState',
|
||||
defaultValue: undefined,
|
||||
});
|
||||
+3
-4
@@ -21,7 +21,7 @@ import {
|
||||
} from 'twenty-ui/icon';
|
||||
import { Toggle } from 'twenty-ui/input';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||
import { useGetLogicFunctionHttpUrl } from '@/settings/logic-functions/hooks/useGetLogicFunctionHttpUrl';
|
||||
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
|
||||
|
||||
const HTTP_METHOD_OPTIONS: Array<{
|
||||
@@ -73,6 +73,7 @@ export const SettingsLogicFunctionHttpTriggerSection = ({
|
||||
const { t } = useLingui();
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const { copyToClipboard } = useCopyToClipboard();
|
||||
const { getLogicFunctionHttpUrl } = useGetLogicFunctionHttpUrl();
|
||||
|
||||
const updateField = <TKey extends keyof HttpRouteTriggerSettings>(
|
||||
key: TKey,
|
||||
@@ -84,9 +85,7 @@ export const SettingsLogicFunctionHttpTriggerSection = ({
|
||||
onChange({ ...value, [key]: fieldValue });
|
||||
};
|
||||
|
||||
const fullUrl = isDefined(value)
|
||||
? `${REACT_APP_SERVER_BASE_URL}/s${value.path}`
|
||||
: '';
|
||||
const fullUrl = isDefined(value) ? getLogicFunctionHttpUrl(value.path) : '';
|
||||
|
||||
return (
|
||||
<SettingsLogicFunctionTriggerSection
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
|
||||
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
|
||||
import { getLogicFunctionHttpUrl } from '@/settings/logic-functions/utils/getLogicFunctionHttpUrl';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||
|
||||
export const useGetLogicFunctionHttpUrl = () => {
|
||||
const { publicFunctionDomain } = useAtomStateValue(domainConfigurationState);
|
||||
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
|
||||
|
||||
const workspaceSubdomain = currentWorkspace?.subdomain;
|
||||
|
||||
const getHttpUrl = useCallback(
|
||||
(path: string) =>
|
||||
getLogicFunctionHttpUrl({
|
||||
path,
|
||||
serverBaseUrl: REACT_APP_SERVER_BASE_URL,
|
||||
publicFunctionDomain,
|
||||
workspaceSubdomain,
|
||||
}),
|
||||
[publicFunctionDomain, workspaceSubdomain],
|
||||
);
|
||||
|
||||
return {
|
||||
getLogicFunctionHttpUrl: getHttpUrl,
|
||||
publicFunctionDomain,
|
||||
workspaceSubdomain,
|
||||
};
|
||||
};
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
getFunctionsBaseUrl,
|
||||
getLogicFunctionHttpUrl,
|
||||
} from '@/settings/logic-functions/utils/getLogicFunctionHttpUrl';
|
||||
|
||||
describe('getFunctionsBaseUrl', () => {
|
||||
it('builds the isolated base from subdomain + public domain', () => {
|
||||
expect(
|
||||
getFunctionsBaseUrl({
|
||||
publicFunctionDomain: 'withtwenty.com',
|
||||
workspaceSubdomain: 'acme',
|
||||
}),
|
||||
).toBe('https://acme.withtwenty.com');
|
||||
});
|
||||
|
||||
it('returns undefined when the public domain is missing', () => {
|
||||
expect(
|
||||
getFunctionsBaseUrl({
|
||||
publicFunctionDomain: null,
|
||||
workspaceSubdomain: 'acme',
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when the subdomain is missing', () => {
|
||||
expect(
|
||||
getFunctionsBaseUrl({
|
||||
publicFunctionDomain: 'withtwenty.com',
|
||||
workspaceSubdomain: undefined,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLogicFunctionHttpUrl', () => {
|
||||
it('builds the isolated public-domain URL when configured', () => {
|
||||
expect(
|
||||
getLogicFunctionHttpUrl({
|
||||
path: '/webhook/stripe',
|
||||
serverBaseUrl: 'https://api.twenty.com',
|
||||
publicFunctionDomain: 'withtwenty.com',
|
||||
workspaceSubdomain: 'acme',
|
||||
}),
|
||||
).toBe('https://acme.withtwenty.com/webhook/stripe');
|
||||
});
|
||||
|
||||
it('normalizes a path that does not start with a slash', () => {
|
||||
expect(
|
||||
getLogicFunctionHttpUrl({
|
||||
path: 'webhook',
|
||||
serverBaseUrl: 'https://api.twenty.com',
|
||||
publicFunctionDomain: 'withtwenty.com',
|
||||
workspaceSubdomain: 'acme',
|
||||
}),
|
||||
).toBe('https://acme.withtwenty.com/webhook');
|
||||
});
|
||||
|
||||
it('falls back to the legacy /s/ route when no public domain is configured', () => {
|
||||
expect(
|
||||
getLogicFunctionHttpUrl({
|
||||
path: '/webhook/stripe',
|
||||
serverBaseUrl: 'https://api.twenty.com',
|
||||
publicFunctionDomain: null,
|
||||
workspaceSubdomain: 'acme',
|
||||
}),
|
||||
).toBe('https://api.twenty.com/s/webhook/stripe');
|
||||
});
|
||||
|
||||
it('falls back to the legacy /s/ route when the workspace has no subdomain', () => {
|
||||
expect(
|
||||
getLogicFunctionHttpUrl({
|
||||
path: '/webhook',
|
||||
serverBaseUrl: 'https://api.twenty.com',
|
||||
publicFunctionDomain: 'withtwenty.com',
|
||||
workspaceSubdomain: undefined,
|
||||
}),
|
||||
).toBe('https://api.twenty.com/s/webhook');
|
||||
});
|
||||
});
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
export const getFunctionsBaseUrl = ({
|
||||
publicFunctionDomain,
|
||||
workspaceSubdomain,
|
||||
}: {
|
||||
publicFunctionDomain?: string | null;
|
||||
workspaceSubdomain?: string | null;
|
||||
}): string | undefined => {
|
||||
if (
|
||||
isNonEmptyString(publicFunctionDomain) &&
|
||||
isNonEmptyString(workspaceSubdomain)
|
||||
) {
|
||||
return `https://${workspaceSubdomain}.${publicFunctionDomain}`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getLogicFunctionHttpUrl = ({
|
||||
path,
|
||||
serverBaseUrl,
|
||||
publicFunctionDomain,
|
||||
workspaceSubdomain,
|
||||
}: {
|
||||
path: string;
|
||||
serverBaseUrl: string;
|
||||
publicFunctionDomain?: string | null;
|
||||
workspaceSubdomain?: string | null;
|
||||
}): string => {
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
|
||||
const functionsBaseUrl = getFunctionsBaseUrl({
|
||||
publicFunctionDomain,
|
||||
workspaceSubdomain,
|
||||
});
|
||||
|
||||
if (isNonEmptyString(functionsBaseUrl)) {
|
||||
return `${functionsBaseUrl}${normalizedPath}`;
|
||||
}
|
||||
|
||||
return `${serverBaseUrl}/s${normalizedPath}`;
|
||||
};
|
||||
Reference in New Issue
Block a user