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
@@ -200,24 +200,29 @@ export default defineFrontComponent({
Front components run browser-side in a sandboxed Web Worker, while [logic functions](/developers/extend/apps/logic/logic-functions) run server-side. There is no direct in-process call between the two — instead, a front component reaches a logic function over HTTP.
A logic function declared with `httpRouteTriggerSettings` is exposed under the `/s/` endpoint at `${TWENTY_API_URL}/s<path>`. Your front component calls that route with the `RestApiClient` from `twenty-client-sdk/rest`, which authenticates with the `TWENTY_APP_ACCESS_TOKEN` that Twenty injects into the worker.
A logic function declared with `httpRouteTriggerSettings` is reachable over HTTP at its route path. Twenty injects the base URL your functions are served from into the worker as `TWENTY_FUNCTIONS_URL`, together with the `TWENTY_APP_ACCESS_TOKEN` that authenticates the call. There is no dedicated SDK client for invoking your own functions yet, so call them with a plain `fetch`:
The `RestApiClient` is built for exactly this. It reads `TWENTY_API_URL` and `TWENTY_APP_ACCESS_TOKEN` from the worker environment, attaches the `Authorization: Bearer` header, serializes and parses JSON, and throws a `RestApiClientError` when the token or URL is missing or the response is non-2xx — so you don't reimplement that boilerplate in every component.
> **On Twenty Cloud, HTTP-triggered logic functions are served on a dedicated per-workspace domain** at `https://<your-workspace-subdomain>.twenty.com<path>` — this is exactly what `TWENTY_FUNCTIONS_URL` resolves to. For external callers, copy the exact URL from the function's **HTTP trigger** settings or the application's **Settings** tab.
<Warning>
The legacy `/s/` function route is **deprecated** and will be **deactivated on 2026-07-24**. Use `TWENTY_FUNCTIONS_URL` (above) instead, and migrate any hard-coded `/s/` URLs before that date. The `/s/` route remains available for self-hosting.
</Warning>
A headless front component can run the call on mount via the `Command` component, then unmount automatically:
```tsx src/front-components/sync-prs.tsx
import { defineFrontComponent } from 'twenty-sdk/define';
import { Command } from 'twenty-sdk/command';
import { RestApiClient } from 'twenty-client-sdk/rest';
const SyncPrs = () => {
const execute = async () => {
const client = new RestApiClient();
await client.post('/s/github/fetch-prs', {
owner: 'twentyhq',
repo: 'twenty',
await fetch(`${process.env.TWENTY_FUNCTIONS_URL}/github/fetch-prs`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.TWENTY_APP_ACCESS_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ owner: 'twentyhq', repo: 'twenty' }),
});
};
@@ -233,7 +238,7 @@ export default defineFrontComponent({
});
```
The path passed to the client is the route's public path — the logic function's `httpRouteTriggerSettings.path` prefixed with `/s`. Keep `isAuthRequired: true`; the client supplies the app access token Twenty mints for your component:
The path appended to `TWENTY_FUNCTIONS_URL` is the logic function's `httpRouteTriggerSettings.path`. Keep `isAuthRequired: true`; the `TWENTY_APP_ACCESS_TOKEN` Twenty mints for your component authenticates the request:
```ts src/logic-functions/fetch-prs.logic-function.ts
import { defineLogicFunction } from 'twenty-sdk/define';
@@ -258,12 +263,12 @@ export default defineLogicFunction({
```
<Note>
`TWENTY_API_URL` and `TWENTY_APP_ACCESS_TOKEN` are injected automatically — see [Application variables](#application-variables). Because secret application variables are never exposed to front components, keep API keys and other sensitive logic in the logic function, not in the front component.
`TWENTY_FUNCTIONS_URL` and `TWENTY_APP_ACCESS_TOKEN` are injected automatically — see [Application variables](#application-variables). Because secret application variables are never exposed to front components, keep API keys and other sensitive logic in the logic function, not in the front component.
</Note>
### RestApiClient reference
### Calling the Twenty REST API
Import `RestApiClient` from `twenty-client-sdk/rest`. It belongs to the same client family as `CoreApiClient` and `MetadataApiClient`, but targets your app's HTTP routes instead of the GraphQL API.
To read or write Twenty records from a front component, use `RestApiClient` from `twenty-client-sdk/rest`. It belongs to the same client family as `CoreApiClient` and `MetadataApiClient`, but targets the Twenty REST API (`/rest/...`) instead of the GraphQL API, reading its base URL from `TWENTY_API_URL`.
| Method | Description |
|--------|-------------|
@@ -280,7 +285,7 @@ The base URL and token are resolved from the environment by default. Pass overri
```ts
const client = new RestApiClient({
baseUrl: 'https://api.example.com',
baseUrl: 'https://myworkspace.twenty.com',
token: 'my-token',
});
```
@@ -293,8 +298,8 @@ import { RestApiClient, RestApiClientError } from 'twenty-client-sdk/rest';
const client = new RestApiClient();
try {
const prs = await client.get('/s/github/fetch-prs', {
query: { state: 'open' },
const people = await client.get('/rest/people', {
query: { limit: 10 },
});
} catch (error) {
if (error instanceof RestApiClientError) {
@@ -376,7 +381,8 @@ The following system variables are always available via `process.env`:
| Variable | Description |
|----------|-------------|
| `TWENTY_API_URL` | Base URL of the Twenty API |
| `TWENTY_FUNCTIONS_URL` | Base URL your app's HTTP logic functions are served from (used by `RestApiClient`) |
| `TWENTY_API_URL` | Base URL of the Twenty core API |
| `TWENTY_APP_ACCESS_TOKEN` | Short-lived token scoped to your app's role |
## Host communication API