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
@@ -1772,7 +1772,6 @@ enum FeatureFlagKey {
IS_UNIQUE_INDEXES_ENABLED
IS_JSON_FILTER_ENABLED
IS_MARKETPLACE_SETTING_TAB_VISIBLE
IS_PUBLIC_DOMAIN_ENABLED
IS_EMAIL_GROUP_ENABLED
IS_JUNCTION_RELATIONS_ENABLED
IS_REST_METADATA_API_NEW_FORMAT_DIRECT
@@ -1947,6 +1946,7 @@ type ClientConfig {
isEmailVerificationRequired: Boolean!
defaultSubdomain: String
frontDomain: String!
publicFunctionDomain: String
analyticsEnabled: Boolean!
support: Support!
isAttachmentPreviewEnabled: Boolean!
@@ -3450,8 +3450,7 @@ type Mutation {
startChannelSync(connectedAccountId: UUID!): ChannelSyncSuccess!
saveImapSmtpCaldavAccount(handle: String!, connectionParameters: EmailAccountConnectionParameters!, id: UUID): ImapSmtpCaldavConnectionSuccess!
updateLabPublicFeatureFlag(input: UpdateLabPublicFeatureFlagInput!): FeatureFlag!
createPublicDomain(domain: String!, applicationId: String): PublicDomain!
updatePublicDomain(domain: String!, applicationId: String): PublicDomain!
createPublicDomain(domain: String!, applicationId: String!): PublicDomain!
deletePublicDomain(domain: String!): Boolean!
checkPublicDomainValidRecords(domain: String!): DomainValidRecords
createOneAppToken(input: CreateOneAppTokenInput!): AppToken!
@@ -1408,7 +1408,7 @@ export interface FeatureFlag {
__typename: 'FeatureFlag'
}
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' | 'IS_PUBLIC_DOMAIN_ENABLED' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' | 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED' | 'IS_SETTINGS_DISCOVERY_HERO_ENABLED' | 'IS_CALL_RECORDING_ENABLED' | 'IS_MESSAGING_CALENDAR_WEBHOOK_ENABLED'
export type FeatureFlagKey = 'IS_UNIQUE_INDEXES_ENABLED' | 'IS_JSON_FILTER_ENABLED' | 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' | 'IS_EMAIL_GROUP_ENABLED' | 'IS_JUNCTION_RELATIONS_ENABLED' | 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' | 'IS_LOGIC_FUNCTION_PREBUILT_MODE_ENABLED' | 'IS_SETTINGS_DISCOVERY_HERO_ENABLED' | 'IS_CALL_RECORDING_ENABLED' | 'IS_MESSAGING_CALENDAR_WEBHOOK_ENABLED'
export interface WorkspaceUrls {
customUrl?: Scalars['String']
@@ -1574,6 +1574,7 @@ export interface ClientConfig {
isEmailVerificationRequired: Scalars['Boolean']
defaultSubdomain?: Scalars['String']
frontDomain: Scalars['String']
publicFunctionDomain?: Scalars['String']
analyticsEnabled: Scalars['Boolean']
support: Support
isAttachmentPreviewEnabled: Scalars['Boolean']
@@ -2968,7 +2969,6 @@ export interface Mutation {
saveImapSmtpCaldavAccount: ImapSmtpCaldavConnectionSuccess
updateLabPublicFeatureFlag: FeatureFlag
createPublicDomain: PublicDomain
updatePublicDomain: PublicDomain
deletePublicDomain: Scalars['Boolean']
checkPublicDomainValidRecords?: DomainValidRecords
createOneAppToken: AppToken
@@ -4633,6 +4633,7 @@ export interface ClientConfigGenqlSelection{
isEmailVerificationRequired?: boolean | number
defaultSubdomain?: boolean | number
frontDomain?: boolean | number
publicFunctionDomain?: boolean | number
analyticsEnabled?: boolean | number
support?: SupportGenqlSelection
isAttachmentPreviewEnabled?: boolean | number
@@ -6153,8 +6154,7 @@ export interface MutationGenqlSelection{
startChannelSync?: (ChannelSyncSuccessGenqlSelection & { __args: {connectedAccountId: Scalars['UUID']} })
saveImapSmtpCaldavAccount?: (ImapSmtpCaldavConnectionSuccessGenqlSelection & { __args: {handle: Scalars['String'], connectionParameters: EmailAccountConnectionParameters, id?: (Scalars['UUID'] | null)} })
updateLabPublicFeatureFlag?: (FeatureFlagGenqlSelection & { __args: {input: UpdateLabPublicFeatureFlagInput} })
createPublicDomain?: (PublicDomainGenqlSelection & { __args: {domain: Scalars['String'], applicationId?: (Scalars['String'] | null)} })
updatePublicDomain?: (PublicDomainGenqlSelection & { __args: {domain: Scalars['String'], applicationId?: (Scalars['String'] | null)} })
createPublicDomain?: (PublicDomainGenqlSelection & { __args: {domain: Scalars['String'], applicationId: Scalars['String']} })
deletePublicDomain?: { __args: {domain: Scalars['String']} }
checkPublicDomainValidRecords?: (DomainValidRecordsGenqlSelection & { __args: {domain: Scalars['String']} })
createOneAppToken?: (AppTokenGenqlSelection & { __args: {input: CreateOneAppTokenInput} })
@@ -9079,7 +9079,6 @@ export const enumFeatureFlagKey = {
IS_UNIQUE_INDEXES_ENABLED: 'IS_UNIQUE_INDEXES_ENABLED' as const,
IS_JSON_FILTER_ENABLED: 'IS_JSON_FILTER_ENABLED' as const,
IS_MARKETPLACE_SETTING_TAB_VISIBLE: 'IS_MARKETPLACE_SETTING_TAB_VISIBLE' as const,
IS_PUBLIC_DOMAIN_ENABLED: 'IS_PUBLIC_DOMAIN_ENABLED' as const,
IS_EMAIL_GROUP_ENABLED: 'IS_EMAIL_GROUP_ENABLED' as const,
IS_JUNCTION_RELATIONS_ENABLED: 'IS_JUNCTION_RELATIONS_ENABLED' as const,
IS_REST_METADATA_API_NEW_FORMAT_DIRECT: 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT' as const,
@@ -3784,6 +3784,9 @@ export default {
"frontDomain": [
1
],
"publicFunctionDomain": [
1
],
"analyticsEnabled": [
6
],
@@ -8950,19 +8953,8 @@ export default {
"String!"
],
"applicationId": [
1
]
}
],
"updatePublicDomain": [
270,
{
"domain": [
1,
"String!"
],
"applicationId": [
1
]
}
],
@@ -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
@@ -22,6 +22,7 @@ type FrontComponentContentProps = {
componentUrl: string;
applicationAccessToken?: string;
apiUrl?: string;
functionsBaseUrl?: string;
sdkClientUrls?: SdkClientUrls;
applicationVariables?: Record<string, string>;
executionContext: FrontComponentExecutionContext;
@@ -34,6 +35,7 @@ export const FrontComponentRenderer = ({
componentUrl,
applicationAccessToken,
apiUrl,
functionsBaseUrl,
sdkClientUrls,
applicationVariables,
executionContext,
@@ -56,6 +58,7 @@ export const FrontComponentRenderer = ({
componentUrl={componentUrl}
applicationAccessToken={applicationAccessToken}
apiUrl={apiUrl}
functionsBaseUrl={functionsBaseUrl}
sdkClientUrls={sdkClientUrls}
applicationVariables={applicationVariables}
frontComponentId={executionContext.frontComponentId}
@@ -71,6 +74,7 @@ export const FrontComponentRenderer = ({
setThread,
applicationAccessToken,
apiUrl,
functionsBaseUrl,
sdkClientUrls,
applicationVariables,
executionContext.frontComponentId,
@@ -36,6 +36,7 @@ type FrontComponentWorkerEffectProps = {
componentUrl: string;
applicationAccessToken?: string;
apiUrl?: string;
functionsBaseUrl?: string;
sdkClientUrls?: SdkClientUrls;
applicationVariables?: Record<string, string>;
frontComponentId: string;
@@ -53,6 +54,7 @@ export const FrontComponentWorkerEffect = ({
componentUrl,
applicationAccessToken,
apiUrl,
functionsBaseUrl,
sdkClientUrls,
applicationVariables,
frontComponentId,
@@ -123,6 +125,7 @@ export const FrontComponentWorkerEffect = ({
componentUrl,
applicationAccessToken,
apiUrl,
functionsBaseUrl,
sdkClientUrls,
applicationVariables,
})
@@ -146,6 +149,7 @@ export const FrontComponentWorkerEffect = ({
componentUrl,
applicationAccessToken,
apiUrl,
functionsBaseUrl,
sdkClientUrls,
applicationVariables,
frontComponentId,
@@ -104,6 +104,12 @@ const render: WorkerExports['render'] = async (
});
}
if (isDefined(renderContext.functionsBaseUrl)) {
setWorkerEnv({
TWENTY_FUNCTIONS_URL: renderContext.functionsBaseUrl,
});
}
if (isDefined(renderContext.applicationAccessToken)) {
setWorkerEnv({
TWENTY_APP_ACCESS_TOKEN: renderContext.applicationAccessToken,
@@ -7,6 +7,7 @@ export type HostToWorkerRenderContext = {
componentUrl: string;
applicationAccessToken?: string;
apiUrl?: string;
functionsBaseUrl?: string;
sdkClientUrls?: SdkClientUrls;
applicationVariables?: Record<string, string>;
};
@@ -304,7 +304,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'
@@ -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>;
@@ -178,6 +178,7 @@ export const useClientConfig = (): UseClientConfigResult => {
setDomainConfiguration({
defaultSubdomain: clientConfig?.defaultSubdomain,
frontDomain: clientConfig?.frontDomain,
publicFunctionDomain: clientConfig?.publicFunctionDomain,
});
setCanManageFeatureFlags(clientConfig?.canManageFeatureFlags);
setLabPublicFeatureFlags(clientConfig?.publicFeatureFlags);
@@ -22,6 +22,7 @@ export type ClientConfig = {
captcha: Captcha;
defaultSubdomain?: string;
frontDomain: string;
publicFunctionDomain?: string | null;
isAttachmentPreviewEnabled: boolean;
isConfigVariablesInDbEnabled: boolean;
isEmailVerificationRequired: boolean;
@@ -2,11 +2,15 @@ import { type ClientConfig } from '@/client-config/types/ClientConfig';
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const domainConfigurationState = createAtomState<
Pick<ClientConfig, 'frontDomain' | 'defaultSubdomain'>
Pick<
ClientConfig,
'frontDomain' | 'defaultSubdomain' | 'publicFunctionDomain'
>
>({
key: 'domainConfiguration',
defaultValue: {
frontDomain: '',
defaultSubdomain: undefined,
publicFunctionDomain: undefined,
},
});
@@ -1,5 +1,9 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
import { FrontComponentRendererProvider } from '@/front-components/components/FrontComponentRendererProvider';
import { FrontComponentRendererWithSdkClient } from '@/front-components/components/FrontComponentRendererWithSdkClient';
import { getFunctionsBaseUrl } from '@/settings/logic-functions/utils/getLogicFunctionHttpUrl';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useFrontComponentExecutionContext } from '@/front-components/hooks/useFrontComponentExecutionContext';
import { useOnFrontComponentUpdated } from '@/front-components/hooks/useOnFrontComponentUpdated';
import { frontComponentApplicationTokenPairComponentState } from '@/front-components/states/frontComponentApplicationTokenPairComponentState';
@@ -29,6 +33,9 @@ export const FrontComponentRenderer = ({
const { colorScheme } = useContext(ThemeContext);
const { enqueueErrorSnackBar } = useSnackBar();
const { publicFunctionDomain } = useAtomStateValue(domainConfigurationState);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const setFrontComponentApplicationTokenPair = useSetAtomComponentState(
frontComponentApplicationTokenPairComponentState,
frontComponentId,
@@ -104,6 +111,12 @@ export const FrontComponentRenderer = ({
const applicationVariables =
data.frontComponent.applicationVariables ?? undefined;
const functionsBaseUrl =
getFunctionsBaseUrl({
publicFunctionDomain,
workspaceSubdomain: currentWorkspace?.subdomain,
}) ?? `${REACT_APP_SERVER_BASE_URL}/s`;
if (usesSdkClient) {
return (
<FrontComponentRendererProvider frontComponentId={frontComponentId}>
@@ -112,6 +125,7 @@ export const FrontComponentRenderer = ({
componentUrl={componentUrl}
applicationAccessToken={accessToken}
applicationId={data.frontComponent.applicationId}
functionsBaseUrl={functionsBaseUrl}
executionContext={executionContext}
frontComponentHostCommunicationApi={
frontComponentHostCommunicationApi
@@ -130,6 +144,7 @@ export const FrontComponentRenderer = ({
componentUrl={componentUrl}
applicationAccessToken={accessToken}
apiUrl={REACT_APP_SERVER_BASE_URL}
functionsBaseUrl={functionsBaseUrl}
executionContext={executionContext}
frontComponentHostCommunicationApi={frontComponentHostCommunicationApi}
applicationVariables={applicationVariables}
@@ -14,6 +14,7 @@ type FrontComponentRendererWithSdkClientProps = {
componentUrl: string;
applicationAccessToken: string;
applicationId: string;
functionsBaseUrl?: string;
executionContext: FrontComponentExecutionContext;
frontComponentHostCommunicationApi: FrontComponentHostCommunicationApi;
applicationVariables?: Record<string, string>;
@@ -25,6 +26,7 @@ export const FrontComponentRendererWithSdkClient = ({
componentUrl,
applicationAccessToken,
applicationId,
functionsBaseUrl,
executionContext,
frontComponentHostCommunicationApi,
applicationVariables,
@@ -47,6 +49,7 @@ export const FrontComponentRendererWithSdkClient = ({
componentUrl={componentUrl}
applicationAccessToken={applicationAccessToken}
apiUrl={REACT_APP_SERVER_BASE_URL}
functionsBaseUrl={functionsBaseUrl}
sdkClientUrls={sdkClientState.blobUrls}
executionContext={executionContext}
frontComponentHostCommunicationApi={
@@ -6,10 +6,9 @@ import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { SettingsPath } from 'twenty-shared/types';
import { SettingsPageLayout } from '@/settings/components/layout/SettingsPageLayout';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { Select } from '@/ui/input/components/Select';
import { TextInput } from '@/ui/input/components/TextInput';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { Button, ButtonGroup, type SelectOption } from 'twenty-ui/input';
import { Button, ButtonGroup } from 'twenty-ui/input';
import { styled } from '@linaria/react';
import { SettingsDomainRecords } from '@/settings/domains/components/SettingsDomainRecords';
import { useCheckPublicDomainValidRecords } from '@/settings/domains/hooks/useCheckPublicDomainValidRecords';
@@ -17,14 +16,14 @@ import { useMutation, useQuery } from '@apollo/client/react';
import {
CreatePublicDomainDocument,
DeletePublicDomainDocument,
FindManyApplicationsDocument,
FindManyPublicDomainsDocument,
UpdatePublicDomainDocument,
} from '~/generated-metadata/graphql';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { CheckPublicDomainValidRecordsEffect } from '@/settings/domains/components/CheckPublicDomainValidRecordsEffect';
import { selectedApplicationIdForPublicDomainState } from '@/settings/domains/states/selectedApplicationIdForPublicDomainState';
import { selectedPublicDomainState } from '@/settings/domains/states/selectedPublicDomainState';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useState } from 'react';
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
import { getDomainValidationSchema } from '@/settings/domains/utils/getDomainValidationSchema';
@@ -57,6 +56,9 @@ export const SettingPublicDomain = () => {
const [selectedPublicDomain, setSelectedPublicDomain] = useAtomState(
selectedPublicDomainState,
);
const selectedApplicationIdForPublicDomain = useAtomStateValue(
selectedApplicationIdForPublicDomainState,
);
const { t } = useLingui();
const navigate = useNavigateSettings();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
@@ -65,22 +67,10 @@ export const SettingPublicDomain = () => {
CreatePublicDomainDocument,
);
const [updatePublicDomain] = useMutation(UpdatePublicDomainDocument);
const [newPublicDomain, setNewPublicDomain] = useState<string | undefined>(
selectedPublicDomain?.domain ?? '',
);
// Holds the chosen application before the public domain is created.
// Once selectedPublicDomain exists, the dropdown reads from it directly.
const [draftApplicationId, setDraftApplicationId] = useState<string | null>(
null,
);
const selectedApplicationId = isDefined(selectedPublicDomain)
? (selectedPublicDomain.applicationId ?? null)
: draftApplicationId;
const [newPublicDomainError, setNewPublicDomainError] = useState<
string | undefined
>(undefined);
@@ -89,20 +79,6 @@ export const SettingPublicDomain = () => {
FindManyPublicDomainsDocument,
);
const { data: applicationsData } = useQuery(FindManyApplicationsDocument);
const applicationPinnedOption: SelectOption<string | null> = {
value: null,
label: t`Workspace (all apps)`,
};
const applicationOptions: SelectOption<string | null>[] = (
applicationsData?.findManyApplications ?? []
).map((application) => ({
value: application.id,
label: application.name,
}));
const [deletePublicDomain] = useMutation(DeletePublicDomainDocument);
const { isLoading, publicDomainRecords, checkPublicDomainRecords } =
@@ -132,7 +108,10 @@ export const SettingPublicDomain = () => {
const validationSchema = getDomainValidationSchema();
const onCreate = async () => {
if (!isDefined(newPublicDomain)) {
if (
!isDefined(newPublicDomain) ||
!isDefined(selectedApplicationIdForPublicDomain)
) {
return;
}
@@ -148,7 +127,7 @@ export const SettingPublicDomain = () => {
await createPublicDomain({
variables: {
domain: newPublicDomain,
applicationId: draftApplicationId,
applicationId: selectedApplicationIdForPublicDomain,
},
onCompleted: (data) => {
setSelectedPublicDomain(data.createPublicDomain);
@@ -165,35 +144,6 @@ export const SettingPublicDomain = () => {
});
};
const onApplicationChange = async (nextApplicationId: string | null) => {
if (!isDefined(selectedPublicDomain)) {
setDraftApplicationId(nextApplicationId);
return;
}
if (nextApplicationId === (selectedPublicDomain.applicationId ?? null)) {
return;
}
await updatePublicDomain({
variables: {
domain: selectedPublicDomain.domain,
applicationId: nextApplicationId,
},
onCompleted: (data) => {
setSelectedPublicDomain(data.updatePublicDomain);
enqueueSuccessSnackBar({
message: t`Public domain updated successfully`,
});
refetchPublicDomains();
},
onError: (error) =>
enqueueErrorSnackBar({
apolloError: error,
}),
});
};
return (
<SettingsPageLayout
title={t`Public domain`}
@@ -271,21 +221,6 @@ export const SettingPublicDomain = () => {
</StyledRecordsWrapper>
)}
</Section>
<Section>
<H2Title
title={t`Bound App`}
description={t`Restrict this domain to the HTTP routes of a specific app. Leave empty to expose all workspace HTTP routes.`}
/>
<Select
dropdownId="public-domain-application"
label={t`Application`}
fullWidth
value={selectedApplicationId}
pinnedOption={applicationPinnedOption}
options={applicationOptions}
onChange={onApplicationChange}
/>
</Section>
</SettingsPageContainer>
</SettingsPageLayout>
);
@@ -1,6 +1,7 @@
import { SettingsCard } from '@/settings/components/SettingsCard';
import { SettingsListCard } from '@/settings/components/SettingsListCard';
import { SettingPublicDomainRowDropdownMenu } from '@/settings/domains/components/SettingPublicDomainRowDropdownMenu';
import { selectedApplicationIdForPublicDomainState } from '@/settings/domains/states/selectedApplicationIdForPublicDomainState';
import { selectedPublicDomainState } from '@/settings/domains/states/selectedPublicDomainState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { useLingui } from '@lingui/react/macro';
@@ -14,30 +15,42 @@ import {
} from '~/generated-metadata/graphql';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
export const SettingsPublicDomainsListCard = () => {
export const SettingsPublicDomainsListCard = ({
applicationId,
}: {
applicationId: string;
}) => {
const navigate = useNavigateSettings();
const { t } = useLingui();
const setSelectedPublicDomain = useSetAtomState(selectedPublicDomainState);
const setSelectedApplicationIdForPublicDomain = useSetAtomState(
selectedApplicationIdForPublicDomainState,
);
const { data, loading } = useQuery(FindManyPublicDomainsDocument);
const publicDomains = data?.findManyPublicDomains;
const publicDomains = data?.findManyPublicDomains?.filter(
(publicDomain) => publicDomain.applicationId === applicationId,
);
if (loading || !publicDomains) {
return null;
}
const navigateToCreate = () => {
setSelectedPublicDomain(undefined);
setSelectedApplicationIdForPublicDomain(applicationId);
navigate(SettingsPath.PublicDomain);
};
if (publicDomains.length === 0) {
return (
<SettingsCard
title={t`Add Public Domain`}
Icon={<IconMailCog />}
onClick={() => {
setSelectedPublicDomain(undefined);
navigate(SettingsPath.PublicDomain);
}}
onClick={navigateToCreate}
/>
);
}
@@ -50,6 +63,7 @@ export const SettingsPublicDomainsListCard = () => {
RowIcon={IconAt}
onRowClick={(publicDomain: PublicDomain) => {
setSelectedPublicDomain(publicDomain);
setSelectedApplicationIdForPublicDomain(applicationId);
navigate(SettingsPath.PublicDomain);
}}
RowRightComponent={({ item: publicDomain }) => (
@@ -62,10 +76,7 @@ export const SettingsPublicDomainsListCard = () => {
)}
hasFooter
footerButtonLabel={t`Add Public Domain`}
onFooterButtonClick={() => {
setSelectedPublicDomain(undefined);
navigate(SettingsPath.PublicDomain);
}}
onFooterButtonClick={navigateToCreate}
/>
);
};
@@ -1,7 +1,7 @@
import { gql } from '@apollo/client';
export const CREATE_PUBLIC_DOMAIN = gql`
mutation CreatePublicDomain($domain: String!, $applicationId: String) {
mutation CreatePublicDomain($domain: String!, $applicationId: String!) {
createPublicDomain(domain: $domain, applicationId: $applicationId) {
id
domain
@@ -1,13 +0,0 @@
import { gql } from '@apollo/client';
export const UPDATE_PUBLIC_DOMAIN = gql`
mutation UpdatePublicDomain($domain: String!, $applicationId: String) {
updatePublicDomain(domain: $domain, applicationId: $applicationId) {
id
domain
isValidated
applicationId
createdAt
}
}
`;
@@ -0,0 +1,8 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const selectedApplicationIdForPublicDomainState = createAtomState<
string | undefined
>({
key: 'selectedApplicationIdForPublicDomainState',
defaultValue: undefined,
});
@@ -21,7 +21,7 @@ import {
} from 'twenty-ui/icon';
import { Toggle } from 'twenty-ui/input';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { useGetLogicFunctionHttpUrl } from '@/settings/logic-functions/hooks/useGetLogicFunctionHttpUrl';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
const HTTP_METHOD_OPTIONS: Array<{
@@ -73,6 +73,7 @@ export const SettingsLogicFunctionHttpTriggerSection = ({
const { t } = useLingui();
const { theme } = useContext(ThemeContext);
const { copyToClipboard } = useCopyToClipboard();
const { getLogicFunctionHttpUrl } = useGetLogicFunctionHttpUrl();
const updateField = <TKey extends keyof HttpRouteTriggerSettings>(
key: TKey,
@@ -84,9 +85,7 @@ export const SettingsLogicFunctionHttpTriggerSection = ({
onChange({ ...value, [key]: fieldValue });
};
const fullUrl = isDefined(value)
? `${REACT_APP_SERVER_BASE_URL}/s${value.path}`
: '';
const fullUrl = isDefined(value) ? getLogicFunctionHttpUrl(value.path) : '';
return (
<SettingsLogicFunctionTriggerSection
@@ -0,0 +1,31 @@
import { useCallback } from 'react';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
import { getLogicFunctionHttpUrl } from '@/settings/logic-functions/utils/getLogicFunctionHttpUrl';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
export const useGetLogicFunctionHttpUrl = () => {
const { publicFunctionDomain } = useAtomStateValue(domainConfigurationState);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const workspaceSubdomain = currentWorkspace?.subdomain;
const getHttpUrl = useCallback(
(path: string) =>
getLogicFunctionHttpUrl({
path,
serverBaseUrl: REACT_APP_SERVER_BASE_URL,
publicFunctionDomain,
workspaceSubdomain,
}),
[publicFunctionDomain, workspaceSubdomain],
);
return {
getLogicFunctionHttpUrl: getHttpUrl,
publicFunctionDomain,
workspaceSubdomain,
};
};
@@ -0,0 +1,79 @@
import {
getFunctionsBaseUrl,
getLogicFunctionHttpUrl,
} from '@/settings/logic-functions/utils/getLogicFunctionHttpUrl';
describe('getFunctionsBaseUrl', () => {
it('builds the isolated base from subdomain + public domain', () => {
expect(
getFunctionsBaseUrl({
publicFunctionDomain: 'withtwenty.com',
workspaceSubdomain: 'acme',
}),
).toBe('https://acme.withtwenty.com');
});
it('returns undefined when the public domain is missing', () => {
expect(
getFunctionsBaseUrl({
publicFunctionDomain: null,
workspaceSubdomain: 'acme',
}),
).toBeUndefined();
});
it('returns undefined when the subdomain is missing', () => {
expect(
getFunctionsBaseUrl({
publicFunctionDomain: 'withtwenty.com',
workspaceSubdomain: undefined,
}),
).toBeUndefined();
});
});
describe('getLogicFunctionHttpUrl', () => {
it('builds the isolated public-domain URL when configured', () => {
expect(
getLogicFunctionHttpUrl({
path: '/webhook/stripe',
serverBaseUrl: 'https://api.twenty.com',
publicFunctionDomain: 'withtwenty.com',
workspaceSubdomain: 'acme',
}),
).toBe('https://acme.withtwenty.com/webhook/stripe');
});
it('normalizes a path that does not start with a slash', () => {
expect(
getLogicFunctionHttpUrl({
path: 'webhook',
serverBaseUrl: 'https://api.twenty.com',
publicFunctionDomain: 'withtwenty.com',
workspaceSubdomain: 'acme',
}),
).toBe('https://acme.withtwenty.com/webhook');
});
it('falls back to the legacy /s/ route when no public domain is configured', () => {
expect(
getLogicFunctionHttpUrl({
path: '/webhook/stripe',
serverBaseUrl: 'https://api.twenty.com',
publicFunctionDomain: null,
workspaceSubdomain: 'acme',
}),
).toBe('https://api.twenty.com/s/webhook/stripe');
});
it('falls back to the legacy /s/ route when the workspace has no subdomain', () => {
expect(
getLogicFunctionHttpUrl({
path: '/webhook',
serverBaseUrl: 'https://api.twenty.com',
publicFunctionDomain: 'withtwenty.com',
workspaceSubdomain: undefined,
}),
).toBe('https://api.twenty.com/s/webhook');
});
});
@@ -0,0 +1,43 @@
import { isNonEmptyString } from '@sniptt/guards';
export const getFunctionsBaseUrl = ({
publicFunctionDomain,
workspaceSubdomain,
}: {
publicFunctionDomain?: string | null;
workspaceSubdomain?: string | null;
}): string | undefined => {
if (
isNonEmptyString(publicFunctionDomain) &&
isNonEmptyString(workspaceSubdomain)
) {
return `https://${workspaceSubdomain}.${publicFunctionDomain}`;
}
return undefined;
};
export const getLogicFunctionHttpUrl = ({
path,
serverBaseUrl,
publicFunctionDomain,
workspaceSubdomain,
}: {
path: string;
serverBaseUrl: string;
publicFunctionDomain?: string | null;
workspaceSubdomain?: string | null;
}): string => {
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
const functionsBaseUrl = getFunctionsBaseUrl({
publicFunctionDomain,
workspaceSubdomain,
});
if (isNonEmptyString(functionsBaseUrl)) {
return `${functionsBaseUrl}${normalizedPath}`;
}
return `${serverBaseUrl}/s${normalizedPath}`;
};
@@ -49,6 +49,7 @@ import { SettingsApplicationDetailAboutTab } from '~/pages/settings/applications
import { SettingsApplicationDetailContentTab } from '~/pages/settings/applications/tabs/SettingsApplicationDetailContentTab';
import { SettingsApplicationDetailSettingsTab } from '~/pages/settings/applications/tabs/SettingsApplicationDetailSettingsTab';
import { SettingsApplicationPermissionsTab } from '~/pages/settings/applications/tabs/SettingsApplicationPermissionsTab';
import { applicationHasHttpTriggeredFunctions } from '~/pages/settings/applications/utils/applicationHasHttpTriggeredFunctions';
import { isNewerSemver } from '~/pages/settings/applications/utils/isNewerSemver';
const APPLICATION_DETAIL_ID = 'application-detail-id';
@@ -228,7 +229,11 @@ export const SettingsApplicationDetails = () => {
(() => {
const hasVariables = (application?.applicationVariables ?? []).length > 0;
const hasConnectionProviders = connectionProviders.length > 0;
const hasNothingToConfigure = !hasVariables && !hasConnectionProviders;
const hasHttpTriggeredFunctions =
applicationHasHttpTriggeredFunctions(application);
const canShowFunctionDomain = hasHttpTriggeredFunctions;
const hasNothingToConfigure =
!hasVariables && !hasConnectionProviders && !canShowFunctionDomain;
return {
id: 'settings',
@@ -2,13 +2,19 @@ import { type Application } from '~/generated-metadata/graphql';
import { useUpdateOneApplicationVariable } from '~/pages/settings/applications/hooks/useUpdateOneApplicationVariable';
import { SettingsApplicationConnectionsSection } from '~/pages/settings/applications/tabs/SettingsApplicationConnectionsSection';
import { SettingsApplicationDetailEnvironmentVariablesTable } from '~/pages/settings/applications/tabs/SettingsApplicationDetailEnvironmentVariablesTable';
import { SettingsApplicationFunctionDomainSection } from '~/pages/settings/applications/tabs/SettingsApplicationFunctionDomainSection';
import { applicationHasHttpTriggeredFunctions } from '~/pages/settings/applications/utils/applicationHasHttpTriggeredFunctions';
export const SettingsApplicationDetailSettingsTab = ({
application,
}: {
application?: Pick<
Application,
'applicationVariables' | 'id' | 'universalIdentifier' | 'canBeUninstalled'
| 'applicationVariables'
| 'id'
| 'universalIdentifier'
| 'canBeUninstalled'
| 'logicFunctions'
>;
}) => {
const { updateOneApplicationVariable } = useUpdateOneApplicationVariable();
@@ -17,8 +23,16 @@ export const SettingsApplicationDetailSettingsTab = ({
(a, b) => a.key.localeCompare(b.key),
);
const hasHttpTriggeredFunctions =
applicationHasHttpTriggeredFunctions(application);
return (
<>
{hasHttpTriggeredFunctions && application?.id && (
<SettingsApplicationFunctionDomainSection
applicationId={application.id}
/>
)}
{application?.id && (
<SettingsApplicationConnectionsSection applicationId={application.id} />
)}
@@ -0,0 +1,63 @@
import { useLingui } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { SettingsPublicDomainsListCard } from '@/settings/domains/components/SettingsPublicDomainsListCard';
import { useGetLogicFunctionHttpUrl } from '@/settings/logic-functions/hooks/useGetLogicFunctionHttpUrl';
import { getFunctionsBaseUrl } from '@/settings/logic-functions/utils/getLogicFunctionHttpUrl';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { Info } from 'twenty-ui/feedback';
import { IconCopy } from 'twenty-ui/icon';
import { Section } from 'twenty-ui/layout';
import { H2Title } from 'twenty-ui/typography';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
export const SettingsApplicationFunctionDomainSection = ({
applicationId,
}: {
applicationId: string;
}) => {
const { t } = useLingui();
const { copyToClipboard } = useCopyToClipboard();
const { publicFunctionDomain, workspaceSubdomain } =
useGetLogicFunctionHttpUrl();
const baseUrl = getFunctionsBaseUrl({
publicFunctionDomain,
workspaceSubdomain,
});
return (
<>
{isNonEmptyString(baseUrl) && (
<Section>
<H2Title
title={t`Public URL`}
description={t`Your HTTP-triggered logic functions are served from this dedicated domain, isolated from your main workspace. Because it shares nothing with your app, functions can return any header — including custom headers, Permissions-Policy, Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy.`}
/>
<SettingsTextInput
instanceId="application-public-function-domain"
label={t`Base URL`}
value={baseUrl}
onChange={() => {}}
readOnly
fullWidth
RightIcon={IconCopy}
onRightIconClick={() =>
copyToClipboard(baseUrl, t`URL copied to clipboard`)
}
/>
<Info
text={t`Each HTTP route is reachable at ${baseUrl}/your-route. The legacy /s/ endpoint stays available for self-hosting and existing routes.`}
/>
</Section>
)}
<Section>
<H2Title
title={t`Public Domains`}
description={t`Bind a dedicated domain to serve this app's HTTP routes, isolated from your main workspace.`}
/>
<SettingsPublicDomainsListCard applicationId={applicationId} />
</Section>
</>
);
};
@@ -4,7 +4,6 @@ import {
StyledActionTableCell,
StyledNameTableCell,
} from '@/settings/data-model/object-details/components/SettingsObjectItemTableRowStyledComponents';
import { SettingsPublicDomainsListCard } from '@/settings/domains/components/SettingsPublicDomainsListCard';
import { getDocumentationUrl } from '@/support/utils/getDocumentationUrl';
import { Table } from '@/ui/layout/table/components/Table';
import { TableCell } from '@/ui/layout/table/components/TableCell';
@@ -71,10 +70,6 @@ export const SettingsApplicationsDeveloperTab = () => {
FeatureFlagKey.IS_MARKETPLACE_SETTING_TAB_VISIBLE,
);
const isPublicDomainEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_PUBLIC_DOMAIN_ENABLED,
);
const [marketplaceAppSearchTerm, setMarketplaceAppSearchTerm] = useState('');
const [myAppsSearchTerm, setMyAppsSearchTerm] = useState('');
@@ -197,16 +192,6 @@ export const SettingsApplicationsDeveloperTab = () => {
</Section>
)}
{isPublicDomainEnabled && (
<Section>
<H2Title
title={t`Public Domains`}
description={t`Provision a complete and secure hosting environment on these domains. Bind a domain to a specific app to expose only that app's HTTP routes.`}
/>
<SettingsPublicDomainsListCard />
</Section>
)}
{!isMarketplaceSettingTabVisible && (
<Section>
<H2Title
@@ -0,0 +1,10 @@
import { isDefined } from 'twenty-shared/utils';
import { type Application } from '~/generated-metadata/graphql';
export const applicationHasHttpTriggeredFunctions = (
application?: Pick<Application, 'logicFunctions'>,
): boolean =>
(application?.logicFunctions ?? []).some((logicFunction) =>
isDefined(logicFunction.httpRouteTriggerSettings),
);
@@ -0,0 +1,25 @@
import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.16.0', 1782281874768)
export class AddPrimaryPublicDomainToApplicationFastInstanceCommand implements FastInstanceCommand {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "core"."application" ADD "primaryPublicDomainId" uuid',
);
await queryRunner.query(
'ALTER TABLE "core"."application" ADD CONSTRAINT "FK_38ac5dccee353ca07862a5a94bf" FOREIGN KEY ("primaryPublicDomainId") REFERENCES "core"."publicDomain"("id") ON DELETE SET NULL ON UPDATE NO ACTION',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "core"."application" DROP CONSTRAINT "FK_38ac5dccee353ca07862a5a94bf"',
);
await queryRunner.query(
'ALTER TABLE "core"."application" DROP COLUMN "primaryPublicDomainId"',
);
}
}
@@ -0,0 +1,27 @@
import { DataSource, QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { SlowInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface';
@RegisteredInstanceCommand('2.16.0', 1782281874769, { type: 'slow' })
export class MakePublicDomainApplicationIdNotNullSlowInstanceCommand
implements SlowInstanceCommand
{
async runDataMigration(dataSource: DataSource): Promise<void> {
await dataSource.query(
`DELETE FROM "core"."publicDomain" WHERE "applicationId" IS NULL`,
);
}
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."publicDomain" ALTER COLUMN "applicationId" SET NOT NULL`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."publicDomain" ALTER COLUMN "applicationId" DROP NOT NULL`,
);
}
}
@@ -77,6 +77,8 @@ import { EncryptNonSecretApplicationVariableSlowInstanceCommand } from 'src/data
import { MigrateAiModelPreferencesSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-instance-command-slow-1799000010000-migrate-ai-model-preferences';
import { AddFolderImportToMessageFolderPendingSyncActionFastInstanceCommand } from './2-15/2-15-instance-command-fast-1781714499016-add-folder-import-to-message-folder-pending-sync-action';
import { AddViewKanbanColumnWidthFastInstanceCommand } from './2-15/2-15-instance-command-fast-1781900000000-add-view-kanban-column-width';
import { AddPrimaryPublicDomainToApplicationFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-16/2-16-instance-command-fast-1782281874768-add-primary-public-domain-to-application';
import { MakePublicDomainApplicationIdNotNullSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-16/2-16-instance-command-slow-1782281874769-make-public-domain-application-id-not-null';
import { AddServerTriggerSettingsToLogicFunctionFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-16/2-16-instance-command-fast-1782211913427-add-server-trigger-settings-to-logic-function';
export const INSTANCE_COMMANDS = [
@@ -158,4 +160,6 @@ export const INSTANCE_COMMANDS = [
AddChannelWebhookSubscriptionFieldsFastInstanceCommand,
AddServerTriggerSettingsToLogicFunctionFastInstanceCommand,
AddUniversalIdentifierAndApplicationIdToSearchFieldMetadataFastInstanceCommand,
AddPrimaryPublicDomainToApplicationFastInstanceCommand,
MakePublicDomainApplicationIdNotNullSlowInstanceCommand,
];
@@ -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,
@@ -41,6 +41,7 @@ const MOCK_FLAT_APPLICATION: FlatApplication = {
settingsCustomTabFrontComponentId: null,
canBeUninstalled: false,
applicationRegistrationId: null,
primaryPublicDomainId: null,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
@@ -36,6 +36,7 @@ const MOCK_FLAT_APPLICATION: FlatApplication = {
settingsCustomTabFrontComponentId: null,
canBeUninstalled: false,
applicationRegistrationId: null,
primaryPublicDomainId: null,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { FlatFrontComponentModule } from 'src/engine/metadata-modules/flat-front-component/flat-front-component.module';
import { FrontComponentController } from 'src/engine/metadata-modules/front-component/controllers/front-component.controller';
@@ -25,6 +26,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
FlatFrontComponentModule,
SubscriptionsModule,
WorkspaceCacheModule,
WorkspaceDomainsModule,
],
controllers: [FrontComponentController],
providers: [
@@ -41,9 +41,8 @@ describe('RouteTriggerController', () => {
const response = createResponseMock();
handle.mockResolvedValue({
statusCode: 200,
headers: {},
body: { ok: true },
response: { statusCode: 200, headers: {}, body: { ok: true } },
isIsolatedOrigin: false,
});
await controller.post(request, response);
@@ -60,9 +59,12 @@ describe('RouteTriggerController', () => {
const response = createResponseMock();
handle.mockResolvedValue({
statusCode: 201,
headers: { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' },
body: '<h1>Hi</h1>',
response: {
statusCode: 201,
headers: { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' },
body: '<h1>Hi</h1>',
},
isIsolatedOrigin: false,
});
await controller.get({} as never, response);
@@ -79,18 +81,21 @@ describe('RouteTriggerController', () => {
expect(response.send).toHaveBeenCalledWith('<h1>Hi</h1>');
});
it('drops headers that are not in the allow-list', async () => {
it('drops headers that are not in the allow-list on the same-site /s/ route', async () => {
const response = createResponseMock();
handle.mockResolvedValue({
statusCode: 200,
headers: {
'Content-Type': 'text/html',
'Set-Cookie': 'session=abc',
'Access-Control-Allow-Origin': '*',
'X-Custom': 'foo',
response: {
statusCode: 200,
headers: {
'Content-Type': 'text/html',
'Set-Cookie': 'session=abc',
'Access-Control-Allow-Origin': '*',
'X-Custom': 'foo',
},
body: '<h1>Hi</h1>',
},
body: '<h1>Hi</h1>',
isIsolatedOrigin: false,
});
await controller.get({} as never, response);
@@ -110,10 +115,53 @@ describe('RouteTriggerController', () => {
expect(response.setHeader).not.toHaveBeenCalledWith('X-Custom', 'foo');
});
it('forwards every header when the request is served from an isolated origin', async () => {
const response = createResponseMock();
handle.mockResolvedValue({
response: {
statusCode: 200,
headers: {
'Content-Type': 'text/html',
'Permissions-Policy': 'camera=(self)',
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
'Set-Cookie': 'session=abc',
'X-Custom': 'foo',
},
body: '<h1>Hi</h1>',
},
isIsolatedOrigin: true,
});
await controller.get({} as never, response);
expect(response.setHeader).toHaveBeenCalledWith(
'Permissions-Policy',
'camera=(self)',
);
expect(response.setHeader).toHaveBeenCalledWith(
'Cross-Origin-Opener-Policy',
'same-origin',
);
expect(response.setHeader).toHaveBeenCalledWith(
'Cross-Origin-Embedder-Policy',
'require-corp',
);
expect(response.setHeader).toHaveBeenCalledWith(
'Set-Cookie',
'session=abc',
);
expect(response.setHeader).toHaveBeenCalledWith('X-Custom', 'foo');
});
it('sends an empty response when the body is nil', async () => {
const response = createResponseMock();
handle.mockResolvedValue({ statusCode: 200, headers: {}, body: null });
handle.mockResolvedValue({
response: { statusCode: 200, headers: {}, body: null },
isIsolatedOrigin: false,
});
await controller.get({} as never, response);
@@ -125,7 +173,10 @@ describe('RouteTriggerController', () => {
it('defaults a string body content-type to text/plain when none is set', async () => {
const response = createResponseMock();
handle.mockResolvedValue({ statusCode: 200, headers: {}, body: 'plain' });
handle.mockResolvedValue({
response: { statusCode: 200, headers: {}, body: 'plain' },
isIsolatedOrigin: false,
});
await controller.get({} as never, response);
@@ -140,9 +191,8 @@ describe('RouteTriggerController', () => {
const response = createResponseMock();
handle.mockResolvedValue({
statusCode: 200,
headers: {},
body: { ok: true },
response: { statusCode: 200, headers: {}, body: { ok: true } },
isIsolatedOrigin: false,
});
await controller.get({} as never, response);
@@ -154,9 +204,12 @@ describe('RouteTriggerController', () => {
const response = createResponseMock();
handle.mockResolvedValue({
statusCode: 200,
headers: { 'Content-Type': 'application/ld+json' },
body: { ok: true },
response: {
statusCode: 200,
headers: { 'Content-Type': 'application/ld+json' },
body: { ok: true },
},
isIsolatedOrigin: false,
});
await controller.get({} as never, response);
@@ -26,58 +26,41 @@ import { sendRouteTriggerResponse } from 'src/engine/core-modules/logic-function
export class RouteTriggerController {
constructor(private readonly routeTriggerService: RouteTriggerService) {}
private async handleRequest(
request: Request,
response: Response,
httpMethod: HTTPMethod,
) {
const { response: triggerResponse, isIsolatedOrigin } =
await this.routeTriggerService.handle({ request, httpMethod });
sendRouteTriggerResponse(response, triggerResponse, {
allowAllHeaders: isIsolatedOrigin,
});
}
@Get('*path')
async get(@Req() request: Request, @Res() response: Response) {
sendRouteTriggerResponse(
response,
await this.routeTriggerService.handle({
request,
httpMethod: HTTPMethod.GET,
}),
);
await this.handleRequest(request, response, HTTPMethod.GET);
}
@Post('*path')
async post(@Req() request: Request, @Res() response: Response) {
sendRouteTriggerResponse(
response,
await this.routeTriggerService.handle({
request,
httpMethod: HTTPMethod.POST,
}),
);
await this.handleRequest(request, response, HTTPMethod.POST);
}
@Put('*path')
async put(@Req() request: Request, @Res() response: Response) {
sendRouteTriggerResponse(
response,
await this.routeTriggerService.handle({
request,
httpMethod: HTTPMethod.PUT,
}),
);
await this.handleRequest(request, response, HTTPMethod.PUT);
}
@Patch('*path')
async patch(@Req() request: Request, @Res() response: Response) {
sendRouteTriggerResponse(
response,
await this.routeTriggerService.handle({
request,
httpMethod: HTTPMethod.PATCH,
}),
);
await this.handleRequest(request, response, HTTPMethod.PATCH);
}
@Delete('*path')
async delete(@Req() request: Request, @Res() response: Response) {
sendRouteTriggerResponse(
response,
await this.routeTriggerService.handle({
request,
httpMethod: HTTPMethod.DELETE,
}),
);
await this.handleRequest(request, response, HTTPMethod.DELETE);
}
}
@@ -235,7 +235,6 @@ describe('WorkspaceEntityManager', () => {
IS_UNIQUE_INDEXES_ENABLED: false,
IS_JSON_FILTER_ENABLED: false,
IS_MARKETPLACE_SETTING_TAB_VISIBLE: false,
IS_PUBLIC_DOMAIN_ENABLED: false,
IS_EMAIL_GROUP_ENABLED: false,
IS_JUNCTION_RELATIONS_ENABLED: false,
IS_REST_METADATA_API_NEW_FORMAT_DIRECT: false,
@@ -262,7 +261,6 @@ describe('WorkspaceEntityManager', () => {
featureFlagMap: {
IS_UNIQUE_INDEXES_ENABLED: false,
IS_JSON_FILTER_ENABLED: false,
IS_PUBLIC_DOMAIN_ENABLED: false,
},
permissionsPerRoleId: {},
eventEmitterService: mockInternalContext.eventEmitterService,
@@ -25,11 +25,6 @@ export const seedFeatureFlags = async ({
workspaceId: workspaceId,
value: false,
},
{
key: FeatureFlagKey.IS_PUBLIC_DOMAIN_ENABLED,
workspaceId: workspaceId,
value: true,
},
{
key: FeatureFlagKey.IS_EMAIL_GROUP_ENABLED,
workspaceId: workspaceId,
@@ -0,0 +1 @@
export const DEFAULT_FUNCTIONS_URL_NAME = 'TWENTY_FUNCTIONS_URL';
@@ -21,6 +21,7 @@ export { ASSETS_DIR } from './constants/AssetDirectory';
export { DEFAULT_API_KEY_NAME } from './constants/DefaultApiKeyName';
export { DEFAULT_API_URL_NAME } from './constants/DefaultApiUrlName';
export { DEFAULT_APP_ACCESS_TOKEN_NAME } from './constants/DefaultAppAccessTokenName';
export { DEFAULT_FUNCTIONS_URL_NAME } from './constants/DefaultFunctionsUrlName';
export { GENERATED_DIR } from './constants/GeneratedDirectory';
export { NODE_ESM_CJS_BANNER } from './constants/NodeEsmCjsBanner';
export { OUTPUT_DIR } from './constants/OutputDirectory';
@@ -2,7 +2,6 @@ export enum FeatureFlagKey {
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_MARKETPLACE_SETTING_TAB_VISIBLE = 'IS_MARKETPLACE_SETTING_TAB_VISIBLE',
IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED',
IS_EMAIL_GROUP_ENABLED = 'IS_EMAIL_GROUP_ENABLED',
IS_JUNCTION_RELATIONS_ENABLED = 'IS_JUNCTION_RELATIONS_ENABLED',
IS_REST_METADATA_API_NEW_FORMAT_DIRECT = 'IS_REST_METADATA_API_NEW_FORMAT_DIRECT',