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">``&lt;img alt="Review
in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"&gt;``</a>
This commit is contained in:
Félix Malfait
2026-06-24 15:57:01 +02:00
committed by GitHub
parent 5e5c8e0956
commit 614bc7b7e6
66 changed files with 996 additions and 350 deletions
@@ -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
@@ -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,
};
};
@@ -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');
});
});
@@ -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}`;
};