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:
+42
-6
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+104
@@ -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);
|
||||
});
|
||||
});
|
||||
+32
@@ -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}`);
|
||||
};
|
||||
+82
-2
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user