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:
+3
@@ -26,6 +26,7 @@ export class LogicFunctionTriggerService {
|
||||
request,
|
||||
pathParameters,
|
||||
forwardedRequestHeaders,
|
||||
forwardAllHeaders = false,
|
||||
userId,
|
||||
userWorkspaceId,
|
||||
}: {
|
||||
@@ -33,6 +34,7 @@ export class LogicFunctionTriggerService {
|
||||
request: Request;
|
||||
pathParameters: Record<string, string | string[] | undefined>;
|
||||
forwardedRequestHeaders: string[];
|
||||
forwardAllHeaders?: boolean;
|
||||
userId?: string | null;
|
||||
userWorkspaceId?: string | null;
|
||||
}): Promise<LogicFunctionTriggerOutcome> {
|
||||
@@ -40,6 +42,7 @@ export class LogicFunctionTriggerService {
|
||||
request,
|
||||
pathParameters,
|
||||
forwardedRequestHeaders,
|
||||
forwardAllHeaders,
|
||||
userWorkspaceId: userWorkspaceId ?? null,
|
||||
});
|
||||
|
||||
|
||||
+6
@@ -45,6 +45,12 @@ export class RouteTriggerRestApiExceptionFilter implements ExceptionFilter {
|
||||
response,
|
||||
429,
|
||||
);
|
||||
case RouteTriggerExceptionCode.LEGACY_ROUTE_DEPRECATED:
|
||||
return this.httpExceptionHandlerService.handleError(
|
||||
exception as CustomException,
|
||||
response,
|
||||
410,
|
||||
);
|
||||
case RouteTriggerExceptionCode.ROUTE_TRIGGER_USER_UNCAUGHT_ERROR:
|
||||
return this.httpExceptionHandlerService.handleError(
|
||||
exception as CustomException,
|
||||
|
||||
+3
@@ -15,6 +15,7 @@ export enum RouteTriggerExceptionCode {
|
||||
ROUTE_TRIGGER_USER_UNCAUGHT_ERROR = 'ROUTE_TRIGGER_USER_UNCAUGHT_ERROR',
|
||||
ROUTE_TRIGGER_PLATFORM_ERROR = 'ROUTE_TRIGGER_PLATFORM_ERROR',
|
||||
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
|
||||
LEGACY_ROUTE_DEPRECATED = 'LEGACY_ROUTE_DEPRECATED',
|
||||
}
|
||||
|
||||
const getRouteTriggerExceptionUserFriendlyMessage = (
|
||||
@@ -41,6 +42,8 @@ const getRouteTriggerExceptionUserFriendlyMessage = (
|
||||
return msg`An unexpected error occurred while executing the logic function.`;
|
||||
case RouteTriggerExceptionCode.RATE_LIMIT_EXCEEDED:
|
||||
return msg`Too many requests. Please try again later.`;
|
||||
case RouteTriggerExceptionCode.LEGACY_ROUTE_DEPRECATED:
|
||||
return msg`This endpoint is no longer available on /s/. Use the dedicated public domain URL instead.`;
|
||||
default:
|
||||
assertUnreachable(code);
|
||||
}
|
||||
|
||||
+71
-4
@@ -1,6 +1,8 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { Request } from 'express';
|
||||
import { match } from 'path-to-regexp';
|
||||
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
|
||||
@@ -9,11 +11,14 @@ import { HTTPMethod } from 'twenty-shared/types';
|
||||
|
||||
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
|
||||
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import {
|
||||
RouteTriggerException,
|
||||
RouteTriggerExceptionCode,
|
||||
} from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger.exception';
|
||||
import { LogicFunctionTriggerService } from 'src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.service';
|
||||
import { type RouteTriggerResponse } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util';
|
||||
import {
|
||||
LogicFunctionException,
|
||||
LogicFunctionExceptionCode,
|
||||
@@ -33,6 +38,7 @@ export class RouteTriggerService {
|
||||
private readonly accessTokenService: AccessTokenService,
|
||||
private readonly logicFunctionTriggerService: LogicFunctionTriggerService,
|
||||
private readonly workspaceDomainsService: WorkspaceDomainsService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
@InjectRepository(LogicFunctionEntity)
|
||||
private readonly logicFunctionRepository: Repository<LogicFunctionEntity>,
|
||||
) {}
|
||||
@@ -46,10 +52,11 @@ export class RouteTriggerService {
|
||||
}): Promise<{
|
||||
logicFunction: LogicFunctionEntity;
|
||||
pathParams: Partial<Record<string, string | string[]>>;
|
||||
isIsolatedOrigin: boolean;
|
||||
}> {
|
||||
const host = `${request.protocol}://${request.get('host')}`;
|
||||
|
||||
const { workspace, publicDomain } =
|
||||
const { workspace, publicDomain, isIsolatedOrigin } =
|
||||
await this.workspaceDomainsService.resolveWorkspaceAndPublicDomain(host);
|
||||
|
||||
assertIsDefinedOrThrow(
|
||||
@@ -90,9 +97,16 @@ export class RouteTriggerService {
|
||||
const routeMatched = routeMatcher(requestPath);
|
||||
|
||||
if (routeMatched) {
|
||||
this.assertLegacyRouteIsServableOrThrow({
|
||||
logicFunction,
|
||||
workspace,
|
||||
isIsolatedOrigin,
|
||||
});
|
||||
|
||||
return {
|
||||
logicFunction,
|
||||
pathParams: routeMatched.params,
|
||||
isIsolatedOrigin,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -103,6 +117,58 @@ export class RouteTriggerService {
|
||||
);
|
||||
}
|
||||
|
||||
private assertLegacyRouteIsServableOrThrow({
|
||||
logicFunction,
|
||||
workspace,
|
||||
isIsolatedOrigin,
|
||||
}: {
|
||||
logicFunction: LogicFunctionEntity;
|
||||
workspace: WorkspaceEntity;
|
||||
isIsolatedOrigin: boolean;
|
||||
}) {
|
||||
if (isIsolatedOrigin) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cutoffIso = this.twentyConfigService.get(
|
||||
'LOGIC_FUNCTION_LEGACY_ROUTE_CUTOFF',
|
||||
);
|
||||
|
||||
if (!isNonEmptyString(cutoffIso)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const publicFunctionUrl =
|
||||
this.workspaceDomainsService.buildPublicFunctionUrl({
|
||||
workspace,
|
||||
path: logicFunction.httpRouteTriggerSettings?.path ?? '/',
|
||||
});
|
||||
|
||||
if (!isDefined(publicFunctionUrl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cutoffDate = new Date(cutoffIso);
|
||||
|
||||
if (Number.isNaN(cutoffDate.getTime())) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (logicFunction.createdAt.getTime() >= cutoffDate.getTime()) {
|
||||
this.logger.warn(
|
||||
`Logic function ${logicFunction.id} was requested on the deprecated /s/ route but is only served on ${publicFunctionUrl}`,
|
||||
);
|
||||
|
||||
throw new RouteTriggerException(
|
||||
`Logic function ${logicFunction.id} is no longer served on the legacy /s/ route`,
|
||||
RouteTriggerExceptionCode.LEGACY_ROUTE_DEPRECATED,
|
||||
{
|
||||
userFriendlyMessage: msg`This endpoint has moved. Call it at ${publicFunctionUrl} instead.`,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async validateWorkspaceFromRequest({
|
||||
request,
|
||||
workspaceId,
|
||||
@@ -160,8 +226,8 @@ export class RouteTriggerService {
|
||||
}: {
|
||||
request: Request;
|
||||
httpMethod: HTTPMethod;
|
||||
}) {
|
||||
const { logicFunction, pathParams } =
|
||||
}): Promise<{ response: RouteTriggerResponse; isIsolatedOrigin: boolean }> {
|
||||
const { logicFunction, pathParams, isIsolatedOrigin } =
|
||||
await this.getLogicFunctionWithPathParamsOrFail({
|
||||
request,
|
||||
httpMethod,
|
||||
@@ -191,6 +257,7 @@ export class RouteTriggerService {
|
||||
pathParameters: pathParams,
|
||||
forwardedRequestHeaders:
|
||||
httpRouteSettings?.forwardedRequestHeaders ?? [],
|
||||
forwardAllHeaders: isIsolatedOrigin,
|
||||
userId,
|
||||
userWorkspaceId,
|
||||
});
|
||||
@@ -225,6 +292,6 @@ export class RouteTriggerService {
|
||||
);
|
||||
}
|
||||
|
||||
return outcome.response;
|
||||
return { response: outcome.response, isIsolatedOrigin };
|
||||
}
|
||||
}
|
||||
|
||||
+23
@@ -107,6 +107,29 @@ describe('filterRequestHeaders', () => {
|
||||
'content-type': 'application/json',
|
||||
});
|
||||
});
|
||||
|
||||
it('should forward every header when forwardAllHeaders is true', () => {
|
||||
const requestHeaders = {
|
||||
'content-type': 'application/json',
|
||||
authorization: 'Bearer token123',
|
||||
'x-custom-header': 'custom-value',
|
||||
'x-array-header': ['a', 'b'],
|
||||
'x-missing': undefined,
|
||||
};
|
||||
|
||||
const result = filterRequestHeaders({
|
||||
requestHeaders,
|
||||
forwardedRequestHeaders: [],
|
||||
forwardAllHeaders: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
'content-type': 'application/json',
|
||||
authorization: 'Bearer token123',
|
||||
'x-custom-header': 'custom-value',
|
||||
'x-array-header': 'a, b',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractBody', () => {
|
||||
|
||||
+25
-3
@@ -4,13 +4,34 @@ import { type LogicFunctionEvent } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { isObject, isString } from '@sniptt/guards';
|
||||
|
||||
const normalizeHeaderValue = (
|
||||
headerValue: string | string[] | undefined,
|
||||
): string | undefined =>
|
||||
Array.isArray(headerValue) ? headerValue.join(', ') : headerValue;
|
||||
|
||||
export const filterRequestHeaders = ({
|
||||
requestHeaders,
|
||||
forwardedRequestHeaders,
|
||||
forwardAllHeaders = false,
|
||||
}: {
|
||||
requestHeaders: Request['headers'];
|
||||
forwardedRequestHeaders: string[];
|
||||
forwardAllHeaders?: boolean;
|
||||
}): Record<string, string | undefined> => {
|
||||
if (forwardAllHeaders) {
|
||||
const allHeaders: Record<string, string | undefined> = {};
|
||||
|
||||
for (const [headerName, headerValue] of Object.entries(requestHeaders)) {
|
||||
if (headerValue === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
allHeaders[headerName] = normalizeHeaderValue(headerValue);
|
||||
}
|
||||
|
||||
return allHeaders;
|
||||
}
|
||||
|
||||
const lowercaseForwardedHeaders = forwardedRequestHeaders.map((h) =>
|
||||
h.toLowerCase(),
|
||||
);
|
||||
@@ -21,9 +42,7 @@ export const filterRequestHeaders = ({
|
||||
const headerValue = requestHeaders[headerName];
|
||||
|
||||
if (headerValue !== undefined) {
|
||||
filteredHeaders[headerName] = Array.isArray(headerValue)
|
||||
? headerValue.join(', ')
|
||||
: headerValue;
|
||||
filteredHeaders[headerName] = normalizeHeaderValue(headerValue);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,11 +137,13 @@ export const buildLogicFunctionEvent = ({
|
||||
request,
|
||||
pathParameters,
|
||||
forwardedRequestHeaders,
|
||||
forwardAllHeaders = false,
|
||||
userWorkspaceId,
|
||||
}: {
|
||||
request: Request;
|
||||
pathParameters: Record<string, string | string[] | undefined>;
|
||||
forwardedRequestHeaders: string[];
|
||||
forwardAllHeaders?: boolean;
|
||||
userWorkspaceId: string | null;
|
||||
}): LogicFunctionEvent => {
|
||||
const rawBody = extractRawBody(request);
|
||||
@@ -131,6 +152,7 @@ export const buildLogicFunctionEvent = ({
|
||||
headers: filterRequestHeaders({
|
||||
requestHeaders: request.headers,
|
||||
forwardedRequestHeaders,
|
||||
forwardAllHeaders,
|
||||
}),
|
||||
queryStringParameters: normalizeQueryStringParameters(request.query),
|
||||
pathParameters: normalizePathParameters(pathParameters),
|
||||
|
||||
+2
-1
@@ -33,11 +33,12 @@ export const buildRouteTriggerResponse = (
|
||||
export const sendRouteTriggerResponse = (
|
||||
response: Response,
|
||||
{ statusCode, headers, body }: RouteTriggerResponse,
|
||||
{ allowAllHeaders = false }: { allowAllHeaders?: boolean } = {},
|
||||
) => {
|
||||
response.status(statusCode);
|
||||
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
if (ALLOWED_RESPONSE_HEADERS.has(key.toLowerCase())) {
|
||||
if (allowAllHeaders || ALLOWED_RESPONSE_HEADERS.has(key.toLowerCase())) {
|
||||
response.setHeader(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user