Files
twenty/packages/twenty-front-component-renderer/src/remote/components/FrontComponentWorkerEffect.tsx
T
Félix Malfait 614bc7b7e6 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>
2026-06-24 15:57:01 +02:00

163 lines
4.7 KiB
TypeScript

import { ThreadWebWorker, release, retain } from '@quilted/threads';
import { RemoteReceiver } from '@remote-dom/core/receivers';
import { useEffect, useRef } from 'react';
import { type CommandConfirmationModalResult } from 'twenty-sdk/front-component';
import { type ConfirmationModalCaller } from 'twenty-shared/types';
import { type FrontComponentHostCommunicationApi } from '../../types/FrontComponentHostCommunicationApi';
import { type SdkClientUrls } from '../../types/HostToWorkerRenderContext';
import { type WorkerExports } from '../../types/WorkerExports';
import { createRemoteWorker } from '../worker/utils/createRemoteWorker';
// Must match COMMAND_MENU_ITEM_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME in twenty-front
const COMMAND_MENU_ITEM_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME =
'command-menu-item-confirmation-modal-result';
type CommandMenuItemConfirmationModalResultBrowserEventDetail = {
caller: ConfirmationModalCaller;
confirmationResult: CommandConfirmationModalResult;
};
const noopAsync = async () => {};
const HOST_COMMUNICATION_API_NOOP_INITIALIZATION: FrontComponentHostCommunicationApi =
{
navigate: noopAsync,
requestAccessTokenRefresh: async () => '',
openSidePanelPage: noopAsync,
openCommandConfirmationModal: noopAsync,
unmountFrontComponent: noopAsync,
enqueueSnackbar: noopAsync,
closeSidePanel: noopAsync,
updateProgress: noopAsync,
copyToClipboard: noopAsync,
};
type FrontComponentWorkerEffectProps = {
componentUrl: string;
applicationAccessToken?: string;
apiUrl?: string;
functionsBaseUrl?: string;
sdkClientUrls?: SdkClientUrls;
applicationVariables?: Record<string, string>;
frontComponentId: string;
setReceiver: React.Dispatch<React.SetStateAction<RemoteReceiver | null>>;
setThread: React.Dispatch<
React.SetStateAction<ThreadWebWorker<
WorkerExports,
FrontComponentHostCommunicationApi
> | null>
>;
setError: React.Dispatch<React.SetStateAction<Error | null>>;
};
export const FrontComponentWorkerEffect = ({
componentUrl,
applicationAccessToken,
apiUrl,
functionsBaseUrl,
sdkClientUrls,
applicationVariables,
frontComponentId,
setReceiver,
setThread,
setError,
}: FrontComponentWorkerEffectProps) => {
const isInitializedRef = useRef(false);
useEffect(() => {
if (isInitializedRef.current) {
return;
}
const newReceiver = new RemoteReceiver({ retain, release });
const worker = createRemoteWorker();
worker.onerror = (event: ErrorEvent) => {
const workerError =
event.error ?? new Error(event.message || 'Unknown worker error');
console.error('[FrontComponentRenderer] Worker error:', workerError);
setError(workerError);
};
const thread = new ThreadWebWorker<
WorkerExports,
FrontComponentHostCommunicationApi
>(worker, {
exports: { ...HOST_COMMUNICATION_API_NOOP_INITIALIZATION },
});
const handleCommandMenuItemConfirmationModalResultBrowserEvent = (
event: CustomEvent<CommandMenuItemConfirmationModalResultBrowserEventDetail>,
) => {
const commandMenuItemConfirmationModalResultBrowserEventDetail =
event.detail;
const caller =
commandMenuItemConfirmationModalResultBrowserEventDetail.caller;
if (
caller.type !== 'frontComponent' ||
caller.frontComponentId !== frontComponentId
) {
return;
}
thread.imports
.onConfirmationModalResult(
commandMenuItemConfirmationModalResultBrowserEventDetail.confirmationResult,
)
.catch((error: Error) => {
setError(error);
});
};
window.addEventListener(
COMMAND_MENU_ITEM_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME,
handleCommandMenuItemConfirmationModalResultBrowserEvent as EventListener,
);
setThread(thread);
thread.imports
.render(newReceiver.connection, {
componentUrl,
applicationAccessToken,
apiUrl,
functionsBaseUrl,
sdkClientUrls,
applicationVariables,
})
.catch((error: Error) => {
setError(error);
});
setReceiver(newReceiver);
isInitializedRef.current = true;
return () => {
window.removeEventListener(
COMMAND_MENU_ITEM_CONFIRMATION_MODAL_RESULT_BROWSER_EVENT_NAME,
handleCommandMenuItemConfirmationModalResultBrowserEvent as EventListener,
);
setThread(null);
worker.terminate();
isInitializedRef.current = false;
};
}, [
componentUrl,
applicationAccessToken,
apiUrl,
functionsBaseUrl,
sdkClientUrls,
applicationVariables,
frontComponentId,
setError,
setReceiver,
setThread,
]);
return null;
};