614bc7b7e6
## 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>
163 lines
4.7 KiB
TypeScript
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;
|
|
};
|