Refactor application module architecture for clarity and explicitness (#18432)

## Summary

- **Module reorganization**: Moved `ApplicationUpgradeService` and cron
jobs to `application-upgrade/`, `ApplicationSyncService` to
`application-manifest/`, and
`runWorkspaceMigration`/`uninstallApplication` mutations to the manifest
resolver — each module now has a single clear responsibility.
- **Explicit install flow**: Removed implicit `ApplicationEntity`
creation from `ApplicationSyncService`. The install service and dev
resolver now explicitly create the `ApplicationEntity` before syncing.
npm packages are resolved at registration time to extract manifest
metadata (universalIdentifier, name, description, etc.), eliminating the
`reconcileUniversalIdentifier` hack.
- **Better error handling**: Frontend hooks now surface actual server
error messages in snackbars instead of swallowing them. Replaced the
ugly `ConfirmationModal` for transfer ownership with a proper form
modal. Fixed `SettingsAdminTableCard` row height overflow and corrected
the `yarn-engine` asset path.

## Test plan
- [ ] Register an npm package — verify manifest metadata (name,
description, universalIdentifier) is extracted correctly
- [ ] Install a registered npm app on a workspace — verify
ApplicationEntity is created and sync succeeds
- [ ] Test `app:dev` CLI flow — verify local app registration and sync
work
- [ ] Upload a tarball — verify registration and install flow
- [ ] Transfer ownership — verify the new modal UX works
- [ ] Verify error messages appear correctly in snackbars when
operations fail


Made with [Cursor](https://cursor.com)
This commit is contained in:
Félix Malfait
2026-03-06 08:45:08 +01:00
committed by GitHub
parent 90cced0e74
commit 514d0017ea
184 changed files with 3179 additions and 1382 deletions
@@ -6,6 +6,7 @@ import { AdminPanelHealthService } from 'src/engine/core-modules/admin-panel/adm
import { AdminPanelQueueService } from 'src/engine/core-modules/admin-panel/admin-panel-queue.service';
import { AdminPanelResolver } from 'src/engine/core-modules/admin-panel/admin-panel.resolver';
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service';
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
@@ -40,6 +41,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
ImpersonationModule,
PermissionsModule,
SecureHttpClientModule,
ApplicationRegistrationModule,
],
providers: [
AdminPanelResolver,
@@ -7,6 +7,8 @@ import { PermissionFlagType } from 'twenty-shared/constants';
import { AdminPanelHealthService } from 'src/engine/core-modules/admin-panel/admin-panel-health.service';
import { AdminPanelQueueService } from 'src/engine/core-modules/admin-panel/admin-panel-queue.service';
import { AdminPanelService } from 'src/engine/core-modules/admin-panel/admin-panel.service';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { AdminAIModelsDTO } from 'src/engine/core-modules/client-config/client-config.entity';
import { ConfigVariableDTO } from 'src/engine/core-modules/admin-panel/dtos/config-variable.dto';
import { ConfigVariablesDTO } from 'src/engine/core-modules/admin-panel/dtos/config-variables.dto';
@@ -56,8 +58,9 @@ import { QueueMetricsDataDTO } from './dtos/queue-metrics-data.dto';
)
export class AdminPanelResolver {
constructor(
private adminService: AdminPanelService,
private adminPanelHealthService: AdminPanelHealthService,
private readonly adminService: AdminPanelService,
private readonly adminPanelHealthService: AdminPanelHealthService,
private readonly applicationRegistrationService: ApplicationRegistrationService,
private adminPanelQueueService: AdminPanelQueueService,
private featureFlagService: FeatureFlagService,
private readonly twentyConfigService: TwentyConfigService,
@@ -267,4 +270,12 @@ export class AdminPanelResolver {
jobIds,
);
}
@UseGuards(AdminPanelGuard)
@Query(() => [ApplicationRegistrationEntity])
async findAllApplicationRegistrations(): Promise<
ApplicationRegistrationEntity[]
> {
return this.applicationRegistrationService.findAll();
}
}
@@ -1,7 +1,7 @@
import { Module } from '@nestjs/common';
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
import { ApplicationInstallModule } from 'src/engine/core-modules/application/application-install/application-install.module';
import { ApplicationManifestModule } from 'src/engine/core-modules/application/application-manifest/application-manifest.module';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { ApplicationDevelopmentResolver } from 'src/engine/core-modules/application/application-development/application-development.resolver';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
@@ -12,9 +12,9 @@ import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/wor
@Module({
imports: [
ApplicationInstallModule,
ApplicationRegistrationModule,
ApplicationModule,
ApplicationManifestModule,
ApplicationRegistrationModule,
FeatureFlagModule,
TokenModule,
FileStorageModule,
@@ -1,5 +1,4 @@
import {
Logger,
UseFilters,
UseGuards,
UseInterceptors,
@@ -13,24 +12,22 @@ import { FileFolder, FeatureFlagKey } from 'twenty-shared/types';
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration/application-registration-variable.service';
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto';
import { ApplicationInput } from 'src/engine/core-modules/application/dtos/application.input';
import { CreateApplicationInput } from 'src/engine/core-modules/application/dtos/create-application.input';
import { GenerateApplicationTokenInput } from 'src/engine/core-modules/application/dtos/generate-application-token.input';
import { UploadApplicationFileInput } from 'src/engine/core-modules/application/dtos/uploadApplicationFileInput';
import { WorkspaceMigrationDTO } from 'src/engine/core-modules/application/dtos/workspace-migration.dto';
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-install/application-sync.service';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationTokenPairDTO } from 'src/engine/core-modules/application/dtos/application-token-pair.dto';
import { ApplicationInput } from 'src/engine/core-modules/application/application-development/dtos/application.input';
import { GenerateApplicationTokenInput } from 'src/engine/core-modules/application/application-development/dtos/generate-application-token.input';
import { UploadApplicationFileInput } from 'src/engine/core-modules/application/application-development/dtos/upload-application-file.input';
import { WorkspaceMigrationDTO } from 'src/engine/core-modules/application/application-development/dtos/workspace-migration.dto';
import { ApplicationTokenPairDTO } from 'src/engine/core-modules/application/application-oauth/dtos/application-token-pair.dto';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
@@ -58,12 +55,10 @@ import { streamToBuffer } from 'src/utils/stream-to-buffer';
SettingsPermissionGuard(PermissionFlagType.APPLICATIONS),
)
export class ApplicationDevelopmentResolver {
private readonly logger = new Logger(ApplicationDevelopmentResolver.name);
constructor(
private readonly applicationTokenService: ApplicationTokenService,
private readonly applicationSyncService: ApplicationSyncService,
private readonly applicationService: ApplicationService,
private readonly applicationSyncService: ApplicationSyncService,
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
private readonly fileStorageService: FileStorageService,
@@ -87,19 +82,17 @@ export class ApplicationDevelopmentResolver {
@Args() { manifest }: ApplicationInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<WorkspaceMigrationDTO> {
const applicationRegistrationId =
await this.resolveApplicationRegistrationId(
manifest.application.universalIdentifier,
{
name: manifest.application.displayName,
description: manifest.application.description,
logoUrl: manifest.application.logoUrl,
author: manifest.application.author,
websiteUrl: manifest.application.websiteUrl,
termsUrl: manifest.application.termsUrl,
},
workspaceId,
);
const applicationRegistrationId = await this.findApplicationRegistrationId(
manifest.application.universalIdentifier,
workspaceId,
);
await this.ensureApplicationExists({
universalIdentifier: manifest.application.universalIdentifier,
name: manifest.application.displayName,
workspaceId,
applicationRegistrationId,
});
const workspaceMigration =
await this.applicationSyncService.synchronizeFromManifest({
@@ -121,19 +114,6 @@ export class ApplicationDevelopmentResolver {
};
}
@Mutation(() => ApplicationDTO)
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
async createOneApplication(
@Args('input') input: CreateApplicationInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
return await this.applicationService.create({
...input,
sourceType: AppRegistrationSourceType.LOCAL,
workspaceId,
});
}
@Mutation(() => FileDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.UPLOAD_FILE))
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
@@ -176,16 +156,8 @@ export class ApplicationDevelopmentResolver {
});
}
private async resolveApplicationRegistrationId(
private async findApplicationRegistrationId(
universalIdentifier: string,
metadata: {
name: string;
description?: string;
logoUrl?: string;
author?: string;
websiteUrl?: string;
termsUrl?: string;
},
workspaceId: string,
): Promise<string> {
const existingRegistration =
@@ -193,35 +165,27 @@ export class ApplicationDevelopmentResolver {
universalIdentifier,
);
if (existingRegistration) {
const isOwner =
await this.applicationRegistrationService.isOwnedByWorkspace(
existingRegistration.id,
workspaceId,
);
if (!isOwner) {
throw new ApplicationException(
'Cannot sync application: registration is owned by another workspace',
ApplicationExceptionCode.FORBIDDEN,
);
}
return existingRegistration.id;
if (!existingRegistration) {
throw new ApplicationException(
`No registration found for "${universalIdentifier}". Create one first with createApplicationRegistration.`,
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
);
}
const { applicationRegistration: newRegistration } =
await this.applicationRegistrationService.create(
{ ...metadata, universalIdentifier },
const isOwner =
await this.applicationRegistrationService.isOwnedByWorkspace(
existingRegistration.id,
workspaceId,
null,
);
this.logger.log(
`Created app registration for ${metadata.name} (${universalIdentifier})`,
);
if (!isOwner) {
throw new ApplicationException(
'Cannot sync application: registration is owned by another workspace',
ApplicationExceptionCode.FORBIDDEN,
);
}
return newRegistration.id;
return existingRegistration.id;
}
private async syncRegistrationMetadata(
@@ -259,4 +223,29 @@ export class ApplicationDevelopmentResolver {
}
}
}
private async ensureApplicationExists(params: {
universalIdentifier: string;
name: string;
workspaceId: string;
applicationRegistrationId: string;
}): Promise<void> {
const existing = await this.applicationService.findByUniversalIdentifier({
universalIdentifier: params.universalIdentifier,
workspaceId: params.workspaceId,
});
if (existing) {
return;
}
await this.applicationService.create({
universalIdentifier: params.universalIdentifier,
name: params.name,
sourcePath: params.universalIdentifier,
sourceType: ApplicationRegistrationSourceType.LOCAL,
applicationRegistrationId: params.applicationRegistrationId,
workspaceId: params.workspaceId,
});
}
}
@@ -21,6 +21,7 @@ export class ApplicationExceptionFilter implements ExceptionFilter {
case ApplicationExceptionCode.FIELD_NOT_FOUND:
case ApplicationExceptionCode.ENTITY_NOT_FOUND:
case ApplicationExceptionCode.APPLICATION_NOT_FOUND:
case ApplicationExceptionCode.APP_NOT_INSTALLED:
case ApplicationExceptionCode.LOGIC_FUNCTION_NOT_FOUND:
case ApplicationExceptionCode.FRONT_COMPONENT_NOT_FOUND:
throw new NotFoundError(exception);
@@ -4,66 +4,26 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { CacheLockModule } from 'src/engine/core-modules/cache-lock/cache-lock.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { ApplicationManifestModule } from 'src/engine/core-modules/application/application-manifest/application-manifest.module';
import { ApplicationPackageModule } from 'src/engine/core-modules/application/application-package/application-package.module';
import { ApplicationInstallResolver } from 'src/engine/core-modules/application/application-install/application-install.resolver';
import { AppPackageFetcherService } from 'src/engine/core-modules/application/application-install/app-package-fetcher.service';
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/application-install/application-manifest-migration.service';
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-install/application-sync.service';
import { AppUpgradeService } from 'src/engine/core-modules/application/application-install/app-upgrade.service';
import { ApplicationVariableEntityModule } from 'src/engine/core-modules/application/application-variable/application-variable.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { ObjectPermissionModule } from 'src/engine/metadata-modules/object-permission/object-permission.module';
import { PermissionFlagModule } from 'src/engine/metadata-modules/permission-flag/permission-flag.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/workspace-migration-runner.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module';
import { CodeStepBuildModule } from 'src/modules/workflow/workflow-builder/workflow-version-step/code-step/code-step-build.module';
@Module({
imports: [
TypeOrmModule.forFeature([
FileEntity,
ApplicationRegistrationEntity,
ApplicationEntity,
]),
TypeOrmModule.forFeature([ApplicationRegistrationEntity]),
ApplicationModule,
ApplicationManifestModule,
ApplicationPackageModule,
CacheLockModule,
FeatureFlagModule,
ApplicationVariableEntityModule,
TokenModule,
WorkspaceMigrationModule,
PermissionsModule,
ObjectPermissionModule,
PermissionFlagModule,
WorkflowCommonModule,
CodeStepBuildModule,
FileStorageModule,
WorkspaceCacheModule,
WorkspaceMigrationRunnerModule,
TwentyConfigModule,
],
providers: [
ApplicationInstallResolver,
ApplicationManifestMigrationService,
ApplicationSyncService,
AppPackageFetcherService,
ApplicationInstallService,
AppUpgradeService,
WorkspaceMigrationGraphqlApiExceptionInterceptor,
],
exports: [
ApplicationSyncService,
AppPackageFetcherService,
ApplicationInstallService,
AppUpgradeService,
],
providers: [ApplicationInstallResolver, ApplicationInstallService],
exports: [ApplicationInstallService],
})
export class ApplicationInstallModule {}
@@ -1,9 +1,4 @@
import {
UseFilters,
UseGuards,
UseInterceptors,
UsePipes,
} from '@nestjs/common';
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Mutation, Query } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
@@ -12,18 +7,10 @@ import { FeatureFlagKey } from 'twenty-shared/types';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-install/application-sync.service';
import { ApplicationTokenPairDTO } from 'src/engine/core-modules/application/dtos/application-token-pair.dto';
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto';
import { InstallApplicationInput } from 'src/engine/core-modules/application/dtos/install-application.input';
import { UninstallApplicationInput } from 'src/engine/core-modules/application/dtos/uninstallApplicationInput';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
@@ -31,25 +18,17 @@ import {
FeatureFlagGuard,
RequireFeatureFlag,
} from 'src/engine/guards/feature-flag.guard';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service';
@UsePipes(ResolverValidationPipe)
@MetadataResolver()
@UseInterceptors(WorkspaceMigrationGraphqlApiExceptionInterceptor)
@UseFilters(ApplicationExceptionFilter, AuthGraphqlApiExceptionFilter)
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard)
export class ApplicationInstallResolver {
constructor(
private readonly applicationService: ApplicationService,
private readonly applicationTokenService: ApplicationTokenService,
private readonly applicationSyncService: ApplicationSyncService,
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly applicationInstallService: ApplicationInstallService,
) {}
@Query(() => [ApplicationDTO])
@@ -78,84 +57,19 @@ export class ApplicationInstallResolver {
});
}
@Mutation(() => ApplicationTokenPairDTO)
@UseGuards(NoPermissionGuard)
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
async renewApplicationToken(
@Args('applicationRefreshToken') applicationRefreshToken: string,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<ApplicationTokenPairDTO> {
const applicationRefreshTokenPayload =
this.applicationTokenService.validateApplicationRefreshToken(
applicationRefreshToken,
);
if (applicationRefreshTokenPayload.workspaceId !== workspaceId) {
throw new ApplicationException(
'Refresh token workspace does not match authenticated workspace',
ApplicationExceptionCode.FORBIDDEN,
);
}
return this.applicationTokenService.renewApplicationTokens(
applicationRefreshTokenPayload,
);
}
@Mutation(() => Boolean)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
async installApplication(
@Args() { workspaceMigration: { actions } }: InstallApplicationInput,
@Args('appRegistrationId') appRegistrationId: string,
@Args('version', { type: () => String, nullable: true })
version: string | undefined,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
const { featureFlagsMap } = await this.workspaceCacheService.getOrRecompute(
workspaceId,
['featureFlagsMap'],
);
if (
featureFlagsMap[
FeatureFlagKey.IS_APPLICATION_INSTALLATION_FROM_TARBALL_ENABLED
] !== true
) {
throw new ApplicationException(
'Application installation from tarball is not enabled',
ApplicationExceptionCode.FORBIDDEN,
);
}
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{
workspaceId,
},
);
await this.workspaceMigrationRunnerService.run({
workspaceMigration: {
actions,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
},
): Promise<boolean> {
return this.applicationInstallService.installApplication({
appRegistrationId,
version,
workspaceId,
});
return true;
}
@Mutation(() => Boolean)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
async uninstallApplication(
@Args() { universalIdentifier }: UninstallApplicationInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
await this.applicationSyncService.uninstallApplication({
applicationUniversalIdentifier: universalIdentifier,
workspaceId,
});
return true;
}
}
@@ -8,14 +8,19 @@ import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import {
AppPackageFetcherService,
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import {
ApplicationPackageFetcherService,
type ResolvedPackage,
} from 'src/engine/core-modules/application/application-install/app-package-fetcher.service';
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-install/application-sync.service';
} from 'src/engine/core-modules/application/application-package/application-package-fetcher.service';
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service';
import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
@@ -43,9 +48,8 @@ export class ApplicationInstallService {
constructor(
@InjectRepository(ApplicationRegistrationEntity)
private readonly appRegistrationRepository: Repository<ApplicationRegistrationEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
private readonly appPackageFetcherService: AppPackageFetcherService,
private readonly applicationService: ApplicationService,
private readonly applicationPackageFetcherService: ApplicationPackageFetcherService,
private readonly applicationSyncService: ApplicationSyncService,
private readonly fileStorageService: FileStorageService,
private readonly cacheLockService: CacheLockService,
@@ -56,11 +60,20 @@ export class ApplicationInstallService {
version?: string;
workspaceId: string;
}): Promise<boolean> {
const appRegistration = await this.appRegistrationRepository.findOneOrFail({
const appRegistration = await this.appRegistrationRepository.findOne({
where: { id: params.appRegistrationId },
});
if (appRegistration.sourceType === AppRegistrationSourceType.LOCAL) {
if (!appRegistration) {
throw new ApplicationException(
`Application registration with id ${params.appRegistrationId} not found`,
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
);
}
if (
appRegistration.sourceType === ApplicationRegistrationSourceType.LOCAL
) {
this.logger.log(
`Skipping install for LOCAL app ${appRegistration.universalIdentifier} (files synced by CLI watcher in dev mode)`,
);
@@ -88,18 +101,29 @@ export class ApplicationInstallService {
let resolvedPackage: ResolvedPackage | null = null;
try {
resolvedPackage = await this.appPackageFetcherService.resolvePackage(
appRegistration,
{ targetVersion: params.version },
);
resolvedPackage =
await this.applicationPackageFetcherService.resolvePackage(
appRegistration,
{ targetVersion: params.version },
);
if (!resolvedPackage) {
return true;
}
const universalIdentifier = appRegistration.universalIdentifier;
await this.ensureApplicationExists({
universalIdentifier,
name: resolvedPackage.manifest.application.displayName,
workspaceId: params.workspaceId,
applicationRegistrationId: appRegistration.id,
sourceType: appRegistration.sourceType,
});
await this.writeFilesToStorage(
resolvedPackage.extractedDir,
appRegistration.universalIdentifier,
universalIdentifier,
params.workspaceId,
);
@@ -109,14 +133,8 @@ export class ApplicationInstallService {
applicationRegistrationId: appRegistration.id,
});
await this.updateApplicationSourceType(
appRegistration.universalIdentifier,
params.workspaceId,
appRegistration.sourceType,
);
this.logger.log(
`Successfully installed app ${appRegistration.universalIdentifier} v${resolvedPackage.packageJson.version ?? 'unknown'}`,
`Successfully installed app ${universalIdentifier} v${resolvedPackage.packageJson.version ?? 'unknown'}`,
);
return true;
@@ -128,7 +146,7 @@ export class ApplicationInstallService {
throw error;
} finally {
if (resolvedPackage) {
await this.appPackageFetcherService.cleanupExtractedDir(
await this.applicationPackageFetcherService.cleanupExtractedDir(
resolvedPackage.cleanupDir,
);
}
@@ -202,14 +220,29 @@ export class ApplicationInstallService {
return result;
}
private async updateApplicationSourceType(
universalIdentifier: string,
workspaceId: string,
sourceType: AppRegistrationSourceType,
): Promise<void> {
await this.applicationRepository.update(
{ universalIdentifier, workspaceId },
{ sourceType },
);
private async ensureApplicationExists(params: {
universalIdentifier: string;
name: string;
workspaceId: string;
applicationRegistrationId: string;
sourceType: ApplicationRegistrationSourceType;
}): Promise<ApplicationEntity> {
const existing = await this.applicationService.findByUniversalIdentifier({
universalIdentifier: params.universalIdentifier,
workspaceId: params.workspaceId,
});
if (isDefined(existing)) {
return existing;
}
return this.applicationService.create({
universalIdentifier: params.universalIdentifier,
name: params.name,
sourcePath: params.universalIdentifier,
sourceType: params.sourceType,
applicationRegistrationId: params.applicationRegistrationId,
workspaceId: params.workspaceId,
});
}
}
@@ -5,14 +5,14 @@ import axios from 'axios';
import { Repository } from 'typeorm';
import { z } from 'zod';
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
const npmPackageMetadataSchema = z.object({
@@ -20,8 +20,8 @@ const npmPackageMetadataSchema = z.object({
});
@Injectable()
export class AppUpgradeService {
private readonly logger = new Logger(AppUpgradeService.name);
export class ApplicationUpgradeService {
private readonly logger = new Logger(ApplicationUpgradeService.name);
constructor(
@InjectRepository(ApplicationRegistrationEntity)
@@ -35,7 +35,7 @@ export class AppUpgradeService {
async checkForUpdates(
appRegistration: ApplicationRegistrationEntity,
): Promise<string | null> {
if (appRegistration.sourceType !== AppRegistrationSourceType.NPM) {
if (appRegistration.sourceType !== ApplicationRegistrationSourceType.NPM) {
return null;
}
@@ -82,7 +82,7 @@ export class AppUpgradeService {
async checkAllForUpdates(): Promise<void> {
const npmRegistrations = await this.appRegistrationRepository.find({
where: { sourceType: AppRegistrationSourceType.NPM },
where: { sourceType: ApplicationRegistrationSourceType.NPM },
});
for (const registration of npmRegistrations) {
@@ -100,8 +100,8 @@ export class AppUpgradeService {
});
if (
appRegistration.sourceType === AppRegistrationSourceType.LOCAL ||
appRegistration.sourceType === AppRegistrationSourceType.TARBALL
appRegistration.sourceType === ApplicationRegistrationSourceType.LOCAL ||
appRegistration.sourceType === ApplicationRegistrationSourceType.TARBALL
) {
throw new ApplicationException(
'Cannot upgrade an app installed from a tarball or local source',
@@ -0,0 +1,37 @@
import { Injectable, Logger } from '@nestjs/common';
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
import { APPLICATION_VERSION_CHECK_CRON_PATTERN } from 'src/engine/core-modules/application/application-upgrade/crons/constants/application-version-check-cron-pattern.constant';
import { ApplicationUpgradeService } from 'src/engine/core-modules/application/application-upgrade/application-upgrade.service';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
@Injectable()
@Processor(MessageQueue.cronQueue)
export class ApplicationVersionCheckCronJob {
private readonly logger = new Logger(ApplicationVersionCheckCronJob.name);
constructor(
private readonly applicationUpgradeService: ApplicationUpgradeService,
) {}
@Process(ApplicationVersionCheckCronJob.name)
@SentryCronMonitor(
ApplicationVersionCheckCronJob.name,
APPLICATION_VERSION_CHECK_CRON_PATTERN,
)
async handle(): Promise<void> {
this.logger.log('Starting application version check...');
try {
await this.applicationUpgradeService.checkAllForUpdates();
this.logger.log('Application version check completed successfully');
} catch (error) {
this.logger.error(
`Application version check failed: ${error instanceof Error ? error.message : String(error)}`,
);
throw error;
}
}
}
@@ -1,7 +1,7 @@
import { Command, CommandRunner } from 'nest-commander';
import { APP_VERSION_CHECK_CRON_PATTERN } from 'src/engine/core-modules/application/application-version-check/crons/constants/app-version-check-cron-pattern.constant';
import { AppVersionCheckCronJob } from 'src/engine/core-modules/application/application-version-check/crons/app-version-check.cron.job';
import { APPLICATION_VERSION_CHECK_CRON_PATTERN } from 'src/engine/core-modules/application/application-upgrade/crons/constants/application-version-check-cron-pattern.constant';
import { ApplicationVersionCheckCronJob } from 'src/engine/core-modules/application/application-upgrade/crons/application-version-check.cron.job';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
@@ -11,7 +11,7 @@ import { MessageQueueService } from 'src/engine/core-modules/message-queue/servi
description:
'Starts a cron job to check for app version updates on npm registries',
})
export class AppVersionCheckCronCommand extends CommandRunner {
export class ApplicationVersionCheckCronCommand extends CommandRunner {
constructor(
@InjectMessageQueue(MessageQueue.cronQueue)
private readonly messageQueueService: MessageQueueService,
@@ -21,11 +21,11 @@ export class AppVersionCheckCronCommand extends CommandRunner {
async run(): Promise<void> {
await this.messageQueueService.addCron<undefined>({
jobName: AppVersionCheckCronJob.name,
jobName: ApplicationVersionCheckCronJob.name,
data: undefined,
options: {
repeat: {
pattern: APP_VERSION_CHECK_CRON_PATTERN,
pattern: APPLICATION_VERSION_CHECK_CRON_PATTERN,
},
},
});
@@ -0,0 +1,2 @@
// Every 6 hours
export const APPLICATION_VERSION_CHECK_CRON_PATTERN = '0 */6 * * *';
@@ -10,9 +10,9 @@ import {
} from 'src/engine/core-modules/application/application.exception';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { buildFromToAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/utils/build-from-to-all-universal-flat-entity-maps.util';
import { computeApplicationManifestAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/utils/compute-application-manifest-all-universal-flat-entity-maps.util';
import { getApplicationSubAllFlatEntityMaps } from 'src/engine/core-modules/application/utils/get-application-sub-all-flat-entity-maps.util';
import { buildFromToAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/build-from-to-all-universal-flat-entity-maps.util';
import { computeApplicationManifestAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/compute-application-manifest-all-universal-flat-entity-maps.util';
import { getApplicationSubAllFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/get-application-sub-all-flat-entity-maps.util';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
import { FieldPermissionService } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.service';
@@ -0,0 +1,39 @@
import { Module } from '@nestjs/common';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/application-manifest/application-manifest-migration.service';
import { ApplicationManifestResolver } from 'src/engine/core-modules/application/application-manifest/application-manifest.resolver';
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service';
import { ApplicationVariableEntityModule } from 'src/engine/core-modules/application/application-variable/application-variable.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
import { ObjectPermissionModule } from 'src/engine/metadata-modules/object-permission/object-permission.module';
import { PermissionFlagModule } from 'src/engine/metadata-modules/permission-flag/permission-flag.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/workspace-migration-runner.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
@Module({
imports: [
ApplicationModule,
ApplicationVariableEntityModule,
FeatureFlagModule,
FileStorageModule,
ObjectPermissionModule,
PermissionFlagModule,
PermissionsModule,
WorkspaceCacheModule,
WorkspaceMigrationModule,
WorkspaceMigrationRunnerModule,
],
providers: [
ApplicationManifestMigrationService,
ApplicationManifestResolver,
ApplicationSyncService,
WorkspaceMigrationGraphqlApiExceptionInterceptor,
],
exports: [ApplicationManifestMigrationService, ApplicationSyncService],
})
export class ApplicationManifestModule {}
@@ -0,0 +1,105 @@
import {
UseFilters,
UseGuards,
UseInterceptors,
UsePipes,
} from '@nestjs/common';
import { Args, Mutation } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { FeatureFlagKey } from 'twenty-shared/types';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service';
import { RunWorkspaceMigrationInput } from 'src/engine/core-modules/application/application-manifest/dtos/run-workspace-migration.input';
import { UninstallApplicationInput } from 'src/engine/core-modules/application/application-manifest/dtos/uninstall-application.input';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import {
FeatureFlagGuard,
RequireFeatureFlag,
} from 'src/engine/guards/feature-flag.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/services/workspace-migration-runner.service';
@UsePipes(ResolverValidationPipe)
@MetadataResolver()
@UseInterceptors(WorkspaceMigrationGraphqlApiExceptionInterceptor)
@UseFilters(ApplicationExceptionFilter, AuthGraphqlApiExceptionFilter)
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard)
export class ApplicationManifestResolver {
constructor(
private readonly applicationService: ApplicationService,
private readonly applicationSyncService: ApplicationSyncService,
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {}
@Mutation(() => Boolean)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
async runWorkspaceMigration(
@Args() { workspaceMigration: { actions } }: RunWorkspaceMigrationInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
const { featureFlagsMap } = await this.workspaceCacheService.getOrRecompute(
workspaceId,
['featureFlagsMap'],
);
if (
featureFlagsMap[
FeatureFlagKey.IS_APPLICATION_INSTALLATION_FROM_TARBALL_ENABLED
] !== true
) {
throw new ApplicationException(
'Application installation from tarball is not enabled',
ApplicationExceptionCode.FORBIDDEN,
);
}
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{
workspaceId,
},
);
await this.workspaceMigrationRunnerService.run({
workspaceMigration: {
actions,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
},
workspaceId,
});
return true;
}
@Mutation(() => Boolean)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
async uninstallApplication(
@Args() { universalIdentifier }: UninstallApplicationInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
await this.applicationSyncService.uninstallApplication({
applicationUniversalIdentifier: universalIdentifier,
workspaceId,
});
return true;
}
}
@@ -11,12 +11,11 @@ import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/application-install/application-manifest-migration.service';
import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/application-manifest/application-manifest-migration.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { buildFromToAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/utils/build-from-to-all-universal-flat-entity-maps.util';
import { getApplicationSubAllFlatEntityMaps } from 'src/engine/core-modules/application/utils/get-application-sub-all-flat-entity-maps.util';
import { getDefaultApplicationPackageFields } from 'src/engine/core-modules/application/utils/get-default-application-package-fields.util';
import { buildFromToAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/build-from-to-all-universal-flat-entity-maps.util';
import { getApplicationSubAllFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/get-application-sub-all-flat-entity-maps.util';
import { ApplicationVariableEntityService } from 'src/engine/core-modules/application/application-variable/application-variable.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { createEmptyAllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-all-flat-entity-maps.constant';
@@ -93,28 +92,18 @@ export class ApplicationSyncService {
).toString('utf-8'),
) as PackageJson;
const defaultPackageFields = await getDefaultApplicationPackageFields();
let application = await this.applicationService.findByUniversalIdentifier({
universalIdentifier: manifest.application.universalIdentifier,
workspaceId,
});
const application = await this.applicationService.findByUniversalIdentifier(
{
universalIdentifier: manifest.application.universalIdentifier,
workspaceId,
},
);
if (!application) {
application = await this.applicationService.create({
universalIdentifier: manifest.application.universalIdentifier,
name,
description: manifest.application.description,
version: packageJson.version,
sourcePath: 'cli-sync',
defaultRoleId: null,
workspaceId,
packageJsonChecksum: defaultPackageFields.packageJsonChecksum,
packageJsonFileId: null,
yarnLockChecksum: defaultPackageFields.yarnLockChecksum,
yarnLockFileId: null,
availablePackages: defaultPackageFields.availablePackages,
});
throw new ApplicationException(
`Application "${manifest.application.universalIdentifier}" is not installed in workspace "${workspaceId}". Install it first.`,
ApplicationExceptionCode.APP_NOT_INSTALLED,
);
}
await this.applicationVariableService.upsertManyApplicationVariableEntities(
@@ -1,4 +1,4 @@
import { fromNavigationMenuItemManifestToUniversalFlatNavigationMenuItem } from 'src/engine/core-modules/application/utils/from-navigation-menu-item-manifest-to-universal-flat-navigation-menu-item.util';
import { fromNavigationMenuItemManifestToUniversalFlatNavigationMenuItem } from 'src/engine/core-modules/application/application-manifest/converters/from-navigation-menu-item-manifest-to-universal-flat-navigation-menu-item.util';
describe('fromNavigationMenuItemManifestToUniversalFlatNavigationMenuItem', () => {
const now = '2026-01-01T00:00:00.000Z';
@@ -1,5 +1,5 @@
import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum';
import { fromPageLayoutManifestToUniversalFlatPageLayout } from 'src/engine/core-modules/application/utils/from-page-layout-manifest-to-universal-flat-page-layout.util';
import { fromPageLayoutManifestToUniversalFlatPageLayout } from 'src/engine/core-modules/application/application-manifest/converters/from-page-layout-manifest-to-universal-flat-page-layout.util';
describe('fromPageLayoutManifestToUniversalFlatPageLayout', () => {
const now = '2026-01-01T00:00:00.000Z';
@@ -1,6 +1,6 @@
import { PageLayoutTabLayoutMode } from 'twenty-shared/types';
import { fromPageLayoutTabManifestToUniversalFlatPageLayoutTab } from 'src/engine/core-modules/application/utils/from-page-layout-tab-manifest-to-universal-flat-page-layout-tab.util';
import { fromPageLayoutTabManifestToUniversalFlatPageLayoutTab } from 'src/engine/core-modules/application/application-manifest/converters/from-page-layout-tab-manifest-to-universal-flat-page-layout-tab.util';
describe('fromPageLayoutTabManifestToUniversalFlatPageLayoutTab', () => {
const now = '2026-01-01T00:00:00.000Z';
@@ -1,5 +1,5 @@
import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum';
import { fromPageLayoutWidgetManifestToUniversalFlatPageLayoutWidget } from 'src/engine/core-modules/application/utils/from-page-layout-widget-manifest-to-universal-flat-page-layout-widget.util';
import { fromPageLayoutWidgetManifestToUniversalFlatPageLayoutWidget } from 'src/engine/core-modules/application/application-manifest/converters/from-page-layout-widget-manifest-to-universal-flat-page-layout-widget.util';
describe('fromPageLayoutWidgetManifestToUniversalFlatPageLayoutWidget', () => {
const now = '2026-01-01T00:00:00.000Z';
@@ -1,6 +1,6 @@
import { AggregateOperations } from 'twenty-shared/types';
import { fromViewFieldManifestToUniversalFlatViewField } from 'src/engine/core-modules/application/utils/from-view-field-manifest-to-universal-flat-view-field.util';
import { fromViewFieldManifestToUniversalFlatViewField } from 'src/engine/core-modules/application/application-manifest/converters/from-view-field-manifest-to-universal-flat-view-field.util';
describe('fromViewFieldManifestToUniversalFlatViewField', () => {
const now = '2026-01-01T00:00:00.000Z';
@@ -1,6 +1,6 @@
import { ViewFilterOperand } from 'twenty-shared/types';
import { fromViewFilterManifestToUniversalFlatViewFilter } from 'src/engine/core-modules/application/utils/from-view-filter-manifest-to-universal-flat-view-filter.util';
import { fromViewFilterManifestToUniversalFlatViewFilter } from 'src/engine/core-modules/application/application-manifest/converters/from-view-filter-manifest-to-universal-flat-view-filter.util';
describe('fromViewFilterManifestToUniversalFlatViewFilter', () => {
const now = '2026-01-01T00:00:00.000Z';
@@ -4,7 +4,7 @@ import {
ViewVisibility,
} from 'twenty-shared/types';
import { fromViewManifestToUniversalFlatView } from 'src/engine/core-modules/application/utils/from-view-manifest-to-universal-flat-view.util';
import { fromViewManifestToUniversalFlatView } from 'src/engine/core-modules/application/application-manifest/converters/from-view-manifest-to-universal-flat-view.util';
describe('fromViewManifestToUniversalFlatView', () => {
const now = '2026-01-01T00:00:00.000Z';
@@ -46,7 +46,7 @@ export class WorkspaceMigrationInput {
}
@ArgsType()
export class InstallApplicationInput {
export class RunWorkspaceMigrationInput {
@Field(() => WorkspaceMigrationInput)
@ValidateNested()
@Type(() => WorkspaceMigrationInput)
@@ -1,23 +1,23 @@
import { type Manifest } from 'twenty-shared/application';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { fromCommandMenuItemManifestToUniversalFlatCommandMenuItem } from 'src/engine/core-modules/application/utils/from-command-menu-item-manifest-to-universal-flat-command-menu-item.util';
import { fromFieldManifestToUniversalFlatFieldMetadata } from 'src/engine/core-modules/application/utils/from-field-manifest-to-universal-flat-field-metadata.util';
import { fromFrontComponentManifestToUniversalFlatFrontComponent } from 'src/engine/core-modules/application/utils/from-front-component-manifest-to-universal-flat-front-component.util';
import { fromLogicFunctionManifestToUniversalFlatLogicFunction } from 'src/engine/core-modules/application/utils/from-logic-function-manifest-to-universal-flat-logic-function.util';
import { fromNavigationMenuItemManifestToUniversalFlatNavigationMenuItem } from 'src/engine/core-modules/application/utils/from-navigation-menu-item-manifest-to-universal-flat-navigation-menu-item.util';
import { fromObjectManifestToUniversalFlatObjectMetadata } from 'src/engine/core-modules/application/utils/from-object-manifest-to-universal-flat-object-metadata.util';
import { fromPageLayoutManifestToUniversalFlatPageLayout } from 'src/engine/core-modules/application/utils/from-page-layout-manifest-to-universal-flat-page-layout.util';
import { fromPageLayoutTabManifestToUniversalFlatPageLayoutTab } from 'src/engine/core-modules/application/utils/from-page-layout-tab-manifest-to-universal-flat-page-layout-tab.util';
import { fromPageLayoutWidgetManifestToUniversalFlatPageLayoutWidget } from 'src/engine/core-modules/application/utils/from-page-layout-widget-manifest-to-universal-flat-page-layout-widget.util';
import { fromRoleManifestToUniversalFlatRole } from 'src/engine/core-modules/application/utils/from-role-manifest-to-universal-flat-role.util';
import { fromSkillManifestToUniversalFlatSkill } from 'src/engine/core-modules/application/utils/from-skill-manifest-to-universal-flat-skill.util';
import { fromViewFieldGroupManifestToUniversalFlatViewFieldGroup } from 'src/engine/core-modules/application/utils/from-view-field-group-manifest-to-universal-flat-view-field-group.util';
import { fromViewFieldManifestToUniversalFlatViewField } from 'src/engine/core-modules/application/utils/from-view-field-manifest-to-universal-flat-view-field.util';
import { fromViewFilterGroupManifestToUniversalFlatViewFilterGroup } from 'src/engine/core-modules/application/utils/from-view-filter-group-manifest-to-universal-flat-view-filter-group.util';
import { fromViewFilterManifestToUniversalFlatViewFilter } from 'src/engine/core-modules/application/utils/from-view-filter-manifest-to-universal-flat-view-filter.util';
import { fromViewGroupManifestToUniversalFlatViewGroup } from 'src/engine/core-modules/application/utils/from-view-group-manifest-to-universal-flat-view-group.util';
import { fromViewManifestToUniversalFlatView } from 'src/engine/core-modules/application/utils/from-view-manifest-to-universal-flat-view.util';
import { fromCommandMenuItemManifestToUniversalFlatCommandMenuItem } from 'src/engine/core-modules/application/application-manifest/converters/from-command-menu-item-manifest-to-universal-flat-command-menu-item.util';
import { fromFieldManifestToUniversalFlatFieldMetadata } from 'src/engine/core-modules/application/application-manifest/converters/from-field-manifest-to-universal-flat-field-metadata.util';
import { fromFrontComponentManifestToUniversalFlatFrontComponent } from 'src/engine/core-modules/application/application-manifest/converters/from-front-component-manifest-to-universal-flat-front-component.util';
import { fromLogicFunctionManifestToUniversalFlatLogicFunction } from 'src/engine/core-modules/application/application-manifest/converters/from-logic-function-manifest-to-universal-flat-logic-function.util';
import { fromNavigationMenuItemManifestToUniversalFlatNavigationMenuItem } from 'src/engine/core-modules/application/application-manifest/converters/from-navigation-menu-item-manifest-to-universal-flat-navigation-menu-item.util';
import { fromObjectManifestToUniversalFlatObjectMetadata } from 'src/engine/core-modules/application/application-manifest/converters/from-object-manifest-to-universal-flat-object-metadata.util';
import { fromPageLayoutManifestToUniversalFlatPageLayout } from 'src/engine/core-modules/application/application-manifest/converters/from-page-layout-manifest-to-universal-flat-page-layout.util';
import { fromPageLayoutTabManifestToUniversalFlatPageLayoutTab } from 'src/engine/core-modules/application/application-manifest/converters/from-page-layout-tab-manifest-to-universal-flat-page-layout-tab.util';
import { fromPageLayoutWidgetManifestToUniversalFlatPageLayoutWidget } from 'src/engine/core-modules/application/application-manifest/converters/from-page-layout-widget-manifest-to-universal-flat-page-layout-widget.util';
import { fromRoleManifestToUniversalFlatRole } from 'src/engine/core-modules/application/application-manifest/converters/from-role-manifest-to-universal-flat-role.util';
import { fromSkillManifestToUniversalFlatSkill } from 'src/engine/core-modules/application/application-manifest/converters/from-skill-manifest-to-universal-flat-skill.util';
import { fromViewFieldGroupManifestToUniversalFlatViewFieldGroup } from 'src/engine/core-modules/application/application-manifest/converters/from-view-field-group-manifest-to-universal-flat-view-field-group.util';
import { fromViewFieldManifestToUniversalFlatViewField } from 'src/engine/core-modules/application/application-manifest/converters/from-view-field-manifest-to-universal-flat-view-field.util';
import { fromViewFilterGroupManifestToUniversalFlatViewFilterGroup } from 'src/engine/core-modules/application/application-manifest/converters/from-view-filter-group-manifest-to-universal-flat-view-filter-group.util';
import { fromViewFilterManifestToUniversalFlatViewFilter } from 'src/engine/core-modules/application/application-manifest/converters/from-view-filter-manifest-to-universal-flat-view-filter.util';
import { fromViewGroupManifestToUniversalFlatViewGroup } from 'src/engine/core-modules/application/application-manifest/converters/from-view-group-manifest-to-universal-flat-view-group.util';
import { fromViewManifestToUniversalFlatView } from 'src/engine/core-modules/application/application-manifest/converters/from-view-manifest-to-universal-flat-view.util';
import { createEmptyAllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-all-flat-entity-maps.constant';
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/add-universal-flat-entity-to-universal-flat-entity-maps-through-mutation-or-throw.util';
@@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
import { MARKETPLACE_CATALOG_SYNC_CRON_PATTERN } from 'src/engine/core-modules/application/application-marketplace/crons/constants/marketplace-catalog-sync-cron-pattern.constant';
import { MarketplaceCatalogSyncService } from 'src/engine/core-modules/application/application-marketplace/services/marketplace-catalog-sync.service';
import { MarketplaceCatalogSyncService } from 'src/engine/core-modules/application/application-marketplace/marketplace-catalog-sync.service';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
@@ -2,6 +2,7 @@ import { Field, Int, ObjectType } from '@nestjs/graphql';
import {
IsArray,
IsBoolean,
IsNotEmpty,
IsNumber,
IsOptional,
@@ -285,4 +286,8 @@ export class MarketplaceAppDTO {
@IsString()
@Field({ nullable: true })
sourcePackage?: string;
@IsBoolean()
@Field(() => Boolean)
isFeatured: boolean;
}
@@ -0,0 +1,88 @@
import { Injectable, Logger } from '@nestjs/common';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { MARKETPLACE_CATALOG_INDEX } from 'src/engine/core-modules/application/application-marketplace/constants/marketplace-catalog-index.constant';
import { MarketplaceService } from 'src/engine/core-modules/application/application-marketplace/marketplace.service';
@Injectable()
export class MarketplaceCatalogSyncService {
private readonly logger = new Logger(MarketplaceCatalogSyncService.name);
constructor(
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly marketplaceService: MarketplaceService,
) {}
async syncCatalog(): Promise<void> {
await this.syncCuratedApps();
await this.syncNpmApps();
this.logger.log('Marketplace catalog sync completed');
}
private async syncCuratedApps(): Promise<void> {
for (const entry of MARKETPLACE_CATALOG_INDEX) {
try {
await this.applicationRegistrationService.upsertFromCatalog({
universalIdentifier: entry.universalIdentifier,
name: entry.name,
description:
entry.richDisplayData.aboutDescription ?? entry.description,
author: entry.author,
sourceType: ApplicationRegistrationSourceType.NPM,
sourcePackage: entry.sourcePackage,
logoUrl: entry.logoUrl ?? null,
websiteUrl: entry.websiteUrl ?? null,
termsUrl: entry.termsUrl ?? null,
latestAvailableVersion: entry.richDisplayData.version ?? null,
isListed: true,
isFeatured: entry.isFeatured,
marketplaceDisplayData: entry.richDisplayData,
ownerWorkspaceId: null,
});
} catch (error) {
this.logger.error(
`Failed to sync curated app "${entry.name}": ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
private async syncNpmApps(): Promise<void> {
const npmApps = await this.marketplaceService.fetchAppsFromNpmRegistry();
const curatedIdentifiers = new Set(
MARKETPLACE_CATALOG_INDEX.map((entry) => entry.universalIdentifier),
);
for (const app of npmApps) {
if (curatedIdentifiers.has(app.id)) {
continue;
}
try {
await this.applicationRegistrationService.upsertFromCatalog({
universalIdentifier: app.id,
name: app.name,
description: app.description,
author: app.author,
sourceType: ApplicationRegistrationSourceType.NPM,
sourcePackage: app.sourcePackage ?? app.name,
logoUrl: null,
websiteUrl: app.websiteUrl ?? null,
termsUrl: null,
latestAvailableVersion: app.version ?? null,
isListed: true,
isFeatured: false,
marketplaceDisplayData: null,
ownerWorkspaceId: null,
});
} catch (error) {
this.logger.error(
`Failed to sync npm app "${app.name}": ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
}
@@ -1,17 +1,13 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { v4 } from 'uuid';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { type ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import {
ApplicationRegistrationException,
ApplicationRegistrationExceptionCode,
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
import { assertValidNpmPackageName } from 'src/engine/core-modules/application/utils/assert-valid-npm-package-name.util';
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { MarketplaceCatalogSyncCronJob } from 'src/engine/core-modules/application/application-marketplace/crons/marketplace-catalog-sync.cron.job';
import { MarketplaceAppDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app.dto';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
@@ -28,8 +24,7 @@ export class MarketplaceQueryService {
private hasSyncBeenEnqueued = false;
constructor(
@InjectRepository(ApplicationRegistrationEntity)
private readonly appRegistrationRepository: Repository<ApplicationRegistrationEntity>,
private readonly applicationRegistrationService: ApplicationRegistrationService,
@InjectMessageQueue(MessageQueue.cronQueue)
private readonly messageQueueService: MessageQueueService,
) {}
@@ -39,9 +34,8 @@ export class MarketplaceQueryService {
return this.cachedApps;
}
const registrations = await this.appRegistrationRepository.find({
where: { sourceType: AppRegistrationSourceType.NPM },
});
const registrations =
await this.applicationRegistrationService.findManyListed();
if (registrations.length === 0) {
if (!this.hasSyncBeenEnqueued) {
@@ -66,12 +60,22 @@ export class MarketplaceQueryService {
return this.cachedApps;
}
async findOneMarketplaceApp(
universalIdentifier: string,
): Promise<MarketplaceAppDTO> {
const registration =
await this.findRegistrationByUniversalIdentifier(universalIdentifier);
return this.toMarketplaceAppDTO(registration);
}
async findRegistrationByUniversalIdentifier(
universalIdentifier: string,
): Promise<ApplicationRegistrationEntity> {
const registration = await this.appRegistrationRepository.findOne({
where: { universalIdentifier },
});
const registration =
await this.applicationRegistrationService.findOneByUniversalIdentifier(
universalIdentifier,
);
if (!isDefined(registration)) {
throw new ApplicationRegistrationException(
@@ -83,53 +87,6 @@ export class MarketplaceQueryService {
return registration;
}
async findOrCreateNpmRegistration(params: {
packageName: string;
ownerWorkspaceId: string;
}): Promise<ApplicationRegistrationEntity> {
assertValidNpmPackageName(params.packageName);
const existing = await this.appRegistrationRepository.findOne({
where: { sourcePackage: params.packageName },
});
if (isDefined(existing)) {
return existing;
}
this.logger.log(
`Creating new registration for npm package "${params.packageName}"`,
);
try {
const registration = this.appRegistrationRepository.create({
universalIdentifier: v4(),
name: params.packageName,
sourceType: AppRegistrationSourceType.NPM,
sourcePackage: params.packageName,
oAuthClientId: v4(),
oAuthRedirectUris: [],
oAuthScopes: [],
ownerWorkspaceId: params.ownerWorkspaceId,
});
return await this.appRegistrationRepository.save(registration);
} catch {
const concurrentlyCreated = await this.appRegistrationRepository.findOne({
where: { sourcePackage: params.packageName },
});
if (isDefined(concurrentlyCreated)) {
return concurrentlyCreated;
}
throw new ApplicationRegistrationException(
`Failed to create registration for package "${params.packageName}"`,
ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND,
);
}
}
toMarketplaceAppDTO(
registration: ApplicationRegistrationEntity,
): MarketplaceAppDTO {
@@ -157,6 +114,7 @@ export class MarketplaceQueryService {
frontComponents: displayData?.frontComponents ?? [],
sourcePackage: registration.sourcePackage ?? undefined,
defaultRole: displayData?.defaultRole,
isFeatured: registration.isFeatured,
};
}
}
@@ -1,21 +1,20 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
import { ApplicationInstallModule } from 'src/engine/core-modules/application/application-install/application-install.module';
import { MarketplaceCatalogSyncCronCommand } from 'src/engine/core-modules/application/application-marketplace/crons/commands/marketplace-catalog-sync.cron.command';
import { MarketplaceCatalogSyncCronJob } from 'src/engine/core-modules/application/application-marketplace/crons/marketplace-catalog-sync.cron.job';
import { MarketplaceCatalogSyncService } from 'src/engine/core-modules/application/application-marketplace/services/marketplace-catalog-sync.service';
import { MarketplaceQueryService } from 'src/engine/core-modules/application/application-marketplace/services/marketplace-query.service';
import { MarketplaceResolver } from 'src/engine/core-modules/application/application-marketplace/resolvers/marketplace.resolver';
import { MarketplaceService } from 'src/engine/core-modules/application/application-marketplace/services/marketplace.service';
import { MarketplaceCatalogSyncService } from 'src/engine/core-modules/application/application-marketplace/marketplace-catalog-sync.service';
import { MarketplaceQueryService } from 'src/engine/core-modules/application/application-marketplace/marketplace-query.service';
import { MarketplaceResolver } from 'src/engine/core-modules/application/application-marketplace/marketplace.resolver';
import { MarketplaceService } from 'src/engine/core-modules/application/application-marketplace/marketplace.service';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@Module({
imports: [
TypeOrmModule.forFeature([ApplicationRegistrationEntity]),
ApplicationRegistrationModule,
ApplicationInstallModule,
FeatureFlagModule,
PermissionsModule,
@@ -6,15 +6,9 @@ import { FeatureFlagKey } from 'twenty-shared/types';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { ApplicationRegistrationExceptionFilter } from 'src/engine/core-modules/application/application-registration/application-registration-exception-filter';
import {
ApplicationRegistrationException,
ApplicationRegistrationExceptionCode,
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
import { AppUpgradeService } from 'src/engine/core-modules/application/application-install/app-upgrade.service';
import { MarketplaceAppDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app.dto';
import { MarketplaceQueryService } from 'src/engine/core-modules/application/application-marketplace/services/marketplace-query.service';
import { MarketplaceQueryService } from 'src/engine/core-modules/application/application-marketplace/marketplace-query.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import {
@@ -38,7 +32,6 @@ export class MarketplaceResolver {
constructor(
private readonly marketplaceQueryService: MarketplaceQueryService,
private readonly applicationInstallService: ApplicationInstallService,
private readonly appUpgradeService: AppUpgradeService,
) {}
@Query(() => [MarketplaceAppDTO])
@@ -47,6 +40,16 @@ export class MarketplaceResolver {
return this.marketplaceQueryService.findManyMarketplaceApps();
}
@Query(() => MarketplaceAppDTO)
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
async findOneMarketplaceApp(
@Args('universalIdentifier') universalIdentifier: string,
): Promise<MarketplaceAppDTO> {
return this.marketplaceQueryService.findOneMarketplaceApp(
universalIdentifier,
);
}
@Mutation(() => Boolean)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.MARKETPLACE_APPS))
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
@@ -61,54 +64,10 @@ export class MarketplaceResolver {
universalIdentifier,
);
if (registration.sourceType !== AppRegistrationSourceType.NPM) {
throw new ApplicationRegistrationException(
`Only NPM apps can be installed via the marketplace`,
ApplicationRegistrationExceptionCode.SOURCE_CHANNEL_MISMATCH,
);
}
return this.applicationInstallService.installApplication({
appRegistrationId: registration.id,
version,
workspaceId: workspace.id,
});
}
@Mutation(() => Boolean)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.MARKETPLACE_APPS))
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
async installNpmApp(
@Args('packageName') packageName: string,
@Args('version', { type: () => String, nullable: true })
version: string | undefined,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<boolean> {
const registration =
await this.marketplaceQueryService.findOrCreateNpmRegistration({
packageName,
ownerWorkspaceId: workspace.id,
});
return this.applicationInstallService.installApplication({
appRegistrationId: registration.id,
version,
workspaceId: workspace.id,
});
}
@Mutation(() => Boolean)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.MARKETPLACE_APPS))
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
async upgradeApplication(
@Args('appRegistrationId') appRegistrationId: string,
@Args('targetVersion') targetVersion: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<boolean> {
return this.appUpgradeService.upgradeApplication({
appRegistrationId,
targetVersion,
workspaceId: workspace.id,
});
}
}
@@ -80,6 +80,7 @@ export class MarketplaceService {
logicFunctions: [],
frontComponents: [],
sourcePackage: name,
isFeatured: false,
};
})
.filter(isDefined);
@@ -1,171 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { v4 } from 'uuid';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
import { MARKETPLACE_CATALOG_INDEX } from 'src/engine/core-modules/application/application-marketplace/constants/marketplace-catalog-index.constant';
import { MarketplaceService } from 'src/engine/core-modules/application/application-marketplace/services/marketplace.service';
import { getAdminWorkspaceId } from 'src/engine/core-modules/application/application-marketplace/utils/get-admin-workspace-id.util';
@Injectable()
export class MarketplaceCatalogSyncService {
private readonly logger = new Logger(MarketplaceCatalogSyncService.name);
constructor(
@InjectRepository(ApplicationRegistrationEntity)
private readonly appRegistrationRepository: Repository<ApplicationRegistrationEntity>,
private readonly marketplaceService: MarketplaceService,
) {}
async syncCatalog(): Promise<void> {
const dataSource = this.appRegistrationRepository.manager.connection;
const adminWorkspaceId = await getAdminWorkspaceId(dataSource);
if (!isDefined(adminWorkspaceId)) {
this.logger.warn(
'No admin workspace found. Skipping marketplace catalog sync.',
);
return;
}
await this.syncCuratedApps(adminWorkspaceId);
await this.syncNpmApps(adminWorkspaceId);
this.logger.log('Marketplace catalog sync completed');
}
private async syncCuratedApps(ownerWorkspaceId: string): Promise<void> {
for (const entry of MARKETPLACE_CATALOG_INDEX) {
try {
await this.upsertRegistration({
universalIdentifier: entry.universalIdentifier,
name: entry.name,
description:
entry.richDisplayData.aboutDescription ?? entry.description,
author: entry.author,
sourceType: AppRegistrationSourceType.NPM,
sourcePackage: entry.sourcePackage,
logoUrl: entry.logoUrl ?? null,
websiteUrl: entry.websiteUrl ?? null,
termsUrl: entry.termsUrl ?? null,
latestAvailableVersion: entry.richDisplayData.version ?? null,
isFeatured: entry.isFeatured,
marketplaceDisplayData: entry.richDisplayData,
ownerWorkspaceId,
});
} catch (error) {
this.logger.error(
`Failed to sync curated app "${entry.name}": ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
private async syncNpmApps(ownerWorkspaceId: string): Promise<void> {
const npmApps = await this.marketplaceService.fetchAppsFromNpmRegistry();
const curatedIdentifiers = new Set(
MARKETPLACE_CATALOG_INDEX.map((entry) => entry.universalIdentifier),
);
for (const app of npmApps) {
if (curatedIdentifiers.has(app.id)) {
continue;
}
try {
await this.upsertRegistration({
universalIdentifier: app.id,
name: app.name,
description: app.description,
author: app.author,
sourceType: AppRegistrationSourceType.NPM,
sourcePackage: app.sourcePackage ?? app.name,
logoUrl: null,
websiteUrl: app.websiteUrl ?? null,
termsUrl: null,
latestAvailableVersion: app.version ?? null,
isFeatured: false,
marketplaceDisplayData: null,
ownerWorkspaceId,
});
} catch (error) {
this.logger.error(
`Failed to sync npm app "${app.name}": ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}
// Lookup by universalIdentifier only (matches the unique constraint).
// ownerWorkspaceId is only set on insert.
private async upsertRegistration(
params: Pick<
ApplicationRegistrationEntity,
| 'universalIdentifier'
| 'name'
| 'description'
| 'author'
| 'sourceType'
| 'sourcePackage'
| 'logoUrl'
| 'websiteUrl'
| 'termsUrl'
| 'latestAvailableVersion'
| 'isFeatured'
| 'marketplaceDisplayData'
| 'ownerWorkspaceId'
>,
): Promise<void> {
const existing = await this.appRegistrationRepository.findOne({
where: {
universalIdentifier: params.universalIdentifier,
},
});
if (isDefined(existing)) {
await this.appRegistrationRepository.save({
...existing,
name: params.name,
description: params.description,
author: params.author,
sourceType: params.sourceType,
sourcePackage: params.sourcePackage,
logoUrl: params.logoUrl,
websiteUrl: params.websiteUrl,
termsUrl: params.termsUrl,
latestAvailableVersion: params.latestAvailableVersion,
isFeatured: params.isFeatured,
marketplaceDisplayData: params.marketplaceDisplayData,
});
return;
}
const registration = this.appRegistrationRepository.create({
universalIdentifier: params.universalIdentifier,
name: params.name,
description: params.description,
author: params.author,
sourceType: params.sourceType,
sourcePackage: params.sourcePackage,
logoUrl: params.logoUrl,
websiteUrl: params.websiteUrl,
termsUrl: params.termsUrl,
latestAvailableVersion: params.latestAvailableVersion,
isFeatured: params.isFeatured,
marketplaceDisplayData: params.marketplaceDisplayData,
oAuthClientId: v4(),
oAuthRedirectUris: [],
oAuthScopes: [],
ownerWorkspaceId: params.ownerWorkspaceId,
});
await this.appRegistrationRepository.save(registration);
}
}
@@ -1,36 +0,0 @@
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { type DataSource } from 'typeorm';
// Every ApplicationRegistration must be owned by a workspace (ownerWorkspaceId
// represents ownership / write-access, not visibility scoping — marketplace
// registrations are readable by all workspaces). When the catalog sync creates
// registrations for marketplace apps that no developer has explicitly claimed,
// we assign them to the "admin" workspace: the oldest active workspace whose
// owner has admin privileges.
//
// TODO: This heuristic is fragile — on fresh instances with no admin users the
// catalog sync is silently skipped, and if the admin workspace is later deleted
// all marketplace registrations become orphaned. Consider introducing a
// dedicated "platform" workspace or making ownerWorkspaceId nullable instead.
export const getAdminWorkspaceId = async (
dataSource: DataSource,
): Promise<string | null> => {
const result = await dataSource.query<Array<{ workspaceId: string }>>(
`SELECT uw."workspaceId"
FROM core."userWorkspace" uw
JOIN core."user" u ON u.id = uw."userId" AND u."deletedAt" IS NULL
JOIN core."workspace" w ON w.id = uw."workspaceId" AND w."deletedAt" IS NULL
WHERE (u."canAccessFullAdminPanel" = true OR u."canImpersonate" = true)
AND w."activationStatus" = $1
AND uw."deletedAt" IS NULL
ORDER BY w."createdAt" ASC
LIMIT 1`,
[WorkspaceActivationStatus.ACTIVE],
);
if (result.length === 0) {
return null;
}
return result[0].workspaceId;
};
@@ -2,7 +2,7 @@ import {
ALL_OAUTH_SCOPES,
OAUTH_SCOPE_DESCRIPTIONS,
OAUTH_SCOPES,
} from 'src/engine/core-modules/application/application-registration/constants/oauth-scopes';
} from 'src/engine/core-modules/application/application-oauth/constants/oauth-scopes';
describe('OAuth Scopes', () => {
it('should have all scopes defined', () => {
@@ -0,0 +1,42 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { ApplicationInstallModule } from 'src/engine/core-modules/application/application-install/application-install.module';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationModule as ApplicationCoreModule } from 'src/engine/core-modules/application/application.module';
import { ApplicationOAuthResolver } from 'src/engine/core-modules/application/application-oauth/application-oauth.resolver';
import { OAuthDiscoveryController } from 'src/engine/core-modules/application/application-oauth/controllers/oauth-discovery.controller';
import { OAuthTokenController } from 'src/engine/core-modules/application/application-oauth/controllers/oauth-token.controller';
import { OAuthService } from 'src/engine/core-modules/application/application-oauth/oauth.service';
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@Module({
imports: [
TypeOrmModule.forFeature([
AppTokenEntity,
ApplicationEntity,
UserWorkspaceEntity,
]),
ApplicationRegistrationModule,
ApplicationCoreModule,
ApplicationInstallModule,
TokenModule,
FeatureFlagModule,
PermissionsModule,
ThrottlerModule,
TwentyConfigModule,
WorkspaceCacheStorageModule,
],
controllers: [OAuthTokenController, OAuthDiscoveryController],
providers: [OAuthService, ApplicationOAuthResolver],
exports: [OAuthService],
})
export class ApplicationOAuthModule {}
@@ -0,0 +1,56 @@
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Mutation } from '@nestjs/graphql';
import { FeatureFlagKey } from 'twenty-shared/types';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { ApplicationTokenPairDTO } from 'src/engine/core-modules/application/application-oauth/dtos/application-token-pair.dto';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import {
FeatureFlagGuard,
RequireFeatureFlag,
} from 'src/engine/guards/feature-flag.guard';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@UsePipes(ResolverValidationPipe)
@MetadataResolver()
@UseFilters(AuthGraphqlApiExceptionFilter)
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard)
export class ApplicationOAuthResolver {
constructor(
private readonly applicationTokenService: ApplicationTokenService,
) {}
@Mutation(() => ApplicationTokenPairDTO)
@UseGuards(NoPermissionGuard)
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
async renewApplicationToken(
@Args('applicationRefreshToken') applicationRefreshToken: string,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<ApplicationTokenPairDTO> {
const applicationRefreshTokenPayload =
this.applicationTokenService.validateApplicationRefreshToken(
applicationRefreshToken,
);
if (applicationRefreshTokenPayload.workspaceId !== workspaceId) {
throw new ApplicationException(
'Refresh token workspace does not match authenticated workspace',
ApplicationExceptionCode.FORBIDDEN,
);
}
return this.applicationTokenService.renewApplicationTokens(
applicationRefreshTokenPayload,
);
}
}
@@ -1,6 +1,6 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application/application-registration/constants/oauth-scopes';
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application/application-oauth/constants/oauth-scopes';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
@@ -13,12 +13,12 @@ import {
import { type Request, type Response } from 'express';
import { OAuthIntrospectInput } from 'src/engine/core-modules/application/application-registration/dtos/oauth-introspect.input';
import { OAuthRevokeInput } from 'src/engine/core-modules/application/application-registration/dtos/oauth-revoke.input';
import { OAuthTokenInput } from 'src/engine/core-modules/application/application-registration/dtos/oauth-token.input';
import { OAuthService } from 'src/engine/core-modules/application/application-registration/oauth.service';
import { OAuthErrorResponse } from 'src/engine/core-modules/application/application-registration/types/oauth-error-response.type';
import { OAuthTokenResponse } from 'src/engine/core-modules/application/application-registration/types/oauth-token-response.type';
import { OAuthIntrospectInput } from 'src/engine/core-modules/application/application-oauth/dtos/oauth-introspect.input';
import { OAuthRevokeInput } from 'src/engine/core-modules/application/application-oauth/dtos/oauth-revoke.input';
import { OAuthTokenInput } from 'src/engine/core-modules/application/application-oauth/dtos/oauth-token.input';
import { OAuthService } from 'src/engine/core-modules/application/application-oauth/oauth.service';
import { OAuthErrorResponse } from 'src/engine/core-modules/application/application-oauth/types/oauth-error-response.type';
import { OAuthTokenResponse } from 'src/engine/core-modules/application/application-oauth/types/oauth-token-response.type';
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
import { ThrottlerException } from 'src/engine/core-modules/throttler/throttler.exception';
import { ThrottlerService } from 'src/engine/core-modules/throttler/throttler.service';
@@ -11,14 +11,13 @@ import {
AppTokenEntity,
AppTokenType,
} from 'src/engine/core-modules/app-token/app-token.entity';
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { OAuthErrorResponse } from 'src/engine/core-modules/application/application-registration/types/oauth-error-response.type';
import { OAuthTokenResponse } from 'src/engine/core-modules/application/application-registration/types/oauth-token-response.type';
import { OAuthErrorResponse } from 'src/engine/core-modules/application/application-oauth/types/oauth-error-response.type';
import { OAuthTokenResponse } from 'src/engine/core-modules/application/application-oauth/types/oauth-token-response.type';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
@@ -557,14 +556,7 @@ export class OAuthService {
return existingApplication;
}
this.logger.log(
`Auto-installing application "${applicationRegistration.name}" in workspace ${workspaceId}`,
);
if (
applicationRegistration.sourceType === AppRegistrationSourceType.NPM ||
applicationRegistration.sourceType === AppRegistrationSourceType.TARBALL
) {
try {
await this.applicationInstallService.installApplication({
appRegistrationId: applicationRegistration.id,
workspaceId,
@@ -584,6 +576,11 @@ export class OAuthService {
this.logger.warn(
`Install succeeded but application not found in workspace, falling back to bare creation`,
);
} catch (error) {
this.logger.warn(
`Auto-install failed for ${applicationRegistration.name}, falling back to bare creation`,
error,
);
}
return this.applicationService.create({
@@ -1,4 +1,5 @@
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { execFile } from 'child_process';
import { promises as fs } from 'fs';
@@ -7,21 +8,27 @@ import { join } from 'path';
import { promisify } from 'util';
import { type Manifest } from 'twenty-shared/application';
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type PackageJson } from 'type-fest';
import { Repository } from 'typeorm';
import { v4 } from 'uuid';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { YARN_ENGINE_DIRNAME } from 'src/engine/core-modules/application/constants/yarn-engine-dirname';
import { assertValidNpmPackageName } from 'src/engine/core-modules/application/utils/assert-valid-npm-package-name.util';
import { extractTarballSecurely } from 'src/engine/core-modules/application/utils/extract-tarball-securely.util';
import { readJsonFileOrThrow } from 'src/engine/core-modules/application/utils/read-json-file.util';
import { resolvePackageContentDir } from 'src/engine/core-modules/application/utils/tarball-utils';
import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { YARN_ENGINE_DIRNAME } from 'src/engine/core-modules/application/application-package/constants/yarn-engine-dirname';
import { assertValidNpmPackageName } from 'src/engine/core-modules/application/application-package/utils/assert-valid-npm-package-name.util';
import { extractTarballSecurely } from 'src/engine/core-modules/application/application-package/utils/extract-tarball-securely.util';
import { readJsonFileOrThrow } from 'src/engine/core-modules/application/application-package/utils/read-json-file.util';
import { resolvePackageContentDir } from 'src/engine/core-modules/application/application-package/utils/tarball-utils';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { removeFileFolderFromFileEntityPath } from 'src/engine/core-modules/file/utils/remove-file-folder-from-file-entity-path.utils';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
@@ -38,12 +45,16 @@ export type ResolvedPackage = {
};
@Injectable()
export class AppPackageFetcherService implements OnModuleInit {
private readonly logger = new Logger(AppPackageFetcherService.name);
export class ApplicationPackageFetcherService implements OnModuleInit {
private readonly logger = new Logger(ApplicationPackageFetcherService.name);
constructor(
private readonly twentyConfigService: TwentyConfigService,
private readonly fileStorageDriverFactory: FileStorageDriverFactory,
private readonly fileStorageService: FileStorageService,
@InjectRepository(FileEntity)
private readonly fileRepository: Repository<FileEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
) {}
async onModuleInit(): Promise<void> {
@@ -54,16 +65,33 @@ export class AppPackageFetcherService implements OnModuleInit {
}
}
async resolveNpmPackage(
packageName: string,
targetVersion?: string,
): Promise<ResolvedPackage> {
return this.resolveFromNpm(packageName, targetVersion);
}
async resolvePackage(
appRegistration: ApplicationRegistrationEntity,
options?: { targetVersion?: string },
): Promise<ResolvedPackage | null> {
switch (appRegistration.sourceType) {
case AppRegistrationSourceType.NPM:
return this.resolveFromNpm(appRegistration, options?.targetVersion);
case AppRegistrationSourceType.TARBALL:
case ApplicationRegistrationSourceType.NPM:
if (!appRegistration.sourcePackage) {
throw new ApplicationException(
`App registration ${appRegistration.id} has sourceType=npm but no sourcePackage`,
ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED,
);
}
return this.resolveFromNpm(
appRegistration.sourcePackage,
options?.targetVersion,
);
case ApplicationRegistrationSourceType.TARBALL:
return this.resolveFromTarball(appRegistration);
case AppRegistrationSourceType.LOCAL:
case ApplicationRegistrationSourceType.LOCAL:
return null;
}
}
@@ -77,7 +105,7 @@ export class AppPackageFetcherService implements OnModuleInit {
}
private async resolveFromNpm(
appRegistration: ApplicationRegistrationEntity,
packageName: string,
targetVersion?: string,
): Promise<ResolvedPackage> {
const workDir = join(APP_FETCHER_TMPDIR, v4());
@@ -89,30 +117,21 @@ export class AppPackageFetcherService implements OnModuleInit {
const authToken = this.twentyConfigService.get('APP_REGISTRY_TOKEN');
if (!appRegistration.sourcePackage) {
throw new ApplicationException(
`App registration ${appRegistration.id} has sourceType=npm but no sourcePackage`,
ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED,
);
}
const sourcePackage = appRegistration.sourcePackage;
assertValidNpmPackageName(sourcePackage);
assertValidNpmPackageName(packageName);
const versionSpec = targetVersion ?? 'latest';
await this.writeNpmrc({
workDir,
packageName: sourcePackage,
packageName,
registryUrl,
authToken,
});
await this.setupYarnEngine(workDir);
await this.writeMinimalPackageJson(workDir, sourcePackage, versionSpec);
await this.writeMinimalPackageJson(workDir, packageName, versionSpec);
await this.runYarnInstall(workDir);
const packageDir = join(workDir, 'node_modules', sourcePackage);
const packageDir = join(workDir, 'node_modules', packageName);
const manifest = await readJsonFileOrThrow<Manifest>(
packageDir,
'manifest.json',
@@ -131,7 +150,7 @@ export class AppPackageFetcherService implements OnModuleInit {
} catch (error) {
await this.cleanupExtractedDir(workDir);
throw new ApplicationException(
`Failed to resolve npm package ${appRegistration.sourcePackage}: ${error}`,
`Failed to resolve npm package ${packageName}: ${error}`,
ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED,
);
}
@@ -140,16 +159,33 @@ export class AppPackageFetcherService implements OnModuleInit {
private async resolveFromTarball(
appRegistration: ApplicationRegistrationEntity,
): Promise<ResolvedPackage> {
if (!isDefined(appRegistration.tarballFileId)) {
throw new ApplicationException(
`App registration ${appRegistration.id} has sourceType=tarball but no tarball file`,
ApplicationExceptionCode.TARBALL_EXTRACTION_FAILED,
);
}
const workDir = join(APP_FETCHER_TMPDIR, v4());
await fs.mkdir(workDir, { recursive: true });
try {
const storagePath = join('app-tarball', appRegistration.id, 'app.tar.gz');
const driver = this.fileStorageDriverFactory.getCurrentDriver();
const tarballStream = await driver.readFile({
filePath: storagePath,
const file = await this.fileRepository.findOneOrFail({
where: { id: appRegistration.tarballFileId },
});
const application = await this.applicationRepository.findOneOrFail({
where: { id: file.applicationId },
});
const tarballStream = await this.fileStorageService.readFile({
workspaceId: file.workspaceId,
applicationUniversalIdentifier: application.universalIdentifier,
fileFolder: FileFolder.AppTarball,
resourcePath: removeFileFolderFromFileEntityPath(file.path),
});
const tarballBuffer = await streamToBuffer(tarballStream);
const tarballPath = join(workDir, 'app.tar.gz');
@@ -273,11 +309,19 @@ export class AppPackageFetcherService implements OnModuleInit {
},
);
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
const stderr =
isDefined(error) &&
typeof error === 'object' &&
'stderr' in error &&
typeof (error as { stderr: unknown }).stderr === 'string'
? (error as { stderr: string }).stderr
: undefined;
const message =
stderr ?? (error instanceof Error ? error.message : String(error));
throw new ApplicationException(
`yarn install failed: ${errorMessage}`,
`yarn install failed: ${message}`,
ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED,
);
}
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationPackageFetcherService } from 'src/engine/core-modules/application/application-package/application-package-fetcher.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
@Module({
imports: [
FileStorageModule,
TwentyConfigModule,
TypeOrmModule.forFeature([FileEntity, ApplicationEntity]),
],
providers: [ApplicationPackageFetcherService],
exports: [ApplicationPackageFetcherService],
})
export class ApplicationPackageModule {}
@@ -6,6 +6,6 @@ export const SEED_DEPENDENCIES_DIRNAME = path.resolve(
__dirname,
path.join(
ASSET_PATH,
'engine/core-modules/application/constants/seed-dependencies',
'engine/core-modules/application/application-package/constants/seed-dependencies',
),
);
@@ -6,6 +6,6 @@ export const YARN_ENGINE_DIRNAME = path.resolve(
__dirname,
path.join(
ASSET_PATH,
'engine/core-modules/application/constants/yarn-engine',
'engine/core-modules/application/application-package/constants/yarn-engine',
),
);
@@ -3,7 +3,7 @@ import { promises as fs, statSync } from 'fs';
import { join } from 'path';
import { promisify } from 'util';
import { YARN_ENGINE_DIRNAME } from 'src/engine/core-modules/application/constants/yarn-engine-dirname';
import { YARN_ENGINE_DIRNAME } from 'src/engine/core-modules/application/application-package/constants/yarn-engine-dirname';
const execFilePromise = promisify(execFile);
@@ -1,8 +1,8 @@
import { readFile } from 'fs/promises';
import path from 'path';
import { parseAvailablePackagesFromPackageJsonAndYarnLock } from 'src/engine/core-modules/application/utils/parse-available-packages-from-package-json-and-yarn-lock.util';
import { SEED_DEPENDENCIES_DIRNAME } from 'src/engine/core-modules/application/constants/seed-dependencies-dirname';
import { parseAvailablePackagesFromPackageJsonAndYarnLock } from 'src/engine/core-modules/application/application-package/utils/parse-available-packages-from-package-json-and-yarn-lock.util';
import { SEED_DEPENDENCIES_DIRNAME } from 'src/engine/core-modules/application/application-package/constants/seed-dependencies-dirname';
// To regenerate: use logicFunctionCreateHash from logic-function-create-hash.utils.
// package.json: hash(JSON.stringify(JSON.parse(content))). yarn.lock: hash(content).
@@ -0,0 +1,20 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module';
@Module({
imports: [
TypeOrmModule.forFeature([
ApplicationRegistrationVariableEntity,
ApplicationRegistrationEntity,
]),
SecretEncryptionModule,
],
providers: [ApplicationRegistrationVariableService],
exports: [ApplicationRegistrationVariableService],
})
export class ApplicationRegistrationVariableModule {}
@@ -5,14 +5,14 @@ import { type ServerVariables } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
import { In, Not, type Repository } from 'typeorm';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration/application-registration-variable.entity';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import {
ApplicationRegistrationException,
ApplicationRegistrationExceptionCode,
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
import { type CreateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration-variable.input';
import { type UpdateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration/dtos/update-application-registration-variable.input';
import { type CreateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration-variable/dtos/create-application-registration-variable.input';
import { type UpdateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration-variable/dtos/update-application-registration-variable.input';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
@Injectable()
@@ -18,10 +18,10 @@ import {
} from 'typeorm';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration/application-registration-variable.entity';
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { type MarketplaceDisplayData } from 'src/engine/core-modules/application/application-marketplace/types/marketplace-display-data.type';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
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 { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -96,22 +96,22 @@ export class ApplicationRegistrationEntity {
@JoinColumn({ name: 'createdByUserId' })
createdByUser: Relation<UserEntity> | null;
// Represents ownership (who can edit), not visibility scoping.
// Marketplace registrations are readable by all workspaces but owned by the
// admin workspace when no developer has explicitly claimed them.
@Column({ name: 'workspaceId', nullable: false, type: 'uuid' })
ownerWorkspaceId: string;
// Ownership (who can edit). Null for catalog-synced marketplace apps that
// have no explicit owner — these are managed via the Admin Panel.
@Field(() => UUIDScalarType, { nullable: true })
@Column({ name: 'workspaceId', nullable: true, type: 'uuid' })
ownerWorkspaceId: string | null;
@ManyToOne(() => WorkspaceEntity, { onDelete: 'CASCADE' })
@ManyToOne(() => WorkspaceEntity, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'workspaceId' })
workspace: Relation<WorkspaceEntity>;
workspace: Relation<WorkspaceEntity> | null;
@Field(() => AppRegistrationSourceType)
@Field(() => ApplicationRegistrationSourceType)
@Column({
type: 'text',
default: AppRegistrationSourceType.LOCAL,
default: ApplicationRegistrationSourceType.LOCAL,
})
sourceType: AppRegistrationSourceType;
sourceType: ApplicationRegistrationSourceType;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
@@ -136,6 +136,10 @@ export class ApplicationRegistrationEntity {
@Column({ nullable: true, type: 'text' })
termsUrl: string | null;
@Field(() => Boolean)
@Column({ type: 'boolean', default: false })
isListed: boolean;
@Field(() => Boolean)
@Column({ name: 'isFeatured', type: 'boolean', default: false })
isFeatured: boolean;
@@ -1,25 +1,18 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration/application-registration-variable.entity';
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration/application-registration-variable.service';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationRegistrationResolver } from 'src/engine/core-modules/application/application-registration/application-registration.resolver';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { OAuthDiscoveryController } from 'src/engine/core-modules/application/application-registration/controllers/oauth-discovery.controller';
import { OAuthTokenController } from 'src/engine/core-modules/application/application-registration/controllers/oauth-token.controller';
import { OAuthService } from 'src/engine/core-modules/application/application-registration/oauth.service';
import { AppTarballUploadService } from 'src/engine/core-modules/application/application-registration/services/app-tarball-upload.service';
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { ApplicationRegistrationVariableModule } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.module';
import { ApplicationTarballService } from 'src/engine/core-modules/application/application-registration/application-tarball.service';
import { ApplicationPackageModule } from 'src/engine/core-modules/application/application-package/application-package.module';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { ApplicationInstallModule } from 'src/engine/core-modules/application/application-install/application-install.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module';
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { FileUrlModule } from 'src/engine/core-modules/file/file-url/file-url.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@@ -27,32 +20,26 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
imports: [
TypeOrmModule.forFeature([
ApplicationRegistrationEntity,
ApplicationRegistrationVariableEntity,
ApplicationEntity,
AppTokenEntity,
UserWorkspaceEntity,
WorkspaceEntity,
]),
SecretEncryptionModule,
ApplicationRegistrationVariableModule,
ApplicationPackageModule,
ApplicationModule,
FeatureFlagModule,
PermissionsModule,
ThrottlerModule,
TokenModule,
ApplicationModule,
ApplicationInstallModule,
FileStorageModule,
FileUrlModule,
WorkspaceCacheStorageModule,
],
controllers: [OAuthTokenController, OAuthDiscoveryController],
providers: [
ApplicationRegistrationService,
ApplicationRegistrationVariableService,
ApplicationRegistrationResolver,
AppTarballUploadService,
OAuthService,
ApplicationTarballService,
],
exports: [
ApplicationRegistrationService,
ApplicationRegistrationVariableService,
ApplicationRegistrationVariableModule,
],
})
export class ApplicationRegistrationModule {}
@@ -3,37 +3,42 @@ import { Args, Mutation, Query } from '@nestjs/graphql';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { PermissionFlagType } from 'twenty-shared/constants';
import { FeatureFlagKey } from 'twenty-shared/types';
import { FeatureFlagKey, FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service';
import { CreateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration-variable/dtos/create-application-registration-variable.input';
import { UpdateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration-variable/dtos/update-application-registration-variable.input';
import { ApplicationPackageFetcherService } from 'src/engine/core-modules/application/application-package/application-package-fetcher.service';
import { ApplicationRegistrationExceptionFilter } from 'src/engine/core-modules/application/application-registration/application-registration-exception-filter';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration/application-registration-variable.entity';
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration/application-registration-variable.service';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import {
AppTarballUploadService,
MAX_TARBALL_UPLOAD_SIZE_BYTES,
} from 'src/engine/core-modules/application/application-registration/services/app-tarball-upload.service';
import { ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-stats.dto';
import { CreateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration.input';
import { CreateApplicationRegistrationDTO } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration.dto';
import { PublicApplicationRegistrationDTO } from 'src/engine/core-modules/application/application-registration/dtos/public-application-registration.dto';
import { CreateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration-variable.input';
import { RotateClientSecretDTO } from 'src/engine/core-modules/application/application-registration/dtos/rotate-client-secret.dto';
import { UpdateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/update-application-registration.input';
import { UpdateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration/dtos/update-application-registration-variable.input';
import {
ApplicationRegistrationException,
ApplicationRegistrationExceptionCode,
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import {
ApplicationTarballService,
MAX_TARBALL_UPLOAD_SIZE_BYTES,
} from 'src/engine/core-modules/application/application-registration/application-tarball.service';
import { ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-stats.dto';
import { CreateApplicationRegistrationDTO } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration.dto';
import { CreateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration.input';
import { PublicApplicationRegistrationDTO } from 'src/engine/core-modules/application/application-registration/dtos/public-application-registration.dto';
import { RotateClientSecretDTO } from 'src/engine/core-modules/application/application-registration/dtos/rotate-client-secret.dto';
import { TransferApplicationRegistrationOwnershipInput } from 'src/engine/core-modules/application/application-registration/dtos/transfer-application-registration-ownership.input';
import { UpdateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/update-application-registration.input';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.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 { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import {
@@ -57,7 +62,9 @@ export class ApplicationRegistrationResolver {
constructor(
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
private readonly appTarballUploadService: AppTarballUploadService,
private readonly applicationTarballService: ApplicationTarballService,
private readonly applicationPackageFetcherService: ApplicationPackageFetcherService,
private readonly fileUrlService: FileUrlService,
) {}
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
@@ -255,6 +262,44 @@ export class ApplicationRegistrationResolver {
);
}
@UseGuards(
WorkspaceAuthGuard,
FeatureFlagGuard,
SettingsPermissionGuard(PermissionFlagType.MARKETPLACE_APPS),
)
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
@Mutation(() => ApplicationRegistrationEntity)
async registerNpmPackage(
@Args('packageName') packageName: string,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<ApplicationRegistrationEntity> {
const resolvedPackage =
await this.applicationPackageFetcherService.resolveNpmPackage(
packageName,
);
try {
const manifest = resolvedPackage.manifest.application;
return await this.applicationRegistrationService.createFromNpmPackage({
packageName,
universalIdentifier: manifest.universalIdentifier,
name: manifest.displayName,
description: manifest.description,
author: manifest.author,
logoUrl: manifest.logoUrl,
websiteUrl: manifest.websiteUrl,
termsUrl: manifest.termsUrl,
version: resolvedPackage.packageJson.version as string | undefined,
ownerWorkspaceId: workspaceId,
});
} finally {
await this.applicationPackageFetcherService.cleanupExtractedDir(
resolvedPackage.cleanupDir,
);
}
}
@UseGuards(
WorkspaceAuthGuard,
FeatureFlagGuard,
@@ -279,10 +324,62 @@ export class ApplicationRegistrationResolver {
);
}
return this.appTarballUploadService.uploadTarball({
return this.applicationTarballService.uploadTarball({
tarballBuffer,
universalIdentifier,
ownerWorkspaceId: workspaceId,
});
}
@UseGuards(
WorkspaceAuthGuard,
FeatureFlagGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
@Query(() => String, { nullable: true })
async applicationRegistrationTarballUrl(
@Args('id') id: string,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<string | null> {
const registration = await this.applicationRegistrationService.findOneById(
id,
workspaceId,
);
if (
registration.sourceType !== ApplicationRegistrationSourceType.TARBALL ||
!isDefined(registration.tarballFileId)
) {
return null;
}
return this.fileUrlService.signFileByIdUrl({
fileId: registration.tarballFileId,
workspaceId,
fileFolder: FileFolder.AppTarball,
});
}
@UseGuards(
WorkspaceAuthGuard,
FeatureFlagGuard,
SettingsPermissionGuard(PermissionFlagType.APPLICATIONS),
)
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
@Mutation(() => ApplicationRegistrationEntity)
async transferApplicationRegistrationOwnership(
@Args()
{
applicationRegistrationId,
targetWorkspaceSubdomain,
}: TransferApplicationRegistrationOwnershipInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<ApplicationRegistrationEntity> {
return this.applicationRegistrationService.transferOwnership({
applicationRegistrationId,
targetWorkspaceSubdomain,
currentOwnerWorkspaceId: workspaceId,
});
}
}
@@ -5,22 +5,23 @@ import crypto from 'crypto';
import * as bcrypt from 'bcrypt';
import { isDefined } from 'twenty-shared/utils';
import { type Repository } from 'typeorm';
import { IsNull, type Repository } from 'typeorm';
import { v4 } from 'uuid';
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application/application-oauth/constants/oauth-scopes';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import {
ApplicationRegistrationException,
ApplicationRegistrationExceptionCode,
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application/application-registration/constants/oauth-scopes';
import { type ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-stats.dto';
import { type CreateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration.input';
import { type PublicApplicationRegistrationDTO } from 'src/engine/core-modules/application/application-registration/dtos/public-application-registration.dto';
import { type UpdateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/update-application-registration.input';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { validateRedirectUri } from 'src/engine/core-modules/auth/utils/validate-redirect-uri.util';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
const BCRYPT_SALT_ROUNDS = 10;
@Injectable()
@@ -30,6 +31,8 @@ export class ApplicationRegistrationService {
private readonly applicationRegistrationRepository: Repository<ApplicationRegistrationEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
@InjectRepository(WorkspaceEntity)
private readonly workspaceRepository: Repository<WorkspaceEntity>,
) {}
async findMany(
@@ -41,12 +44,36 @@ export class ApplicationRegistrationService {
});
}
async findAll(): Promise<ApplicationRegistrationEntity[]> {
return this.applicationRegistrationRepository.find({
order: { createdAt: 'DESC' },
});
}
async findOneById(
id: string,
ownerWorkspaceId: string,
): Promise<ApplicationRegistrationEntity> {
const registration = await this.applicationRegistrationRepository.findOne({
where: { id, ownerWorkspaceId },
where: [
{ id, ownerWorkspaceId },
{ id, ownerWorkspaceId: IsNull() },
],
});
if (!registration) {
throw new ApplicationRegistrationException(
`Application registration with id ${id} not found`,
ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND,
);
}
return registration;
}
async findOneByIdGlobal(id: string): Promise<ApplicationRegistrationEntity> {
const registration = await this.applicationRegistrationRepository.findOne({
where: { id },
});
if (!registration) {
@@ -193,6 +220,7 @@ export class ApplicationRegistrationService {
updateData.oAuthScopes = update.oAuthScopes;
if (isDefined(update.websiteUrl)) updateData.websiteUrl = update.websiteUrl;
if (isDefined(update.termsUrl)) updateData.termsUrl = update.termsUrl;
if (isDefined(update.isListed)) updateData.isListed = update.isListed;
if (Object.keys(updateData).length > 0) {
await this.applicationRegistrationRepository.update(id, updateData);
@@ -235,6 +263,140 @@ export class ApplicationRegistrationService {
return bcrypt.compare(clientSecret, registration.oAuthClientSecretHash);
}
async upsertFromCatalog(
params: Pick<
ApplicationRegistrationEntity,
| 'universalIdentifier'
| 'name'
| 'description'
| 'author'
| 'sourceType'
| 'sourcePackage'
| 'logoUrl'
| 'websiteUrl'
| 'termsUrl'
| 'latestAvailableVersion'
| 'isListed'
| 'isFeatured'
| 'marketplaceDisplayData'
| 'ownerWorkspaceId'
>,
): Promise<void> {
const existing = await this.findOneByUniversalIdentifier(
params.universalIdentifier,
);
if (isDefined(existing)) {
await this.applicationRegistrationRepository.save({
...existing,
name: params.name,
description: params.description,
author: params.author,
sourceType: params.sourceType,
sourcePackage: params.sourcePackage,
logoUrl: params.logoUrl,
websiteUrl: params.websiteUrl,
termsUrl: params.termsUrl,
latestAvailableVersion: params.latestAvailableVersion,
marketplaceDisplayData: params.marketplaceDisplayData,
});
return;
}
const registration = this.applicationRegistrationRepository.create({
universalIdentifier: params.universalIdentifier,
name: params.name,
description: params.description,
author: params.author,
sourceType: params.sourceType,
sourcePackage: params.sourcePackage,
logoUrl: params.logoUrl,
websiteUrl: params.websiteUrl,
termsUrl: params.termsUrl,
latestAvailableVersion: params.latestAvailableVersion,
isListed: params.isListed,
isFeatured: params.isFeatured,
marketplaceDisplayData: params.marketplaceDisplayData,
oAuthClientId: v4(),
oAuthRedirectUris: [],
oAuthScopes: [],
ownerWorkspaceId: params.ownerWorkspaceId,
});
await this.applicationRegistrationRepository.save(registration);
}
async createFromNpmPackage(params: {
packageName: string;
universalIdentifier: string;
name: string;
description?: string;
author?: string;
logoUrl?: string;
websiteUrl?: string;
termsUrl?: string;
version?: string;
ownerWorkspaceId: string;
}): Promise<ApplicationRegistrationEntity> {
const existingByUid = await this.findOneByUniversalIdentifier(
params.universalIdentifier,
);
if (isDefined(existingByUid)) {
throw new ApplicationRegistrationException(
`An app with universalIdentifier "${params.universalIdentifier}" is already registered`,
ApplicationRegistrationExceptionCode.UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED,
);
}
const existingByPackage =
await this.applicationRegistrationRepository.findOne({
where: { sourcePackage: params.packageName },
});
if (isDefined(existingByPackage)) {
throw new ApplicationRegistrationException(
`Package "${params.packageName}" is already registered`,
ApplicationRegistrationExceptionCode.INVALID_INPUT,
);
}
const registration = this.applicationRegistrationRepository.create({
universalIdentifier: params.universalIdentifier,
name: params.name,
description: params.description ?? null,
author: params.author ?? null,
logoUrl: params.logoUrl ?? null,
websiteUrl: params.websiteUrl ?? null,
termsUrl: params.termsUrl ?? null,
latestAvailableVersion: params.version ?? null,
sourceType: ApplicationRegistrationSourceType.NPM,
sourcePackage: params.packageName,
isListed: true,
oAuthClientId: v4(),
oAuthRedirectUris: [],
oAuthScopes: [],
ownerWorkspaceId: params.ownerWorkspaceId,
});
return this.applicationRegistrationRepository.save(registration);
}
async findManyBySourceType(
sourceType: ApplicationRegistrationSourceType,
): Promise<ApplicationRegistrationEntity[]> {
return this.applicationRegistrationRepository.find({
where: { sourceType },
});
}
async findManyListed(): Promise<ApplicationRegistrationEntity[]> {
return this.applicationRegistrationRepository.find({
where: { isListed: true },
});
}
async getStats(
applicationRegistrationId: string,
ownerWorkspaceId: string,
@@ -269,6 +431,43 @@ export class ApplicationRegistrationService {
};
}
async transferOwnership(params: {
applicationRegistrationId: string;
targetWorkspaceSubdomain: string;
currentOwnerWorkspaceId: string;
}): Promise<ApplicationRegistrationEntity> {
const registration = await this.findOneById(
params.applicationRegistrationId,
params.currentOwnerWorkspaceId,
);
const targetWorkspace = await this.workspaceRepository.findOne({
where: { subdomain: params.targetWorkspaceSubdomain },
});
if (!isDefined(targetWorkspace)) {
throw new ApplicationRegistrationException(
`No workspace found with subdomain "${params.targetWorkspaceSubdomain}"`,
ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND,
);
}
if (targetWorkspace.id === params.currentOwnerWorkspaceId) {
throw new ApplicationRegistrationException(
'Cannot transfer ownership to the same workspace',
ApplicationRegistrationExceptionCode.INVALID_INPUT,
);
}
await this.applicationRegistrationRepository.update(registration.id, {
ownerWorkspaceId: targetWorkspace.id,
});
return this.applicationRegistrationRepository.findOneOrFail({
where: { id: registration.id },
});
}
private async generateClientSecret(): Promise<{
clientSecret: string;
clientSecretHash: string;
@@ -5,31 +5,34 @@ import { promises as fs } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { v4 } from 'uuid';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
import {
ApplicationRegistrationException,
ApplicationRegistrationExceptionCode,
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
import { extractTarballSecurely } from 'src/engine/core-modules/application/utils/extract-tarball-securely.util';
import { resolvePackageContentDir } from 'src/engine/core-modules/application/utils/tarball-utils';
import { readJsonFile } from 'src/engine/core-modules/application/utils/read-json-file.util';
import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { extractTarballSecurely } from 'src/engine/core-modules/application/application-package/utils/extract-tarball-securely.util';
import { readJsonFile } from 'src/engine/core-modules/application/application-package/utils/read-json-file.util';
import { resolvePackageContentDir } from 'src/engine/core-modules/application/application-package/utils/tarball-utils';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
export const MAX_TARBALL_UPLOAD_SIZE_BYTES = 50 * 1024 * 1024;
@Injectable()
export class AppTarballUploadService {
private readonly logger = new Logger(AppTarballUploadService.name);
export class ApplicationTarballService {
private readonly logger = new Logger(ApplicationTarballService.name);
constructor(
@InjectRepository(ApplicationRegistrationEntity)
private readonly appRegistrationRepository: Repository<ApplicationRegistrationEntity>,
private readonly fileStorageDriverFactory: FileStorageDriverFactory,
private readonly fileStorageService: FileStorageService,
private readonly applicationService: ApplicationService,
) {}
async uploadTarball(params: {
@@ -86,8 +89,10 @@ export class AppTarballUploadService {
if (isDefined(appRegistration)) {
if (
appRegistration.sourceType !== AppRegistrationSourceType.LOCAL &&
appRegistration.sourceType !== AppRegistrationSourceType.TARBALL
appRegistration.sourceType !==
ApplicationRegistrationSourceType.LOCAL &&
appRegistration.sourceType !==
ApplicationRegistrationSourceType.TARBALL
) {
throw new ApplicationRegistrationException(
`This app is registered as ${appRegistration.sourceType}. Cannot upload tarball.`,
@@ -98,7 +103,7 @@ export class AppTarballUploadService {
appRegistration = this.appRegistrationRepository.create({
universalIdentifier,
name: manifest.application?.displayName ?? 'Unknown App',
sourceType: AppRegistrationSourceType.TARBALL,
sourceType: ApplicationRegistrationSourceType.TARBALL,
oAuthClientId: v4(),
oAuthRedirectUris: [],
oAuthScopes: [],
@@ -109,18 +114,29 @@ export class AppTarballUploadService {
await this.appRegistrationRepository.save(appRegistration);
}
const storagePath = join('app-tarball', appRegistration.id);
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId: params.ownerWorkspaceId },
);
const driver = this.fileStorageDriverFactory.getCurrentDriver();
await driver.writeFile({
filePath: join(storagePath, 'app.tar.gz'),
const savedFile = await this.fileStorageService.writeFile({
sourceFile: params.tarballBuffer,
resourcePath: `${appRegistration.id}/app.tar.gz`,
mimeType: 'application/gzip',
fileFolder: FileFolder.AppTarball,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
workspaceId: params.ownerWorkspaceId,
fileId: appRegistration.tarballFileId ?? v4(),
settings: {
isTemporaryFile: false,
toDelete: false,
},
});
await this.appRegistrationRepository.update(appRegistration.id, {
sourceType: AppRegistrationSourceType.TARBALL,
sourceType: ApplicationRegistrationSourceType.TARBALL,
tarballFileId: savedFile.id,
});
this.logger.log(
@@ -3,7 +3,7 @@ import { Field, ObjectType } from '@nestjs/graphql';
import { IsOptional, IsString } from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
@ObjectType('ApplicationRegistrationSummary')
export class ApplicationRegistrationSummaryDTO {
@@ -15,6 +15,6 @@ export class ApplicationRegistrationSummaryDTO {
@Field({ nullable: true })
latestAvailableVersion?: string;
@Field(() => AppRegistrationSourceType)
sourceType: AppRegistrationSourceType;
@Field(() => ApplicationRegistrationSourceType)
sourceType: ApplicationRegistrationSourceType;
}
@@ -0,0 +1,15 @@
import { ArgsType, Field } from '@nestjs/graphql';
import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
@ArgsType()
export class TransferApplicationRegistrationOwnershipInput {
@Field(() => String)
@IsUUID()
applicationRegistrationId: string;
@Field(() => String)
@IsString()
@IsNotEmpty()
targetWorkspaceSubdomain: string;
}
@@ -4,6 +4,7 @@ import { Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsNotEmpty,
IsOptional,
IsString,
@@ -65,6 +66,11 @@ export class UpdateApplicationRegistrationPayload {
@MaxLength(2048)
@IsOptional()
termsUrl?: string;
@Field(() => Boolean, { nullable: true })
@IsBoolean()
@IsOptional()
isListed?: boolean;
}
@InputType()

Some files were not shown because too many files have changed in this diff Show More