feat: serve HTTP logic functions on isolated *.withtwenty.com domain (#22045)
## Summary Implements [core-team-issues#2473](https://github.com/twentyhq/core-team-issues/issues/2473): serve HTTP-triggered logic functions from a dedicated, **cookieless** public domain (`{workspaceSubdomain}.withtwenty.com`) instead of the same-site `/s/` route, so functions can safely return **arbitrary headers** — custom headers, `Permissions-Policy` (camera/mic/geolocation), `Cross-Origin-Opener-Policy: same-origin`, `Cross-Origin-Embedder-Policy: require-corp`, `Set-Cookie`, etc. The `/s/` route stays the strict, same-site path it is today. **Self-hosting is unchanged** — everything new is gated on `PUBLIC_DOMAIN_URL` being set. ### Why Today user-authored function responses are served same-site with the Twenty app, so the response-header allow-list is restricted to 5 safe headers and request headers are limited to a per-function allow-list. Serving from an origin that shares nothing with `*.twenty.com` removes that constraint safely — the same "user content domain" pattern as GitHub (`*.githubusercontent.com`) and CodeSandbox (`*.csb.app`). ## What's in here **Routing** - The **root-path → `/s` rewrite happens at the nginx ingress**, not in app code. The existing `api-ingress.yaml` already rewrites root paths onto `/s` (host-agnostically) when the edge sets `X-Twenty-Public-Domain: true`, so `*.withtwenty.com` and registered custom public domains are handled by the same mechanism. (An earlier in-app middleware was removed as a redundant, wrong-layer duplicate.) - `WorkspaceDomainsService.resolveWorkspaceAndPublicDomain` recognizes `*.` subdomains, resolves the workspace by subdomain, and returns `isIsolatedOrigin`. Explicitly registered public-domain rows still take precedence and keep their application scoping. The ingress preserves the `Host` header, so this resolution still fires. **Headers (server)** - Isolated origin → all response headers pass through and all request headers are forwarded. Same-site `/s/` keeps the strict allow-lists. (Global CORS already handles preflight/ACAO.) **`/s/` deprecation for new routes (cloud only)** - New `LOGIC_FUNCTION_LEGACY_ROUTE_CUTOFF` config var (ISO date, optional). When `PUBLIC_DOMAIN_URL` is set, functions created on/after the cutoff return **410 Gone** on `/s/` with the new URL. Existing routes and self-hosted instances are untouched. **Frontend education** - `publicFunctionDomain` added to `ClientConfig` (from `PUBLIC_DOMAIN_URL`). - The logic-function **Live URL** now resolves to `https://{workspaceSubdomain}.{publicFunctionDomain}{path}` on cloud, falling back to `/s/` for self-hosting. - Front components call their functions through the SDK (`RestApiClient`), which now targets the isolated domain via the injected `TWENTY_FUNCTIONS_URL`. - New **"Public URL"** section on the application **Settings** tab explaining the isolated domain (shown when the app exposes HTTP-triggered functions). **Docs**: note the `withtwenty.com` domain for external callers in the apps guide. ## Infra prerequisites (not code — needs dashboard work) - Wildcard DNS `*.withtwenty.com` (proxied) + wildcard TLS in the public-domain Cloudflare zone. - Edge (Cloudflare) sets `X-Twenty-Public-Domain: true` for `*.withtwenty.com` requests, so the existing nginx ingress rewrites them onto `/s` (same header the custom-domain flow already relies on). - Set `PUBLIC_DOMAIN_URL=https://withtwenty.com` on cloud. - Submit `withtwenty.com` to the **Public Suffix List** (required for cross-tenant cookie isolation before relying on `Set-Cookie`). ## Test plan - [x] `nx typecheck twenty-server`, `nx typecheck twenty-front` - [x] `lint:diff-with-main` + oxfmt clean (server + front) - [x] `npx jest route-trigger public-function-domain domain-server-config workspace-domains build-logic-function-event client-config` → server unit tests passing (resolution tiers, header passthrough vs allow-list, `/s/` cutoff 410) - [x] `npx jest getLogicFunctionHttpUrl` (front) and `nx test twenty-client-sdk` (RestApiClient routing) passing - [x] CI green (server, front, sdk, renderer, ui, zapier, example apps) - [ ] Manual: hit `{subdomain}.withtwenty.com/` end-to-end once infra is provisioned <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22045?utm_source=github" rel="nofollow noreferrer noopener" target="_blank">``<img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg">``</a>
This commit is contained in:
@@ -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
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
+4
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
+15
@@ -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}
|
||||
|
||||
+3
@@ -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={
|
||||
|
||||
+11
-76
@@ -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>
|
||||
);
|
||||
|
||||
+21
-10
@@ -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
-1
@@ -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
|
||||
|
||||
-13
@@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const selectedApplicationIdForPublicDomainState = createAtomState<
|
||||
string | undefined
|
||||
>({
|
||||
key: 'selectedApplicationIdForPublicDomainState',
|
||||
defaultValue: undefined,
|
||||
});
|
||||
+3
-4
@@ -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
|
||||
|
||||
+31
@@ -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,
|
||||
};
|
||||
};
|
||||
+79
@@ -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');
|
||||
});
|
||||
});
|
||||
+43
@@ -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}`;
|
||||
};
|
||||
+6
-1
@@ -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',
|
||||
|
||||
+15
-1
@@ -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} />
|
||||
)}
|
||||
|
||||
+63
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
-15
@@ -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
|
||||
|
||||
+10
@@ -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),
|
||||
);
|
||||
+25
@@ -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"',
|
||||
);
|
||||
}
|
||||
}
|
||||
+27
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
@@ -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,
|
||||
|
||||
+2
@@ -11,4 +11,6 @@ export const APPLICATION_ENTITY_RELATION_PROPERTIES = [
|
||||
'packageJsonFile',
|
||||
'yarnLockFile',
|
||||
'applicationRegistration',
|
||||
'primaryPublicDomain',
|
||||
'publicDomains',
|
||||
] as const satisfies (keyof ApplicationEntity)[];
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
|
||||
+2
@@ -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',
|
||||
|
||||
+3
@@ -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(
|
||||
|
||||
+42
-6
@@ -1,6 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { buildUrlWithPathnameAndSearchParams } from 'src/engine/core-modules/domain/domain-server-config/utils/build-url-with-pathname-and-search-params.util';
|
||||
import {
|
||||
getHostnameFromUrlOrUndefined,
|
||||
isHostUnderPublicFunctionDomain,
|
||||
} from 'src/engine/core-modules/domain/domain-server-config/utils/public-function-domain.util';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
@Injectable()
|
||||
@@ -31,6 +37,12 @@ export class DomainServerConfigService {
|
||||
return new URL(this.twentyConfigService.get('PUBLIC_DOMAIN_URL'));
|
||||
}
|
||||
|
||||
getPublicBaseHostnameOrUndefined(): string | undefined {
|
||||
return getHostnameFromUrlOrUndefined(
|
||||
this.twentyConfigService.get('PUBLIC_DOMAIN_URL'),
|
||||
);
|
||||
}
|
||||
|
||||
buildBaseUrl({
|
||||
pathname,
|
||||
searchParams,
|
||||
@@ -52,14 +64,38 @@ export class DomainServerConfigService {
|
||||
|
||||
const isFrontdomain = originHostname.endsWith(`.${frontDomain}`);
|
||||
|
||||
const subdomain = originHostname.replace(`.${frontDomain}`, '');
|
||||
if (isFrontdomain) {
|
||||
const subdomain = originHostname.replace(`.${frontDomain}`, '');
|
||||
|
||||
return {
|
||||
subdomain: this.isDefaultSubdomain(subdomain) ? undefined : subdomain,
|
||||
domain: null,
|
||||
isPublicDomainOrigin: false,
|
||||
};
|
||||
}
|
||||
|
||||
const publicBaseDomain = this.getPublicBaseHostnameOrUndefined();
|
||||
|
||||
if (
|
||||
isDefined(publicBaseDomain) &&
|
||||
isHostUnderPublicFunctionDomain({
|
||||
host: originHostname,
|
||||
publicDomainBaseHostname: publicBaseDomain,
|
||||
})
|
||||
) {
|
||||
const subdomain = originHostname.replace(`.${publicBaseDomain}`, '');
|
||||
|
||||
return {
|
||||
subdomain: this.isDefaultSubdomain(subdomain) ? undefined : subdomain,
|
||||
domain: null,
|
||||
isPublicDomainOrigin: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
subdomain:
|
||||
isFrontdomain && !this.isDefaultSubdomain(subdomain)
|
||||
? subdomain
|
||||
: undefined,
|
||||
domain: isFrontdomain ? null : originHostname,
|
||||
subdomain: undefined,
|
||||
domain: originHostname,
|
||||
isPublicDomainOrigin: false,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
getHostnameFromUrlOrUndefined,
|
||||
isHostUnderPublicFunctionDomain,
|
||||
} from 'src/engine/core-modules/domain/domain-server-config/utils/public-function-domain.util';
|
||||
|
||||
describe('getHostnameFromUrlOrUndefined', () => {
|
||||
it('returns the lowercased hostname of a valid url', () => {
|
||||
expect(getHostnameFromUrlOrUndefined('https://WithTwenty.com')).toBe(
|
||||
'withtwenty.com',
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores the path and port', () => {
|
||||
expect(
|
||||
getHostnameFromUrlOrUndefined('https://withtwenty.com:8080/ignored'),
|
||||
).toBe('withtwenty.com');
|
||||
});
|
||||
|
||||
it('returns undefined for empty/nullish input', () => {
|
||||
expect(getHostnameFromUrlOrUndefined(undefined)).toBeUndefined();
|
||||
expect(getHostnameFromUrlOrUndefined(null)).toBeUndefined();
|
||||
expect(getHostnameFromUrlOrUndefined('')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for a non-url string', () => {
|
||||
expect(getHostnameFromUrlOrUndefined('not a url')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isHostUnderPublicFunctionDomain', () => {
|
||||
const publicDomainBaseHostname = 'withtwenty.com';
|
||||
|
||||
it('matches a strict subdomain of the base', () => {
|
||||
expect(
|
||||
isHostUnderPublicFunctionDomain({
|
||||
host: 'acme.withtwenty.com',
|
||||
publicDomainBaseHostname,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('matches deeper subdomains', () => {
|
||||
expect(
|
||||
isHostUnderPublicFunctionDomain({
|
||||
host: 'app.acme.withtwenty.com',
|
||||
publicDomainBaseHostname,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is case-insensitive and strips the port', () => {
|
||||
expect(
|
||||
isHostUnderPublicFunctionDomain({
|
||||
host: 'ACME.WithTwenty.com:443',
|
||||
publicDomainBaseHostname,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match the apex base itself', () => {
|
||||
expect(
|
||||
isHostUnderPublicFunctionDomain({
|
||||
host: 'withtwenty.com',
|
||||
publicDomainBaseHostname,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not match the main app domain', () => {
|
||||
expect(
|
||||
isHostUnderPublicFunctionDomain({
|
||||
host: 'acme.twenty.com',
|
||||
publicDomainBaseHostname,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not match a lookalike suffix', () => {
|
||||
expect(
|
||||
isHostUnderPublicFunctionDomain({
|
||||
host: 'evilwithtwenty.com',
|
||||
publicDomainBaseHostname,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when no base is configured', () => {
|
||||
expect(
|
||||
isHostUnderPublicFunctionDomain({
|
||||
host: 'acme.withtwenty.com',
|
||||
publicDomainBaseHostname: undefined,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when host is missing', () => {
|
||||
expect(
|
||||
isHostUnderPublicFunctionDomain({
|
||||
host: undefined,
|
||||
publicDomainBaseHostname,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
export const getHostnameFromUrlOrUndefined = (
|
||||
url?: string | null,
|
||||
): string | undefined => {
|
||||
if (!isNonEmptyString(url)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(url).hostname.toLowerCase();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const isHostUnderPublicFunctionDomain = ({
|
||||
host,
|
||||
publicDomainBaseHostname,
|
||||
}: {
|
||||
host?: string | null;
|
||||
publicDomainBaseHostname?: string;
|
||||
}): boolean => {
|
||||
if (!isNonEmptyString(host) || !isNonEmptyString(publicDomainBaseHostname)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hostname = host.split(':')[0].toLowerCase();
|
||||
const base = publicDomainBaseHostname.toLowerCase();
|
||||
|
||||
return hostname !== base && hostname.endsWith(`.${base}`);
|
||||
};
|
||||
+82
-2
@@ -1,6 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
@@ -99,8 +100,9 @@ export class WorkspaceDomainsService {
|
||||
async resolveWorkspaceAndPublicDomain(origin: string): Promise<{
|
||||
workspace: WorkspaceEntity | undefined;
|
||||
publicDomain: PublicDomainEntity | null;
|
||||
isIsolatedOrigin: boolean;
|
||||
}> {
|
||||
const { subdomain, domain } =
|
||||
const { subdomain, domain, isPublicDomainOrigin } =
|
||||
this.domainServerConfigService.getSubdomainAndDomainFromUrl(origin);
|
||||
|
||||
if (!this.twentyConfigService.get('IS_MULTIWORKSPACE_ENABLED')) {
|
||||
@@ -113,11 +115,46 @@ export class WorkspaceDomainsService {
|
||||
return {
|
||||
workspace: await this.getDefaultWorkspace(),
|
||||
publicDomain: publicDomain ?? null,
|
||||
isIsolatedOrigin: isPublicDomainOrigin || isDefined(publicDomain),
|
||||
};
|
||||
}
|
||||
|
||||
if (isPublicDomainOrigin) {
|
||||
const hostname = new URL(origin).hostname;
|
||||
|
||||
const registeredPublicDomain = await this.publicDomainRepository.findOne({
|
||||
where: { domain: hostname },
|
||||
relations: ['workspace', 'workspace.workspaceSSOIdentityProviders'],
|
||||
});
|
||||
|
||||
if (isDefined(registeredPublicDomain)) {
|
||||
return {
|
||||
workspace: registeredPublicDomain.workspace ?? undefined,
|
||||
publicDomain: registeredPublicDomain,
|
||||
isIsolatedOrigin: true,
|
||||
};
|
||||
}
|
||||
|
||||
const workspaceFromSubdomain = isDefined(subdomain)
|
||||
? ((await this.workspaceRepository.findOne({
|
||||
where: { subdomain },
|
||||
relations: ['workspaceSSOIdentityProviders'],
|
||||
})) ?? undefined)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
workspace: workspaceFromSubdomain,
|
||||
publicDomain: null,
|
||||
isIsolatedOrigin: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (!domain && !subdomain) {
|
||||
return { workspace: undefined, publicDomain: null };
|
||||
return {
|
||||
workspace: undefined,
|
||||
publicDomain: null,
|
||||
isIsolatedOrigin: false,
|
||||
};
|
||||
}
|
||||
|
||||
const where = isDefined(domain) ? { customDomain: domain } : { subdomain };
|
||||
@@ -132,6 +169,7 @@ export class WorkspaceDomainsService {
|
||||
return {
|
||||
workspace: workspaceFromCustomDomainOrSubdomain,
|
||||
publicDomain: null,
|
||||
isIsolatedOrigin: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -143,9 +181,51 @@ export class WorkspaceDomainsService {
|
||||
return {
|
||||
workspace: publicDomain?.workspace ?? undefined,
|
||||
publicDomain: publicDomain ?? null,
|
||||
isIsolatedOrigin: isDefined(publicDomain),
|
||||
};
|
||||
}
|
||||
|
||||
buildPublicFunctionBaseUrl({
|
||||
workspace,
|
||||
primaryPublicDomain,
|
||||
}: {
|
||||
workspace: Pick<WorkspaceEntity, 'subdomain'>;
|
||||
primaryPublicDomain?: string | null;
|
||||
}): string | undefined {
|
||||
if (isNonEmptyString(primaryPublicDomain)) {
|
||||
return `https://${primaryPublicDomain}`;
|
||||
}
|
||||
|
||||
const publicBaseHostname =
|
||||
this.domainServerConfigService.getPublicBaseHostnameOrUndefined();
|
||||
|
||||
if (!isNonEmptyString(publicBaseHostname)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const url = this.domainServerConfigService.getPublicDomainUrl();
|
||||
|
||||
url.hostname = `${workspace.subdomain}.${publicBaseHostname}`;
|
||||
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
buildPublicFunctionUrl({
|
||||
workspace,
|
||||
path,
|
||||
}: {
|
||||
workspace: Pick<WorkspaceEntity, 'subdomain'>;
|
||||
path: string;
|
||||
}): string | undefined {
|
||||
const baseUrl = this.buildPublicFunctionBaseUrl({ workspace });
|
||||
|
||||
if (!isDefined(baseUrl)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `${baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
private getCustomWorkspaceUrl(customDomain: string) {
|
||||
const url = this.domainServerConfigService.getFrontUrl();
|
||||
|
||||
|
||||
+3
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
+6
@@ -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,
|
||||
|
||||
+3
@@ -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);
|
||||
}
|
||||
|
||||
+71
-4
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
+23
@@ -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', () => {
|
||||
|
||||
+25
-3
@@ -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),
|
||||
|
||||
+2
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-4
@@ -1,6 +1,6 @@
|
||||
import { ArgsType, Field } from '@nestjs/graphql';
|
||||
|
||||
import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
@ArgsType()
|
||||
export class CreatePublicDomainInput {
|
||||
@@ -9,8 +9,7 @@ export class CreatePublicDomainInput {
|
||||
@IsNotEmpty()
|
||||
domain: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsOptional()
|
||||
@Field(() => String)
|
||||
@IsUUID()
|
||||
applicationId?: string | null;
|
||||
applicationId: string;
|
||||
}
|
||||
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
import { ArgsType, Field } from '@nestjs/graphql';
|
||||
|
||||
import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
@ArgsType()
|
||||
export class UpdatePublicDomainInput {
|
||||
@Field(() => String)
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
domain: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
applicationId?: string | null;
|
||||
}
|
||||
+11
-7
@@ -34,13 +34,17 @@ export class PublicDomainEntity extends WorkspaceRelatedEntity {
|
||||
@Column({ type: 'boolean', default: false, nullable: false })
|
||||
isValidated: boolean;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
applicationId: string | null;
|
||||
@Column({ type: 'uuid', nullable: false })
|
||||
applicationId: string;
|
||||
|
||||
@ManyToOne(() => ApplicationEntity, {
|
||||
onDelete: 'CASCADE',
|
||||
nullable: true,
|
||||
})
|
||||
@ManyToOne(
|
||||
() => ApplicationEntity,
|
||||
(application) => application.publicDomains,
|
||||
{
|
||||
onDelete: 'CASCADE',
|
||||
nullable: false,
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: 'applicationId' })
|
||||
application: Relation<ApplicationEntity> | null;
|
||||
application: Relation<ApplicationEntity>;
|
||||
}
|
||||
|
||||
+1
-14
@@ -14,7 +14,6 @@ import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/re
|
||||
import { CreatePublicDomainInput } from 'src/engine/core-modules/public-domain/dtos/create-public-domain.input';
|
||||
import { PublicDomainDTO } from 'src/engine/core-modules/public-domain/dtos/public-domain.dto';
|
||||
import { PublicDomainInput } from 'src/engine/core-modules/public-domain/dtos/public-domain.input';
|
||||
import { UpdatePublicDomainInput } from 'src/engine/core-modules/public-domain/dtos/update-public-domain.input';
|
||||
import { PublicDomainExceptionFilter } from 'src/engine/core-modules/public-domain/public-domain-exception-filter';
|
||||
import { PublicDomainEntity } from 'src/engine/core-modules/public-domain/public-domain.entity';
|
||||
import {
|
||||
@@ -60,19 +59,7 @@ export class PublicDomainResolver {
|
||||
return this.publicDomainService.createPublicDomain({
|
||||
domain,
|
||||
workspace: currentWorkspace,
|
||||
applicationId: applicationId ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => PublicDomainDTO)
|
||||
async updatePublicDomain(
|
||||
@Args() { domain, applicationId }: UpdatePublicDomainInput,
|
||||
@AuthWorkspace() currentWorkspace: WorkspaceEntity,
|
||||
): Promise<PublicDomainDTO> {
|
||||
return this.publicDomainService.updatePublicDomainApplication({
|
||||
domain,
|
||||
workspace: currentWorkspace,
|
||||
applicationId: applicationId ?? null,
|
||||
applicationId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+6
-50
@@ -59,7 +59,7 @@ export class PublicDomainService {
|
||||
}: {
|
||||
domain: string;
|
||||
workspace: WorkspaceEntity;
|
||||
applicationId: string | null;
|
||||
applicationId: string;
|
||||
}): Promise<PublicDomainDTO> {
|
||||
const formattedDomain = domain.trim().toLowerCase();
|
||||
|
||||
@@ -69,12 +69,10 @@ export class PublicDomainService {
|
||||
this.publicDomainRepository.findOne(workspace.id, {
|
||||
where: { domain: formattedDomain },
|
||||
}),
|
||||
isDefined(applicationId)
|
||||
? this.applicationRepository.findOneBy({
|
||||
id: applicationId,
|
||||
workspaceId: workspace.id,
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
this.applicationRepository.findOneBy({
|
||||
id: applicationId,
|
||||
workspaceId: workspace.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (isDefined(workspaceWithCustomDomain)) {
|
||||
@@ -97,7 +95,7 @@ export class PublicDomainService {
|
||||
);
|
||||
}
|
||||
|
||||
if (isDefined(applicationId) && !isDefined(application)) {
|
||||
if (!isDefined(application)) {
|
||||
throw new PublicDomainException(
|
||||
'Application not found in this workspace',
|
||||
PublicDomainExceptionCode.APPLICATION_NOT_FOUND,
|
||||
@@ -130,48 +128,6 @@ export class PublicDomainService {
|
||||
return publicDomain;
|
||||
}
|
||||
|
||||
async updatePublicDomainApplication({
|
||||
domain,
|
||||
workspace,
|
||||
applicationId,
|
||||
}: {
|
||||
domain: string;
|
||||
workspace: WorkspaceEntity;
|
||||
applicationId: string | null;
|
||||
}): Promise<PublicDomainDTO> {
|
||||
const formattedDomain = domain.trim().toLowerCase();
|
||||
|
||||
const [publicDomain, application] = await Promise.all([
|
||||
this.publicDomainRepository.findOne(workspace.id, {
|
||||
where: { domain: formattedDomain },
|
||||
}),
|
||||
isDefined(applicationId)
|
||||
? this.applicationRepository.findOneBy({
|
||||
id: applicationId,
|
||||
workspaceId: workspace.id,
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
if (!isDefined(publicDomain)) {
|
||||
throw new PublicDomainException(
|
||||
`Public domain ${domain} not found`,
|
||||
PublicDomainExceptionCode.PUBLIC_DOMAIN_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if (isDefined(applicationId) && !isDefined(application)) {
|
||||
throw new PublicDomainException(
|
||||
'Application not found in this workspace',
|
||||
PublicDomainExceptionCode.APPLICATION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
publicDomain.applicationId = applicationId;
|
||||
|
||||
return this.publicDomainRepository.save(workspace.id, publicDomain);
|
||||
}
|
||||
|
||||
async checkPublicDomainValidRecords(
|
||||
publicDomain: PublicDomainEntity,
|
||||
domainValidRecords?: DomainValidRecords,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+1
@@ -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
@@ -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: [
|
||||
|
||||
+75
-22
@@ -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);
|
||||
|
||||
+18
-35
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
-2
@@ -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,
|
||||
|
||||
-5
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user