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
@@ -918,6 +918,7 @@ export type ClientConfig = {
isWorkspaceSchemaDDLLocked: Scalars['Boolean']['output'];
maintenance?: Maybe<ClientConfigMaintenanceMode>;
publicFeatureFlags: Array<PublicFeatureFlag>;
publicFunctionDomain?: Maybe<Scalars['String']['output']>;
sentry: Sentry;
signInPrefilled: Scalars['Boolean']['output'];
support: Support;
@@ -1713,7 +1714,6 @@ export enum FeatureFlagKey {
IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED = 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED',
IS_MARKETPLACE_SETTING_TAB_VISIBLE = 'IS_MARKETPLACE_SETTING_TAB_VISIBLE',
IS_MESSAGING_CALENDAR_WEBHOOK_ENABLED = 'IS_MESSAGING_CALENDAR_WEBHOOK_ENABLED',
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT',
IS_SETTINGS_DISCOVERY_HERO_ENABLED = 'IS_SETTINGS_DISCOVERY_HERO_ENABLED',
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED'
@@ -2635,7 +2635,6 @@ export type Mutation = {
updatePageLayoutWidget: PageLayoutWidget;
updatePageLayoutWithTabsAndWidgets: PageLayout;
updatePasswordViaResetToken: InvalidatePassword;
updatePublicDomain: PublicDomain;
updateSkill: Skill;
updateUnsubscribeTopic: UnsubscribeTopic;
updateUserEmail: Scalars['Boolean']['output'];
@@ -2864,7 +2863,7 @@ export type MutationCreatePageLayoutWidgetArgs = {
export type MutationCreatePublicDomainArgs = {
applicationId?: InputMaybe<Scalars['String']['input']>;
applicationId: Scalars['String']['input'];
domain: Scalars['String']['input'];
};
@@ -3576,12 +3575,6 @@ export type MutationUpdatePasswordViaResetTokenArgs = {
};
export type MutationUpdatePublicDomainArgs = {
applicationId?: InputMaybe<Scalars['String']['input']>;
domain: Scalars['String']['input'];
};
export type MutationUpdateSkillArgs = {
input: UpdateSkillInput;
};
@@ -7887,7 +7880,7 @@ export type CheckPublicDomainValidRecordsMutation = { __typename?: 'Mutation', c
export type CreatePublicDomainMutationVariables = Exact<{
domain: Scalars['String']['input'];
applicationId?: InputMaybe<Scalars['String']['input']>;
applicationId: Scalars['String']['input'];
}>;
@@ -7900,14 +7893,6 @@ export type DeletePublicDomainMutationVariables = Exact<{
export type DeletePublicDomainMutation = { __typename?: 'Mutation', deletePublicDomain: boolean };
export type UpdatePublicDomainMutationVariables = Exact<{
domain: Scalars['String']['input'];
applicationId?: InputMaybe<Scalars['String']['input']>;
}>;
export type UpdatePublicDomainMutation = { __typename?: 'Mutation', updatePublicDomain: { __typename?: 'PublicDomain', id: string, domain: string, isValidated: boolean, applicationId?: string | null, createdAt: string } };
export type FindManyPublicDomainsQueryVariables = Exact<{ [key: string]: never; }>;
@@ -8857,9 +8842,8 @@ export const GetApiKeysDocument = {"kind":"Document","definitions":[{"kind":"Ope
export const GetWebhookDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetWebhook"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"webhook"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"WebhookFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WebhookFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Webhook"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"targetUrl"}},{"kind":"Field","name":{"kind":"Name","value":"operations"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"secret"}}]}}]} as unknown as DocumentNode<GetWebhookQuery, GetWebhookQueryVariables>;
export const GetWebhooksDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetWebhooks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"webhooks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"WebhookFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WebhookFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Webhook"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"targetUrl"}},{"kind":"Field","name":{"kind":"Name","value":"operations"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"secret"}}]}}]} as unknown as DocumentNode<GetWebhooksQuery, GetWebhooksQueryVariables>;
export const CheckPublicDomainValidRecordsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CheckPublicDomainValidRecords"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"domain"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"checkPublicDomainValidRecords"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"domain"},"value":{"kind":"Variable","name":{"kind":"Name","value":"domain"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"records"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"value"}},{"kind":"Field","name":{"kind":"Name","value":"validationType"}},{"kind":"Field","name":{"kind":"Name","value":"status"}}]}}]}}]}}]} as unknown as DocumentNode<CheckPublicDomainValidRecordsMutation, CheckPublicDomainValidRecordsMutationVariables>;
export const CreatePublicDomainDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreatePublicDomain"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"domain"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"applicationId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createPublicDomain"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"domain"},"value":{"kind":"Variable","name":{"kind":"Name","value":"domain"}}},{"kind":"Argument","name":{"kind":"Name","value":"applicationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"applicationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"isValidated"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]} as unknown as DocumentNode<CreatePublicDomainMutation, CreatePublicDomainMutationVariables>;
export const CreatePublicDomainDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreatePublicDomain"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"domain"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"applicationId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createPublicDomain"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"domain"},"value":{"kind":"Variable","name":{"kind":"Name","value":"domain"}}},{"kind":"Argument","name":{"kind":"Name","value":"applicationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"applicationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"isValidated"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]} as unknown as DocumentNode<CreatePublicDomainMutation, CreatePublicDomainMutationVariables>;
export const DeletePublicDomainDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeletePublicDomain"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"domain"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deletePublicDomain"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"domain"},"value":{"kind":"Variable","name":{"kind":"Name","value":"domain"}}}]}]}}]} as unknown as DocumentNode<DeletePublicDomainMutation, DeletePublicDomainMutationVariables>;
export const UpdatePublicDomainDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdatePublicDomain"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"domain"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"applicationId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updatePublicDomain"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"domain"},"value":{"kind":"Variable","name":{"kind":"Name","value":"domain"}}},{"kind":"Argument","name":{"kind":"Name","value":"applicationId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"applicationId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"isValidated"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]} as unknown as DocumentNode<UpdatePublicDomainMutation, UpdatePublicDomainMutationVariables>;
export const FindManyPublicDomainsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindManyPublicDomains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findManyPublicDomains"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"isValidated"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}}]} as unknown as DocumentNode<FindManyPublicDomainsQuery, FindManyPublicDomainsQueryVariables>;
export const DeleteEmailingDomainDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteEmailingDomain"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteEmailingDomain"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}]}}]} as unknown as DocumentNode<DeleteEmailingDomainMutation, DeleteEmailingDomainMutationVariables>;
export const VerifyEmailingDomainDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"VerifyEmailingDomain"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"verifyEmailingDomain"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"domain"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"verifiedAt"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<VerifyEmailingDomainMutation, VerifyEmailingDomainMutationVariables>;