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
@@ -19,6 +19,7 @@ import { ApplicationRegistrationEntity } from 'src/engine/core-modules/applicati
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
import { PublicDomainEntity } from 'src/engine/core-modules/public-domain/public-domain.entity';
import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { CommandMenuItemEntity } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity';
@@ -120,6 +121,29 @@ export class ApplicationEntity extends WorkspaceRelatedEntity {
@JoinColumn({ name: 'applicationRegistrationId' })
applicationRegistration: Relation<ApplicationRegistrationEntity> | null;
@Column({ nullable: true, type: 'uuid' })
@WasIntroducedInUpgrade({
upgradeCommandName:
'2.16.0_AddPrimaryPublicDomainToApplicationFastInstanceCommand_1782281874768',
})
primaryPublicDomainId: string | null;
@ManyToOne(() => PublicDomainEntity, {
onDelete: 'SET NULL',
nullable: true,
})
@JoinColumn({ name: 'primaryPublicDomainId' })
primaryPublicDomain: Relation<PublicDomainEntity> | null;
@OneToMany(
() => PublicDomainEntity,
(publicDomain) => publicDomain.application,
{
onDelete: 'SET NULL',
},
)
publicDomains: Relation<PublicDomainEntity[]>;
@OneToMany(() => AgentEntity, (agent) => agent.application, {
onDelete: 'CASCADE',
})
@@ -258,6 +258,21 @@ export class ApplicationService {
});
}
async findPrimaryPublicDomainName({
applicationId,
workspaceId,
}: {
applicationId: string;
workspaceId: string;
}): Promise<string | null> {
const application = await this.applicationRepository.findOne({
where: { id: applicationId, workspaceId },
relations: ['primaryPublicDomain'],
});
return application?.primaryPublicDomain?.domain ?? null;
}
async findByUniversalIdentifier({
universalIdentifier,
workspaceId,
@@ -11,4 +11,6 @@ export const APPLICATION_ENTITY_RELATION_PROPERTIES = [
'packageJsonFile',
'yarnLockFile',
'applicationRegistration',
'primaryPublicDomain',
'publicDomains',
] as const satisfies (keyof ApplicationEntity)[];
@@ -68,6 +68,7 @@ describe('ClientConfigController', () => {
isEmailVerificationRequired: false,
defaultSubdomain: 'app',
frontDomain: 'localhost',
publicFunctionDomain: null,
support: {
supportDriver: SupportDriver.NONE,
supportFrontChatId: undefined,
@@ -270,6 +270,9 @@ export class ClientConfig {
@Field(() => String)
frontDomain: string;
@Field(() => String, { nullable: true })
publicFunctionDomain: string | null;
@Field(() => Boolean)
analyticsEnabled: boolean;
@@ -30,6 +30,7 @@ describe('ClientConfigService', () => {
provide: DomainServerConfigService,
useValue: {
getFrontUrl: jest.fn(),
getPublicBaseHostnameOrUndefined: jest.fn(),
},
},
{
@@ -147,6 +148,7 @@ describe('ClientConfigService', () => {
isEmailVerificationRequired: true,
defaultSubdomain: 'app',
frontDomain: 'app.twenty.com',
publicFunctionDomain: null,
support: {
supportDriver: 'FRONT',
supportFrontChatId: 'chat-123',
@@ -195,6 +195,9 @@ export class ClientConfigService {
),
defaultSubdomain: this.twentyConfigService.get('DEFAULT_SUBDOMAIN'),
frontDomain: this.domainServerConfigService.getFrontUrl().hostname,
publicFunctionDomain:
this.domainServerConfigService.getPublicBaseHostnameOrUndefined() ??
null,
support: {
supportDriver: supportDriver ? supportDriver : SupportDriver.NONE,
supportFrontChatId: this.twentyConfigService.get(
@@ -1,6 +1,12 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { buildUrlWithPathnameAndSearchParams } from 'src/engine/core-modules/domain/domain-server-config/utils/build-url-with-pathname-and-search-params.util';
import {
getHostnameFromUrlOrUndefined,
isHostUnderPublicFunctionDomain,
} from 'src/engine/core-modules/domain/domain-server-config/utils/public-function-domain.util';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
@@ -31,6 +37,12 @@ export class DomainServerConfigService {
return new URL(this.twentyConfigService.get('PUBLIC_DOMAIN_URL'));
}
getPublicBaseHostnameOrUndefined(): string | undefined {
return getHostnameFromUrlOrUndefined(
this.twentyConfigService.get('PUBLIC_DOMAIN_URL'),
);
}
buildBaseUrl({
pathname,
searchParams,
@@ -52,14 +64,38 @@ export class DomainServerConfigService {
const isFrontdomain = originHostname.endsWith(`.${frontDomain}`);
const subdomain = originHostname.replace(`.${frontDomain}`, '');
if (isFrontdomain) {
const subdomain = originHostname.replace(`.${frontDomain}`, '');
return {
subdomain: this.isDefaultSubdomain(subdomain) ? undefined : subdomain,
domain: null,
isPublicDomainOrigin: false,
};
}
const publicBaseDomain = this.getPublicBaseHostnameOrUndefined();
if (
isDefined(publicBaseDomain) &&
isHostUnderPublicFunctionDomain({
host: originHostname,
publicDomainBaseHostname: publicBaseDomain,
})
) {
const subdomain = originHostname.replace(`.${publicBaseDomain}`, '');
return {
subdomain: this.isDefaultSubdomain(subdomain) ? undefined : subdomain,
domain: null,
isPublicDomainOrigin: true,
};
}
return {
subdomain:
isFrontdomain && !this.isDefaultSubdomain(subdomain)
? subdomain
: undefined,
domain: isFrontdomain ? null : originHostname,
subdomain: undefined,
domain: originHostname,
isPublicDomainOrigin: false,
};
};
@@ -0,0 +1,104 @@
import {
getHostnameFromUrlOrUndefined,
isHostUnderPublicFunctionDomain,
} from 'src/engine/core-modules/domain/domain-server-config/utils/public-function-domain.util';
describe('getHostnameFromUrlOrUndefined', () => {
it('returns the lowercased hostname of a valid url', () => {
expect(getHostnameFromUrlOrUndefined('https://WithTwenty.com')).toBe(
'withtwenty.com',
);
});
it('ignores the path and port', () => {
expect(
getHostnameFromUrlOrUndefined('https://withtwenty.com:8080/ignored'),
).toBe('withtwenty.com');
});
it('returns undefined for empty/nullish input', () => {
expect(getHostnameFromUrlOrUndefined(undefined)).toBeUndefined();
expect(getHostnameFromUrlOrUndefined(null)).toBeUndefined();
expect(getHostnameFromUrlOrUndefined('')).toBeUndefined();
});
it('returns undefined for a non-url string', () => {
expect(getHostnameFromUrlOrUndefined('not a url')).toBeUndefined();
});
});
describe('isHostUnderPublicFunctionDomain', () => {
const publicDomainBaseHostname = 'withtwenty.com';
it('matches a strict subdomain of the base', () => {
expect(
isHostUnderPublicFunctionDomain({
host: 'acme.withtwenty.com',
publicDomainBaseHostname,
}),
).toBe(true);
});
it('matches deeper subdomains', () => {
expect(
isHostUnderPublicFunctionDomain({
host: 'app.acme.withtwenty.com',
publicDomainBaseHostname,
}),
).toBe(true);
});
it('is case-insensitive and strips the port', () => {
expect(
isHostUnderPublicFunctionDomain({
host: 'ACME.WithTwenty.com:443',
publicDomainBaseHostname,
}),
).toBe(true);
});
it('does not match the apex base itself', () => {
expect(
isHostUnderPublicFunctionDomain({
host: 'withtwenty.com',
publicDomainBaseHostname,
}),
).toBe(false);
});
it('does not match the main app domain', () => {
expect(
isHostUnderPublicFunctionDomain({
host: 'acme.twenty.com',
publicDomainBaseHostname,
}),
).toBe(false);
});
it('does not match a lookalike suffix', () => {
expect(
isHostUnderPublicFunctionDomain({
host: 'evilwithtwenty.com',
publicDomainBaseHostname,
}),
).toBe(false);
});
it('returns false when no base is configured', () => {
expect(
isHostUnderPublicFunctionDomain({
host: 'acme.withtwenty.com',
publicDomainBaseHostname: undefined,
}),
).toBe(false);
});
it('returns false when host is missing', () => {
expect(
isHostUnderPublicFunctionDomain({
host: undefined,
publicDomainBaseHostname,
}),
).toBe(false);
});
});
@@ -0,0 +1,32 @@
import { isNonEmptyString } from '@sniptt/guards';
export const getHostnameFromUrlOrUndefined = (
url?: string | null,
): string | undefined => {
if (!isNonEmptyString(url)) {
return undefined;
}
try {
return new URL(url).hostname.toLowerCase();
} catch {
return undefined;
}
};
export const isHostUnderPublicFunctionDomain = ({
host,
publicDomainBaseHostname,
}: {
host?: string | null;
publicDomainBaseHostname?: string;
}): boolean => {
if (!isNonEmptyString(host) || !isNonEmptyString(publicDomainBaseHostname)) {
return false;
}
const hostname = host.split(':')[0].toLowerCase();
const base = publicDomainBaseHostname.toLowerCase();
return hostname !== base && hostname.endsWith(`.${base}`);
};
@@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
@@ -99,8 +100,9 @@ export class WorkspaceDomainsService {
async resolveWorkspaceAndPublicDomain(origin: string): Promise<{
workspace: WorkspaceEntity | undefined;
publicDomain: PublicDomainEntity | null;
isIsolatedOrigin: boolean;
}> {
const { subdomain, domain } =
const { subdomain, domain, isPublicDomainOrigin } =
this.domainServerConfigService.getSubdomainAndDomainFromUrl(origin);
if (!this.twentyConfigService.get('IS_MULTIWORKSPACE_ENABLED')) {
@@ -113,11 +115,46 @@ export class WorkspaceDomainsService {
return {
workspace: await this.getDefaultWorkspace(),
publicDomain: publicDomain ?? null,
isIsolatedOrigin: isPublicDomainOrigin || isDefined(publicDomain),
};
}
if (isPublicDomainOrigin) {
const hostname = new URL(origin).hostname;
const registeredPublicDomain = await this.publicDomainRepository.findOne({
where: { domain: hostname },
relations: ['workspace', 'workspace.workspaceSSOIdentityProviders'],
});
if (isDefined(registeredPublicDomain)) {
return {
workspace: registeredPublicDomain.workspace ?? undefined,
publicDomain: registeredPublicDomain,
isIsolatedOrigin: true,
};
}
const workspaceFromSubdomain = isDefined(subdomain)
? ((await this.workspaceRepository.findOne({
where: { subdomain },
relations: ['workspaceSSOIdentityProviders'],
})) ?? undefined)
: undefined;
return {
workspace: workspaceFromSubdomain,
publicDomain: null,
isIsolatedOrigin: true,
};
}
if (!domain && !subdomain) {
return { workspace: undefined, publicDomain: null };
return {
workspace: undefined,
publicDomain: null,
isIsolatedOrigin: false,
};
}
const where = isDefined(domain) ? { customDomain: domain } : { subdomain };
@@ -132,6 +169,7 @@ export class WorkspaceDomainsService {
return {
workspace: workspaceFromCustomDomainOrSubdomain,
publicDomain: null,
isIsolatedOrigin: false,
};
}
@@ -143,9 +181,51 @@ export class WorkspaceDomainsService {
return {
workspace: publicDomain?.workspace ?? undefined,
publicDomain: publicDomain ?? null,
isIsolatedOrigin: isDefined(publicDomain),
};
}
buildPublicFunctionBaseUrl({
workspace,
primaryPublicDomain,
}: {
workspace: Pick<WorkspaceEntity, 'subdomain'>;
primaryPublicDomain?: string | null;
}): string | undefined {
if (isNonEmptyString(primaryPublicDomain)) {
return `https://${primaryPublicDomain}`;
}
const publicBaseHostname =
this.domainServerConfigService.getPublicBaseHostnameOrUndefined();
if (!isNonEmptyString(publicBaseHostname)) {
return undefined;
}
const url = this.domainServerConfigService.getPublicDomainUrl();
url.hostname = `${workspace.subdomain}.${publicBaseHostname}`;
return url.origin;
}
buildPublicFunctionUrl({
workspace,
path,
}: {
workspace: Pick<WorkspaceEntity, 'subdomain'>;
path: string;
}): string | undefined {
const baseUrl = this.buildPublicFunctionBaseUrl({ workspace });
if (!isDefined(baseUrl)) {
return undefined;
}
return `${baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
}
private getCustomWorkspaceUrl(customDomain: string) {
const url = this.domainServerConfigService.getFrontUrl();
@@ -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,
});
@@ -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,
@@ -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);
}
@@ -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 };
}
}
@@ -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', () => {
@@ -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),
@@ -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);
}
}
@@ -1,6 +1,6 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
@ArgsType()
export class CreatePublicDomainInput {
@@ -9,8 +9,7 @@ export class CreatePublicDomainInput {
@IsNotEmpty()
domain: string;
@Field(() => String, { nullable: true })
@IsOptional()
@Field(() => String)
@IsUUID()
applicationId?: string | null;
applicationId: string;
}
@@ -1,16 +0,0 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
@ArgsType()
export class UpdatePublicDomainInput {
@Field(() => String)
@IsString()
@IsNotEmpty()
domain: string;
@Field(() => String, { nullable: true })
@IsOptional()
@IsUUID()
applicationId?: string | null;
}
@@ -34,13 +34,17 @@ export class PublicDomainEntity extends WorkspaceRelatedEntity {
@Column({ type: 'boolean', default: false, nullable: false })
isValidated: boolean;
@Column({ type: 'uuid', nullable: true })
applicationId: string | null;
@Column({ type: 'uuid', nullable: false })
applicationId: string;
@ManyToOne(() => ApplicationEntity, {
onDelete: 'CASCADE',
nullable: true,
})
@ManyToOne(
() => ApplicationEntity,
(application) => application.publicDomains,
{
onDelete: 'CASCADE',
nullable: false,
},
)
@JoinColumn({ name: 'applicationId' })
application: Relation<ApplicationEntity> | null;
application: Relation<ApplicationEntity>;
}
@@ -14,7 +14,6 @@ import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/re
import { CreatePublicDomainInput } from 'src/engine/core-modules/public-domain/dtos/create-public-domain.input';
import { PublicDomainDTO } from 'src/engine/core-modules/public-domain/dtos/public-domain.dto';
import { PublicDomainInput } from 'src/engine/core-modules/public-domain/dtos/public-domain.input';
import { UpdatePublicDomainInput } from 'src/engine/core-modules/public-domain/dtos/update-public-domain.input';
import { PublicDomainExceptionFilter } from 'src/engine/core-modules/public-domain/public-domain-exception-filter';
import { PublicDomainEntity } from 'src/engine/core-modules/public-domain/public-domain.entity';
import {
@@ -60,19 +59,7 @@ export class PublicDomainResolver {
return this.publicDomainService.createPublicDomain({
domain,
workspace: currentWorkspace,
applicationId: applicationId ?? null,
});
}
@Mutation(() => PublicDomainDTO)
async updatePublicDomain(
@Args() { domain, applicationId }: UpdatePublicDomainInput,
@AuthWorkspace() currentWorkspace: WorkspaceEntity,
): Promise<PublicDomainDTO> {
return this.publicDomainService.updatePublicDomainApplication({
domain,
workspace: currentWorkspace,
applicationId: applicationId ?? null,
applicationId,
});
}
@@ -59,7 +59,7 @@ export class PublicDomainService {
}: {
domain: string;
workspace: WorkspaceEntity;
applicationId: string | null;
applicationId: string;
}): Promise<PublicDomainDTO> {
const formattedDomain = domain.trim().toLowerCase();
@@ -69,12 +69,10 @@ export class PublicDomainService {
this.publicDomainRepository.findOne(workspace.id, {
where: { domain: formattedDomain },
}),
isDefined(applicationId)
? this.applicationRepository.findOneBy({
id: applicationId,
workspaceId: workspace.id,
})
: Promise.resolve(null),
this.applicationRepository.findOneBy({
id: applicationId,
workspaceId: workspace.id,
}),
]);
if (isDefined(workspaceWithCustomDomain)) {
@@ -97,7 +95,7 @@ export class PublicDomainService {
);
}
if (isDefined(applicationId) && !isDefined(application)) {
if (!isDefined(application)) {
throw new PublicDomainException(
'Application not found in this workspace',
PublicDomainExceptionCode.APPLICATION_NOT_FOUND,
@@ -130,48 +128,6 @@ export class PublicDomainService {
return publicDomain;
}
async updatePublicDomainApplication({
domain,
workspace,
applicationId,
}: {
domain: string;
workspace: WorkspaceEntity;
applicationId: string | null;
}): Promise<PublicDomainDTO> {
const formattedDomain = domain.trim().toLowerCase();
const [publicDomain, application] = await Promise.all([
this.publicDomainRepository.findOne(workspace.id, {
where: { domain: formattedDomain },
}),
isDefined(applicationId)
? this.applicationRepository.findOneBy({
id: applicationId,
workspaceId: workspace.id,
})
: Promise.resolve(null),
]);
if (!isDefined(publicDomain)) {
throw new PublicDomainException(
`Public domain ${domain} not found`,
PublicDomainExceptionCode.PUBLIC_DOMAIN_NOT_FOUND,
);
}
if (isDefined(applicationId) && !isDefined(application)) {
throw new PublicDomainException(
'Application not found in this workspace',
PublicDomainExceptionCode.APPLICATION_NOT_FOUND,
);
}
publicDomain.applicationId = applicationId;
return this.publicDomainRepository.save(workspace.id, publicDomain);
}
async checkPublicDomainValidRecords(
publicDomain: PublicDomainEntity,
domainValidRecords?: DomainValidRecords,
@@ -2,6 +2,7 @@ import { Logger } from '@nestjs/common';
import { plainToClass } from 'class-transformer';
import {
IsDateString,
IsDefined,
IsNotEmpty,
IsOptional,
@@ -1241,6 +1242,16 @@ export class ConfigVariables {
@IsOptional()
PUBLIC_DOMAIN_URL: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.SERVER_CONFIG,
description:
'ISO date from which HTTP logic functions are no longer served on the legacy /s/ route. Functions created on or after this date are only reachable on the isolated public domain (*.withtwenty.com). Only enforced when PUBLIC_DOMAIN_URL is set; leave empty to keep serving every function on /s/ (default for self-hosting).',
type: ConfigVariableType.STRING,
})
@IsDateString()
@IsOptional()
LOGIC_FUNCTION_LEGACY_ROUTE_CUTOFF: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.SERVER_CONFIG,
isSensitive: true,