feat(public-domain): bind public domains to apps + reorganize settings (#20360)

## Summary

- **Public domains can now be bound to a specific app.** When a request
hits an app-bound public domain, route resolution restricts
logic-function matching to that app's HTTP-routed functions only —
isolating each app's routes to its own domain instead of letting routes
from other apps in the workspace match nondeterministically.
- **Settings sidebar reorganized.** Removed the standalone Domains page.
Workspace Domain → General. Approved Domains + Invitations → Members
"Access" tab. Emailing Domains + Public Domains → Apps "Developer" tab.
Roles → Members "Roles" tab.

## Why

The use case: someone building a partner portal app or a lead-collection
app declares private objects (leads, partners…) plus a few public HTTP
routes. Each app needs its own domain (`partners.acme.com`,
`leads.acme.com`) without those domains exposing every other app's
routes in the same workspace. Today's PublicDomainEntity is
workspace-scoped only, so all HTTP-routed logic functions in a workspace
compete for any public domain — first match wins nondeterministically.

## Backend

- Added nullable `applicationId` FK to `PublicDomainEntity`
(cascade-deleted with the app); indexed for the route-trigger lookup.
- New fast instance command
`2-4-instance-command-fast-1798000003000-add-application-id-to-public-domain`
adds the column, index, and FK constraint.
- `createPublicDomain(domain, applicationId)` accepts an optional app
binding; new `updatePublicDomain(domain, applicationId)` mutation
rebinds/unbinds an existing domain. Both validate the application
belongs to the workspace.
- `WorkspaceDomainsService.resolveWorkspaceAndPublicDomain(origin)`
returns both the workspace and the matched public domain in one query —
replacing the old back-to-back lookups in the route-trigger hot path.
`getWorkspaceByOriginOrDefaultWorkspace` is preserved as a thin wrapper.
- `RouteTriggerService` filters `logicFunction` by `applicationId` when
the matched public domain is app-scoped; falls back to workspace-wide
when unbound.
- Three sequential validation queries in `createPublicDomain` now run in
parallel via `Promise.all`.

## Frontend

| Old location | New location |
|---|---|
| Settings sidebar → Domains (standalone page) | Removed |
| Domains page → Workspace Domain | General page |
| Domains page → Approved Domains | Members → Access tab |
| Domains page → Emailing Domains | Apps → Developer tab |
| Domains page → Public Domains | Apps → Developer tab |
| Settings sidebar → Roles (standalone) | Members → Roles tab |
| `pages/settings/roles/` | `pages/settings/members/roles/` |

- The Public Domain detail page has an Application picker that uses
`Select`'s native `emptyOption` + `null` value pattern (matches
`SettingsDataModelObjectIdentifiersForm`).
- Members page tabs use the existing `TabListFromUrlOptionalEffect`
mechanism (rendered automatically by `TabList`) for hash-based tab
activation.
- `/settings/members/roles` redirects to `/settings/members#roles` so
role sub-pages' `navigate(SettingsPath.Roles)` lands on the Members page
with the Roles tab pre-selected.
- All affected breadcrumbs updated to nest under their new parents.
- `SettingsPath.Roles` and friends now nest under `members/`;
`Subdomain` and `CustomDomain` under `general/`; `PublicDomain` and
`EmailingDomain` under `applications/`.

## Test plan

- [x] `nx typecheck twenty-front` passes
- [x] `nx typecheck twenty-server` passes
- [x] `oxlint --type-aware` clean on all touched files
- [x] `prettier --check` clean on all touched files
- [x] Migration applied locally; `publicDomain.applicationId` (uuid,
nullable) confirmed in DB
- [x] GraphQL schema exposes `PublicDomain.applicationId`,
`createPublicDomain.applicationId`, `updatePublicDomain` mutation
- [x] **End-to-end route resolution scenarios verified locally:**
  - Domain bound to App A, function in App A → route matches 
- Domain bound to App B, function in App A → route does NOT match (HTTP
404 `TRIGGER_NOT_FOUND`) 
- Domain unbound (`applicationId = NULL`) → route matches workspace-wide

  - Unknown path on bound domain → returns 404 cleanly 
- [x] UI sanity (browser-tested at `apple.localhost:3001`):
  - General page shows Workspace Domain card
  - Members page shows Team / Access / Roles tabs
  - Access tab combines Invite by link + by email + Approved Domains
  - Roles tab embeds the role list
- `/settings/members/roles` direct URL → redirects + Roles tab
pre-selected
  - Apps Developer tab shows Emailing Domains + Public Domains sections
- Public Domain detail page has Application picker dropdown listing
workspace apps
- Sidebar nav: "Domains" and "Roles" no longer present (now folded into
General/Members)

## Notes for reviewers

- Creating a public domain via the UI still requires Cloudflare
credentials in the dev `.env` (`CLOUDFLARE_API_KEY`,
`CLOUDFLARE_PUBLIC_DOMAIN_ZONE_ID`, `PUBLIC_DOMAIN_URL`). The DNS step
is unchanged from main.
- The `applicationId` column is nullable, so existing public-domain rows
continue to work workspace-wide — no data backfill required.
- `SettingsRolesContainer` was deleted (no longer referenced after
`SettingsRoles` index page was removed).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-05-10 20:17:28 +02:00
committed by GitHub
parent 086830f81b
commit 34b927ff23
73 changed files with 1168 additions and 905 deletions
@@ -63,7 +63,7 @@ export class ApprovedAccessDomainService {
const link = this.workspaceDomainsService.buildWorkspaceURL({
workspace,
pathname: getSettingsPath(SettingsPath.Domains),
pathname: getSettingsPath(SettingsPath.WorkspaceMembersPage),
searchParams: {
wtdId: approvedAccessDomain.id,
validationToken: this.generateUniqueHash(approvedAccessDomain),
@@ -310,7 +310,7 @@ describe('ApprovedAccessDomainService', () => {
expect(workspaceDomainsService.buildWorkspaceURL).toHaveBeenCalledWith({
workspace: workspace,
pathname: getSettingsPath(SettingsPath.Domains),
pathname: getSettingsPath(SettingsPath.WorkspaceMembersPage),
searchParams: { validationToken: expect.any(String) },
});
@@ -85,14 +85,34 @@ export class WorkspaceDomainsService {
}
async getWorkspaceByOriginOrDefaultWorkspace(origin: string) {
if (!this.twentyConfigService.get('IS_MULTIWORKSPACE_ENABLED')) {
return this.getDefaultWorkspace();
}
const { workspace } = await this.resolveWorkspaceAndPublicDomain(origin);
return workspace;
}
async resolveWorkspaceAndPublicDomain(origin: string): Promise<{
workspace: WorkspaceEntity | undefined;
publicDomain: PublicDomainEntity | null;
}> {
const { subdomain, domain } =
this.domainServerConfigService.getSubdomainAndDomainFromUrl(origin);
if (!domain && !subdomain) return;
if (!this.twentyConfigService.get('IS_MULTIWORKSPACE_ENABLED')) {
// Single-workspace: workspace is always the default. Still resolve a
// matching public domain so the route trigger can scope by application.
const publicDomain = isDefined(domain)
? await this.publicDomainRepository.findOne({ where: { domain } })
: null;
return {
workspace: await this.getDefaultWorkspace(),
publicDomain: publicDomain ?? null,
};
}
if (!domain && !subdomain) {
return { workspace: undefined, publicDomain: null };
}
const where = isDefined(domain) ? { customDomain: domain } : { subdomain };
@@ -103,18 +123,21 @@ export class WorkspaceDomainsService {
})) ?? undefined;
if (isDefined(workspaceFromCustomDomainOrSubdomain) || !isDefined(domain)) {
return workspaceFromCustomDomainOrSubdomain;
return {
workspace: workspaceFromCustomDomainOrSubdomain,
publicDomain: null,
};
}
const publicDomainFromCustomDomain =
await this.publicDomainRepository.findOne({
where: {
domain,
},
relations: ['workspace', 'workspace.workspaceSSOIdentityProviders'],
});
const publicDomain = await this.publicDomainRepository.findOne({
where: { domain },
relations: ['workspace', 'workspace.workspaceSSOIdentityProviders'],
});
return publicDomainFromCustomDomain?.workspace;
return {
workspace: publicDomain?.workspace ?? undefined,
publicDomain: publicDomain ?? null,
};
}
private getCustomWorkspaceUrl(customDomain: string) {
@@ -50,10 +50,8 @@ export class RouteTriggerService {
}> {
const host = `${request.protocol}://${request.get('host')}`;
const workspace =
await this.workspaceDomainsService.getWorkspaceByOriginOrDefaultWorkspace(
host,
);
const { workspace, publicDomain } =
await this.workspaceDomainsService.resolveWorkspaceAndPublicDomain(host);
assertIsDefinedOrThrow(
workspace,
@@ -63,11 +61,15 @@ export class RouteTriggerService {
),
);
// App-scoped public domain → restrict matches to that app's logic functions.
const applicationId = publicDomain?.applicationId ?? null;
const logicFunctionsWithHttpRouteTrigger =
await this.logicFunctionRepository.find({
where: {
workspaceId: workspace.id,
httpRouteTriggerSettings: Not(IsNull()),
...(isDefined(applicationId) ? { applicationId } : {}),
},
});
@@ -0,0 +1,16 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator';
@ArgsType()
export class CreatePublicDomainInput {
@Field(() => String)
@IsString()
@IsNotEmpty()
domain: string;
@Field(() => String, { nullable: true })
@IsOptional()
@IsUUID()
applicationId?: string | null;
}
@@ -15,6 +15,9 @@ export class PublicDomainDTO {
@Field({ nullable: false })
isValidated: boolean;
@Field(() => UUIDScalarType, { nullable: true })
applicationId: string | null;
@Field()
createdAt: Date;
}
@@ -0,0 +1,16 @@
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;
}
@@ -19,6 +19,7 @@ export class PublicDomainExceptionFilter implements ExceptionFilter {
case PublicDomainExceptionCode.DOMAIN_ALREADY_REGISTERED_AS_CUSTOM_DOMAIN:
throw new UserInputError(exception);
case PublicDomainExceptionCode.PUBLIC_DOMAIN_NOT_FOUND:
case PublicDomainExceptionCode.APPLICATION_NOT_FOUND:
throw new NotFoundError(exception);
default:
assertUnreachable(exception.code);
@@ -4,14 +4,20 @@ import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
UpdateDateColumn,
} from 'typeorm';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity';
@Entity({ name: 'publicDomain', schema: 'core' })
@ObjectType('PublicDomain')
@Index('IDX_PUBLIC_DOMAIN_APPLICATION_ID', ['applicationId'])
export class PublicDomainEntity extends WorkspaceRelatedEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@@ -27,4 +33,14 @@ export class PublicDomainEntity extends WorkspaceRelatedEntity {
@Column({ type: 'boolean', default: false, nullable: false })
isValidated: boolean;
@Column({ type: 'uuid', nullable: true })
applicationId: string | null;
@ManyToOne(() => ApplicationEntity, {
onDelete: 'CASCADE',
nullable: true,
})
@JoinColumn({ name: 'applicationId' })
application: Relation<ApplicationEntity> | null;
}
@@ -8,6 +8,7 @@ export enum PublicDomainExceptionCode {
PUBLIC_DOMAIN_ALREADY_REGISTERED = 'PUBLIC_DOMAIN_ALREADY_REGISTERED',
DOMAIN_ALREADY_REGISTERED_AS_CUSTOM_DOMAIN = 'DOMAIN_ALREADY_REGISTERED_AS_CUSTOM_DOMAIN',
PUBLIC_DOMAIN_NOT_FOUND = 'PUBLIC_DOMAIN_NOT_FOUND',
APPLICATION_NOT_FOUND = 'APPLICATION_NOT_FOUND',
}
const getPublicDomainExceptionUserFriendlyMessage = (
@@ -20,6 +21,8 @@ const getPublicDomainExceptionUserFriendlyMessage = (
return msg`This domain is already registered as a custom domain.`;
case PublicDomainExceptionCode.PUBLIC_DOMAIN_NOT_FOUND:
return msg`Public domain not found.`;
case PublicDomainExceptionCode.APPLICATION_NOT_FOUND:
return msg`Application not found in this workspace.`;
default:
assertUnreachable(code);
}
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { PublicDomainService } from 'src/engine/core-modules/public-domain/public-domain.service';
import { PublicDomainEntity } from 'src/engine/core-modules/public-domain/public-domain.entity';
import { PublicDomainResolver } from 'src/engine/core-modules/public-domain/public-domain.resolver';
@@ -13,7 +14,11 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
@Module({
imports: [
NestjsQueryTypeOrmModule.forFeature([PublicDomainEntity, WorkspaceEntity]),
NestjsQueryTypeOrmModule.forFeature([
PublicDomainEntity,
WorkspaceEntity,
ApplicationEntity,
]),
DnsManagerModule,
PermissionsModule,
],
@@ -11,8 +11,10 @@ import { DomainValidRecords } from 'src/engine/core-modules/dns-manager/dtos/dom
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
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 {
@@ -54,12 +56,25 @@ export class PublicDomainResolver {
@Mutation(() => PublicDomainDTO)
async createPublicDomain(
@Args() { domain }: PublicDomainInput,
@Args() { domain, applicationId }: CreatePublicDomainInput,
@AuthWorkspace() currentWorkspace: WorkspaceEntity,
): Promise<PublicDomainDTO> {
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,
});
}
@@ -2,9 +2,11 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { msg } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { DnsManagerService } from 'src/engine/core-modules/dns-manager/services/dns-manager.service';
import { PublicDomainDTO } from 'src/engine/core-modules/public-domain/dtos/public-domain.dto';
import { PublicDomainEntity } from 'src/engine/core-modules/public-domain/public-domain.entity';
@@ -23,6 +25,8 @@ export class PublicDomainService {
private readonly publicDomainRepository: Repository<PublicDomainEntity>,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
) {}
async deletePublicDomain({
@@ -47,17 +51,30 @@ export class PublicDomainService {
async createPublicDomain({
domain,
workspace,
applicationId,
}: {
domain: string;
workspace: WorkspaceEntity;
applicationId: string | null;
}): Promise<PublicDomainDTO> {
const formattedDomain = domain.trim().toLowerCase();
if (
await this.workspaceRepository.findOneBy({
customDomain: formattedDomain,
})
) {
const [workspaceWithCustomDomain, existingPublicDomain, application] =
await Promise.all([
this.workspaceRepository.findOneBy({ customDomain: formattedDomain }),
this.publicDomainRepository.findOneBy({
domain: formattedDomain,
workspaceId: workspace.id,
}),
isDefined(applicationId)
? this.applicationRepository.findOneBy({
id: applicationId,
workspaceId: workspace.id,
})
: Promise.resolve(null),
]);
if (isDefined(workspaceWithCustomDomain)) {
throw new PublicDomainException(
'Domain already used for workspace custom domain',
PublicDomainExceptionCode.DOMAIN_ALREADY_REGISTERED_AS_CUSTOM_DOMAIN,
@@ -67,12 +84,7 @@ export class PublicDomainService {
);
}
if (
await this.publicDomainRepository.findOneBy({
domain: formattedDomain,
workspaceId: workspace.id,
})
) {
if (isDefined(existingPublicDomain)) {
throw new PublicDomainException(
'Public domain already registered',
PublicDomainExceptionCode.PUBLIC_DOMAIN_ALREADY_REGISTERED,
@@ -82,9 +94,17 @@ export class PublicDomainService {
);
}
if (isDefined(applicationId) && !isDefined(application)) {
throw new PublicDomainException(
'Application not found in this workspace',
PublicDomainExceptionCode.APPLICATION_NOT_FOUND,
);
}
const publicDomain = this.publicDomainRepository.create({
domain: formattedDomain,
workspaceId: workspace.id,
applicationId,
});
await this.dnsManagerService.registerHostname(formattedDomain, {
@@ -94,7 +114,7 @@ export class PublicDomainService {
try {
await this.publicDomainRepository.insert(
publicDomain as QueryDeepPartialEntity<
Omit<PublicDomainEntity, 'workspace'>
Omit<PublicDomainEntity, 'workspace' | 'application'>
>,
);
} catch (error) {
@@ -108,6 +128,49 @@ 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.findOneBy({
domain: formattedDomain,
workspaceId: workspace.id,
}),
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(publicDomain);
}
async checkPublicDomainValidRecords(
publicDomain: PublicDomainEntity,
domainValidRecords?: DomainValidRecords,