This commit is contained in:
Félix Malfait
2026-03-09 12:26:54 +01:00
parent 0871053565
commit 6d8eb8fb6a
18 changed files with 191 additions and 230 deletions
@@ -1,7 +0,0 @@
import gql from 'graphql-tag';
export const INSTALL_APPLICATION = gql`
mutation InstallApplication($appRegistrationId: String!, $version: String) {
installApplication(appRegistrationId: $appRegistrationId, version: $version)
}
`;
@@ -1,11 +0,0 @@
import { gql } from '@apollo/client';
export const UPLOAD_APP_TARBALL = gql`
mutation UploadAppTarball($file: Upload!, $universalIdentifier: String) {
uploadAppTarball(file: $file, universalIdentifier: $universalIdentifier) {
id
universalIdentifier
name
}
}
`;
@@ -1,61 +0,0 @@
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { useMutation } from '@apollo/client';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { UPLOAD_APP_TARBALL } from '~/modules/marketplace/graphql/mutations/uploadAppTarball';
type UploadResult =
| {
success: true;
registrationId: string;
universalIdentifier: string;
}
| {
success: false;
};
export const useUploadAppTarball = () => {
const [uploadAppTarball] = useMutation(UPLOAD_APP_TARBALL);
const { enqueueErrorSnackBar } = useSnackBar();
const [isUploading, setIsUploading] = useState(false);
const upload = async (file: File): Promise<UploadResult> => {
setIsUploading(true);
try {
const result = await uploadAppTarball({
variables: { file },
});
const registration = result.data?.uploadAppTarball;
if (
!isDefined(registration?.id) ||
!isDefined(registration?.universalIdentifier)
) {
enqueueErrorSnackBar({ message: t`Upload failed.` });
return { success: false };
}
return {
success: true,
registrationId: registration.id,
universalIdentifier: registration.universalIdentifier,
};
} catch (error) {
const graphqlMessage = error instanceof Error ? error.message : undefined;
enqueueErrorSnackBar({
message: graphqlMessage ?? t`Failed to upload tarball.`,
});
return { success: false };
} finally {
setIsUploading(false);
}
};
return { upload, isUploading };
};
@@ -9,6 +9,7 @@ import { ApplicationManifestModule } from 'src/engine/core-modules/application/a
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 { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
import { ApplicationQueryResolver } from 'src/engine/core-modules/application/application-install/application-query.resolver';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@@ -23,7 +24,11 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
PermissionsModule,
FileStorageModule,
],
providers: [ApplicationInstallResolver, ApplicationInstallService],
providers: [
ApplicationInstallResolver,
ApplicationQueryResolver,
ApplicationInstallService,
],
exports: [ApplicationInstallService],
})
export class ApplicationInstallModule {}
@@ -1,15 +1,12 @@
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Mutation, Query } from '@nestjs/graphql';
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 { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
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 { 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';
@@ -27,36 +24,9 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard)
export class ApplicationInstallResolver {
constructor(
private readonly applicationService: ApplicationService,
private readonly applicationInstallService: ApplicationInstallService,
) {}
@Query(() => [ApplicationDTO])
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
async findManyApplications(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
return this.applicationService.findManyApplications(workspaceId);
}
@Query(() => ApplicationDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
async findOneApplication(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@Args('id', { type: () => UUIDScalarType, nullable: true }) id?: string,
@Args('universalIdentifier', {
type: () => UUIDScalarType,
nullable: true,
})
universalIdentifier?: string,
) {
return await this.applicationService.findOneApplicationOrThrow({
id,
universalIdentifier,
workspaceId,
});
}
@Mutation(() => Boolean)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
@@ -110,30 +110,15 @@ export class ApplicationInstallService {
'manifest.json',
);
const universalIdentifier = appRegistration.universalIdentifier;
await this.ensureApplicationExists({
universalIdentifier,
name: manifest.application.displayName,
workspaceId: params.workspaceId,
applicationRegistrationId: appRegistration.id,
sourceType: appRegistration.sourceType,
});
await this.writeFilesToStorage(
params.extractedDir,
universalIdentifier,
params.workspaceId,
);
await this.applicationSyncService.synchronizeFromManifest({
workspaceId: params.workspaceId,
await this.syncFromExtractedDirectory({
appRegistration,
extractedDir: params.extractedDir,
manifest,
applicationRegistrationId: appRegistration.id,
workspaceId: params.workspaceId,
});
this.logger.log(
`Successfully installed app ${universalIdentifier} from local directory`,
`Successfully installed app ${appRegistration.universalIdentifier} from local directory`,
);
}
@@ -154,30 +139,15 @@ export class ApplicationInstallService {
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,
universalIdentifier,
params.workspaceId,
);
await this.applicationSyncService.synchronizeFromManifest({
workspaceId: params.workspaceId,
await this.syncFromExtractedDirectory({
appRegistration,
extractedDir: resolvedPackage.extractedDir,
manifest: resolvedPackage.manifest,
applicationRegistrationId: appRegistration.id,
workspaceId: params.workspaceId,
});
this.logger.log(
`Successfully installed app ${universalIdentifier} v${resolvedPackage.packageJson.version ?? 'unknown'}`,
`Successfully installed app ${appRegistration.universalIdentifier} v${resolvedPackage.packageJson.version ?? 'unknown'}`,
);
return true;
@@ -196,6 +166,35 @@ export class ApplicationInstallService {
}
}
private async syncFromExtractedDirectory(params: {
appRegistration: ApplicationRegistrationEntity;
extractedDir: string;
manifest: Manifest;
workspaceId: string;
}): Promise<void> {
const { appRegistration, extractedDir, manifest, workspaceId } = params;
await this.ensureApplicationExists({
universalIdentifier: appRegistration.universalIdentifier,
name: manifest.application.displayName,
workspaceId,
applicationRegistrationId: appRegistration.id,
sourceType: appRegistration.sourceType,
});
await this.writeFilesToStorage(
extractedDir,
appRegistration.universalIdentifier,
workspaceId,
);
await this.applicationSyncService.synchronizeFromManifest({
workspaceId,
manifest,
applicationRegistrationId: appRegistration.id,
});
}
private async writeFilesToStorage(
extractedDir: string,
applicationUniversalIdentifier: string,
@@ -0,0 +1,51 @@
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import { Args, Query } from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
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 { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto';
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 } 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';
@UsePipes(ResolverValidationPipe)
@MetadataResolver()
@UseFilters(ApplicationExceptionFilter, AuthGraphqlApiExceptionFilter)
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard)
export class ApplicationQueryResolver {
constructor(private readonly applicationService: ApplicationService) {}
@Query(() => [ApplicationDTO])
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
async findManyApplications(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
return this.applicationService.findManyApplications(workspaceId);
}
@Query(() => ApplicationDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
async findOneApplication(
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
@Args('id', { type: () => UUIDScalarType, nullable: true }) id?: string,
@Args('universalIdentifier', {
type: () => UUIDScalarType,
nullable: true,
})
universalIdentifier?: string,
) {
return await this.applicationService.findOneApplicationOrThrow({
id,
universalIdentifier,
workspaceId,
});
}
}
@@ -246,6 +246,11 @@ export class MarketplaceAppDTO {
@Field()
aboutDescription: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
readme?: string;
@IsArray()
@Field(() => [String])
providers: string[];
@@ -1,9 +1,13 @@
import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationNpmRegistrationService } from 'src/engine/core-modules/application/application-registration/application-npm-registration.service';
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 { type MarketplaceDisplayData } from 'src/engine/core-modules/application/application-marketplace/types/marketplace-display-data.type';
import { type NpmPackument } from 'src/engine/core-modules/application/application-marketplace/types/npm-packument.type';
import { MarketplaceService } from 'src/engine/core-modules/application/application-marketplace/marketplace.service';
@Injectable()
@@ -63,6 +67,8 @@ export class MarketplaceCatalogSyncService {
}
try {
const packageName = app.sourcePackage ?? app.name;
let isProvenanceVerified = false;
let provenanceRepositoryUrl: string | null = null;
let provenanceVerifiedAt: Date | null = null;
@@ -70,7 +76,7 @@ export class MarketplaceCatalogSyncService {
if (app.version) {
const provenance =
await this.applicationNpmRegistrationService.fetchProvenanceMetadata(
app.sourcePackage ?? app.name,
packageName,
app.version,
);
@@ -81,19 +87,26 @@ export class MarketplaceCatalogSyncService {
}
}
const packument =
await this.marketplaceService.fetchPackument(packageName);
const displayData = isDefined(packument)
? this.buildDisplayDataFromPackument(packument)
: null;
await this.applicationRegistrationService.upsertFromCatalog({
universalIdentifier: app.id,
name: app.name,
description: app.description,
author: app.author,
sourceType: ApplicationRegistrationSourceType.NPM,
sourcePackage: app.sourcePackage ?? app.name,
sourcePackage: packageName,
logoUrl: null,
websiteUrl: app.websiteUrl ?? null,
termsUrl: null,
latestAvailableVersion: app.version ?? null,
isFeatured: false,
marketplaceDisplayData: null,
marketplaceDisplayData: displayData,
ownerWorkspaceId: null,
isProvenanceVerified,
provenanceRepositoryUrl,
@@ -106,4 +119,39 @@ export class MarketplaceCatalogSyncService {
}
}
}
private buildDisplayDataFromPackument(
packument: NpmPackument,
): MarketplaceDisplayData {
const repositoryUrl = this.extractRepositoryUrl(packument.repository);
return {
readme:
isDefined(packument.readme) &&
packument.readme !== 'ERROR: No README data found!'
? packument.readme
: undefined,
aboutDescription: packument.description,
providers: isDefined(repositoryUrl) ? [repositoryUrl] : [],
};
}
private extractRepositoryUrl(
repository?: { type?: string; url?: string } | string,
): string | null {
if (!isDefined(repository)) {
return null;
}
const url =
typeof repository === 'string' ? repository : repository.url ?? null;
if (!isDefined(url)) {
return null;
}
return url
.replace(/^git\+/, '')
.replace(/\.git$/, '');
}
}
@@ -105,6 +105,7 @@ export class MarketplaceQueryService {
screenshots: displayData?.screenshots ?? [],
aboutDescription:
displayData?.aboutDescription ?? registration.description ?? '',
readme: displayData?.readme,
providers: displayData?.providers ?? [],
websiteUrl: registration.websiteUrl ?? undefined,
termsUrl: registration.termsUrl ?? undefined,
@@ -5,6 +5,7 @@ import { isDefined } from 'twenty-shared/utils';
import { z } from 'zod';
import { MarketplaceAppDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app.dto';
import { type NpmPackument } from 'src/engine/core-modules/application/application-marketplace/types/npm-packument.type';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
const npmSearchResultSchema = z.object({
@@ -28,6 +29,31 @@ export class MarketplaceService {
constructor(private readonly twentyConfigService: TwentyConfigService) {}
async fetchPackument(packageName: string): Promise<NpmPackument | null> {
const registryUrl = this.twentyConfigService.get('APP_REGISTRY_URL');
try {
const { data } = await axios.get<NpmPackument>(
`${registryUrl}/${encodeURIComponent(packageName)}`,
{
headers: {
Accept: 'application/json',
'User-Agent': 'Twenty-Marketplace',
},
timeout: 10_000,
},
);
return data;
} catch (error) {
this.logger.warn(
`Failed to fetch packument for ${packageName}: ${error instanceof Error ? error.message : String(error)}`,
);
return null;
}
}
async fetchAppsFromNpmRegistry(): Promise<MarketplaceAppDTO[]> {
const registryUrl = this.twentyConfigService.get('APP_REGISTRY_URL');
@@ -8,6 +8,7 @@ export type MarketplaceDisplayData = {
logo?: string;
screenshots?: string[];
aboutDescription?: string;
readme?: string;
providers?: string[];
objects?: MarketplaceDisplayObject[];
fields?: MarketplaceDisplayField[];
@@ -0,0 +1,10 @@
// Subset of the npm registry packument response (GET /{packageName}).
// Only the fields we actually use are typed; the full response is much larger.
export type NpmPackument = {
name: string;
description?: string;
readme?: string;
license?: string;
repository?: { type?: string; url?: string } | string;
homepage?: string;
};
@@ -65,13 +65,6 @@ export class ApplicationPackageFetcherService implements OnModuleInit {
}
}
async resolveNpmPackage(
packageName: string,
targetVersion?: string,
): Promise<ResolvedPackage> {
return this.resolveFromNpm(packageName, targetVersion);
}
async resolvePackage(
appRegistration: ApplicationRegistrationEntity,
options?: { targetVersion?: string },
@@ -71,21 +71,6 @@ export class ApplicationRegistrationService {
return registration;
}
async findOneByIdGlobal(id: string): Promise<ApplicationRegistrationEntity> {
const registration = await this.applicationRegistrationRepository.findOne({
where: { id },
});
if (!registration) {
throw new ApplicationRegistrationException(
`Application registration with id ${id} not found`,
ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND,
);
}
return registration;
}
// Global lookup — used by OAuth flow (no workspace scoping)
async findOneByClientId(
clientId: string,
@@ -339,14 +324,6 @@ export class ApplicationRegistrationService {
await this.applicationRegistrationRepository.save(registration);
}
async findManyBySourceType(
sourceType: ApplicationRegistrationSourceType,
): Promise<ApplicationRegistrationEntity[]> {
return this.applicationRegistrationRepository.find({
where: { sourceType },
});
}
async findManyNpm(): Promise<ApplicationRegistrationEntity[]> {
return this.applicationRegistrationRepository.find({
where: { sourceType: ApplicationRegistrationSourceType.NPM },
@@ -4,8 +4,6 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { WorkspaceFlatApplicationMapCacheService } from 'src/engine/core-modules/application/workspace-flat-application-map-cache.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 { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@@ -15,8 +13,6 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
TypeOrmModule.forFeature([ApplicationEntity, WorkspaceEntity]),
WorkspaceManyOrAllFlatEntityMapsCacheModule,
WorkspaceCacheModule,
TwentyConfigModule,
FeatureFlagModule,
],
exports: [ApplicationService, WorkspaceFlatApplicationMapCacheService],
providers: [ApplicationService, WorkspaceFlatApplicationMapCacheService],
@@ -30,24 +30,6 @@ export class ApplicationService {
private readonly workspaceRepository: Repository<WorkspaceEntity>,
) {}
async findApplicationRoleId(
applicationId: string,
workspaceId: string,
): Promise<string> {
const application = await this.applicationRepository.findOne({
where: { id: applicationId, workspaceId },
});
if (!isDefined(application) || !isDefined(application.defaultRoleId)) {
throw new ApplicationException(
`Could not find application ${applicationId}`,
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
);
}
return application.defaultRoleId;
}
async findWorkspaceTwentyStandardAndCustomApplicationOrThrow({
workspace: workspaceInput,
workspaceId,
@@ -1,24 +0,0 @@
import { type AllMetadataName } from 'twenty-shared/metadata';
export const APPLICATION_MANIFEST_METADATA_NAMES = [
'objectMetadata',
'fieldMetadata',
'logicFunction',
'frontComponent',
'role',
'skill',
'view',
'viewField',
'viewFieldGroup',
'viewFilter',
'viewFilterGroup',
'viewGroup',
'navigationMenuItem',
'pageLayout',
'pageLayoutTab',
'pageLayoutWidget',
'commandMenuItem',
] as const satisfies AllMetadataName[];
export type ApplicationManifestMetadataName =
(typeof APPLICATION_MANIFEST_METADATA_NAMES)[number];