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
@@ -41,6 +41,7 @@ const MOCK_FLAT_APPLICATION: FlatApplication = {
settingsCustomTabFrontComponentId: null,
canBeUninstalled: false,
applicationRegistrationId: null,
primaryPublicDomainId: null,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
@@ -36,6 +36,7 @@ const MOCK_FLAT_APPLICATION: FlatApplication = {
settingsCustomTabFrontComponentId: null,
canBeUninstalled: false,
applicationRegistrationId: null,
primaryPublicDomainId: null,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { FlatFrontComponentModule } from 'src/engine/metadata-modules/flat-front-component/flat-front-component.module';
import { FrontComponentController } from 'src/engine/metadata-modules/front-component/controllers/front-component.controller';
@@ -25,6 +26,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
FlatFrontComponentModule,
SubscriptionsModule,
WorkspaceCacheModule,
WorkspaceDomainsModule,
],
controllers: [FrontComponentController],
providers: [
@@ -41,9 +41,8 @@ describe('RouteTriggerController', () => {
const response = createResponseMock();
handle.mockResolvedValue({
statusCode: 200,
headers: {},
body: { ok: true },
response: { statusCode: 200, headers: {}, body: { ok: true } },
isIsolatedOrigin: false,
});
await controller.post(request, response);
@@ -60,9 +59,12 @@ describe('RouteTriggerController', () => {
const response = createResponseMock();
handle.mockResolvedValue({
statusCode: 201,
headers: { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' },
body: '<h1>Hi</h1>',
response: {
statusCode: 201,
headers: { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' },
body: '<h1>Hi</h1>',
},
isIsolatedOrigin: false,
});
await controller.get({} as never, response);
@@ -79,18 +81,21 @@ describe('RouteTriggerController', () => {
expect(response.send).toHaveBeenCalledWith('<h1>Hi</h1>');
});
it('drops headers that are not in the allow-list', async () => {
it('drops headers that are not in the allow-list on the same-site /s/ route', async () => {
const response = createResponseMock();
handle.mockResolvedValue({
statusCode: 200,
headers: {
'Content-Type': 'text/html',
'Set-Cookie': 'session=abc',
'Access-Control-Allow-Origin': '*',
'X-Custom': 'foo',
response: {
statusCode: 200,
headers: {
'Content-Type': 'text/html',
'Set-Cookie': 'session=abc',
'Access-Control-Allow-Origin': '*',
'X-Custom': 'foo',
},
body: '<h1>Hi</h1>',
},
body: '<h1>Hi</h1>',
isIsolatedOrigin: false,
});
await controller.get({} as never, response);
@@ -110,10 +115,53 @@ describe('RouteTriggerController', () => {
expect(response.setHeader).not.toHaveBeenCalledWith('X-Custom', 'foo');
});
it('forwards every header when the request is served from an isolated origin', async () => {
const response = createResponseMock();
handle.mockResolvedValue({
response: {
statusCode: 200,
headers: {
'Content-Type': 'text/html',
'Permissions-Policy': 'camera=(self)',
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
'Set-Cookie': 'session=abc',
'X-Custom': 'foo',
},
body: '<h1>Hi</h1>',
},
isIsolatedOrigin: true,
});
await controller.get({} as never, response);
expect(response.setHeader).toHaveBeenCalledWith(
'Permissions-Policy',
'camera=(self)',
);
expect(response.setHeader).toHaveBeenCalledWith(
'Cross-Origin-Opener-Policy',
'same-origin',
);
expect(response.setHeader).toHaveBeenCalledWith(
'Cross-Origin-Embedder-Policy',
'require-corp',
);
expect(response.setHeader).toHaveBeenCalledWith(
'Set-Cookie',
'session=abc',
);
expect(response.setHeader).toHaveBeenCalledWith('X-Custom', 'foo');
});
it('sends an empty response when the body is nil', async () => {
const response = createResponseMock();
handle.mockResolvedValue({ statusCode: 200, headers: {}, body: null });
handle.mockResolvedValue({
response: { statusCode: 200, headers: {}, body: null },
isIsolatedOrigin: false,
});
await controller.get({} as never, response);
@@ -125,7 +173,10 @@ describe('RouteTriggerController', () => {
it('defaults a string body content-type to text/plain when none is set', async () => {
const response = createResponseMock();
handle.mockResolvedValue({ statusCode: 200, headers: {}, body: 'plain' });
handle.mockResolvedValue({
response: { statusCode: 200, headers: {}, body: 'plain' },
isIsolatedOrigin: false,
});
await controller.get({} as never, response);
@@ -140,9 +191,8 @@ describe('RouteTriggerController', () => {
const response = createResponseMock();
handle.mockResolvedValue({
statusCode: 200,
headers: {},
body: { ok: true },
response: { statusCode: 200, headers: {}, body: { ok: true } },
isIsolatedOrigin: false,
});
await controller.get({} as never, response);
@@ -154,9 +204,12 @@ describe('RouteTriggerController', () => {
const response = createResponseMock();
handle.mockResolvedValue({
statusCode: 200,
headers: { 'Content-Type': 'application/ld+json' },
body: { ok: true },
response: {
statusCode: 200,
headers: { 'Content-Type': 'application/ld+json' },
body: { ok: true },
},
isIsolatedOrigin: false,
});
await controller.get({} as never, response);
@@ -26,58 +26,41 @@ import { sendRouteTriggerResponse } from 'src/engine/core-modules/logic-function
export class RouteTriggerController {
constructor(private readonly routeTriggerService: RouteTriggerService) {}
private async handleRequest(
request: Request,
response: Response,
httpMethod: HTTPMethod,
) {
const { response: triggerResponse, isIsolatedOrigin } =
await this.routeTriggerService.handle({ request, httpMethod });
sendRouteTriggerResponse(response, triggerResponse, {
allowAllHeaders: isIsolatedOrigin,
});
}
@Get('*path')
async get(@Req() request: Request, @Res() response: Response) {
sendRouteTriggerResponse(
response,
await this.routeTriggerService.handle({
request,
httpMethod: HTTPMethod.GET,
}),
);
await this.handleRequest(request, response, HTTPMethod.GET);
}
@Post('*path')
async post(@Req() request: Request, @Res() response: Response) {
sendRouteTriggerResponse(
response,
await this.routeTriggerService.handle({
request,
httpMethod: HTTPMethod.POST,
}),
);
await this.handleRequest(request, response, HTTPMethod.POST);
}
@Put('*path')
async put(@Req() request: Request, @Res() response: Response) {
sendRouteTriggerResponse(
response,
await this.routeTriggerService.handle({
request,
httpMethod: HTTPMethod.PUT,
}),
);
await this.handleRequest(request, response, HTTPMethod.PUT);
}
@Patch('*path')
async patch(@Req() request: Request, @Res() response: Response) {
sendRouteTriggerResponse(
response,
await this.routeTriggerService.handle({
request,
httpMethod: HTTPMethod.PATCH,
}),
);
await this.handleRequest(request, response, HTTPMethod.PATCH);
}
@Delete('*path')
async delete(@Req() request: Request, @Res() response: Response) {
sendRouteTriggerResponse(
response,
await this.routeTriggerService.handle({
request,
httpMethod: HTTPMethod.DELETE,
}),
);
await this.handleRequest(request, response, HTTPMethod.DELETE);
}
}