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
-4
@@ -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;
|
||||
}
|
||||
|
||||
-16
@@ -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;
|
||||
}
|
||||
+11
-7
@@ -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>;
|
||||
}
|
||||
|
||||
+1
-14
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+6
-50
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user