Provide applicatiion assets (#18973)

- improve backend
- improve frontend

<img width="1293" height="824" alt="image"
src="https://github.com/user-attachments/assets/7a4633f1-85cd-4126-b058-dbeae6ba2218"
/>
This commit is contained in:
martmull
2026-03-30 10:53:31 +02:00
committed by GitHub
parent 58189e1c05
commit fe1377f18b
74 changed files with 2367 additions and 2558 deletions
@@ -24,6 +24,7 @@ import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service';
import { ApplicationTokenPairDTO } from 'src/engine/core-modules/application/application-oauth/dtos/application-token-pair.dto';
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service';
import { resolveManifestAssetUrls } from 'src/engine/core-modules/application/application-marketplace/utils/resolve-manifest-asset-urls.util';
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 {
@@ -36,6 +37,7 @@ import { FileStorageService } from 'src/engine/core-modules/file-storage/file-st
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { SdkClientGenerationService } from 'src/engine/core-modules/sdk-client/sdk-client-generation.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
@@ -64,6 +66,7 @@ export class ApplicationDevelopmentResolver {
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
private readonly fileStorageService: FileStorageService,
private readonly sdkClientGenerationService: SdkClientGenerationService,
private readonly twentyConfigService: TwentyConfigService,
) {}
@Mutation(() => DevelopmentApplicationDTO)
@@ -71,10 +74,8 @@ export class ApplicationDevelopmentResolver {
@Args() { universalIdentifier, name }: CreateDevelopmentApplicationInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<DevelopmentApplicationDTO> {
const applicationRegistrationId = await this.findApplicationRegistrationId(
universalIdentifier,
workspaceId,
);
const applicationRegistrationId =
await this.findApplicationRegistrationId(universalIdentifier);
const existing = await this.applicationService.findByUniversalIdentifier({
universalIdentifier,
@@ -125,7 +126,6 @@ export class ApplicationDevelopmentResolver {
): Promise<WorkspaceMigrationDTO> {
const applicationRegistrationId = await this.findApplicationRegistrationId(
manifest.application.universalIdentifier,
workspaceId,
);
const application = await this.applicationService.findByUniversalIdentifier(
@@ -164,6 +164,7 @@ export class ApplicationDevelopmentResolver {
applicationRegistrationId,
manifest,
workspaceId,
application.id,
);
return {
@@ -216,7 +217,6 @@ export class ApplicationDevelopmentResolver {
private async findApplicationRegistrationId(
universalIdentifier: string,
workspaceId: string,
): Promise<string> {
const existingRegistration =
await this.applicationRegistrationService.findOneByUniversalIdentifier(
@@ -230,55 +230,33 @@ export class ApplicationDevelopmentResolver {
);
}
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;
}
private async syncRegistrationMetadata(
applicationRegistrationId: string,
manifest: { application: ApplicationInput['manifest']['application'] },
manifest: ApplicationInput['manifest'],
workspaceId: string,
applicationId: string,
): Promise<void> {
const isOwner =
await this.applicationRegistrationService.isOwnedByWorkspace(
const serverUrl = this.twentyConfigService.get('SERVER_URL');
const manifestWithResolvedUrls = resolveManifestAssetUrls(
manifest,
(filePath) =>
`${serverUrl}/public-assets/${workspaceId}/${applicationId}/${filePath}`,
);
await this.applicationRegistrationService.updateFromManifest(
applicationRegistrationId,
manifestWithResolvedUrls,
);
if (manifest.application.serverVariables) {
await this.applicationRegistrationVariableService.syncVariableSchemas(
applicationRegistrationId,
workspaceId,
manifest.application.serverVariables,
);
if (isOwner) {
await this.applicationRegistrationService.update(
{
id: applicationRegistrationId,
update: {
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,
);
if (manifest.application.serverVariables) {
await this.applicationRegistrationVariableService.syncVariableSchemas(
applicationRegistrationId,
manifest.application.serverVariables,
);
}
}
}
}
@@ -1,32 +1,22 @@
import { type MarketplaceDisplayData } from 'src/engine/core-modules/application/application-marketplace/types/marketplace-display-data.type';
export type CuratedAppEntry = {
universalIdentifier: string;
sourcePackage: string;
isFeatured: boolean;
name: string;
description: string;
author: string;
logoUrl?: string;
websiteUrl?: string;
termsUrl?: string;
richDisplayData: MarketplaceDisplayData;
latestAvailableVersion?: string;
};
const MOCK_ENRICHMENT_APP_ID = 'a1b2c3d4-0000-0000-0000-000000000001';
const MOCK_ENRICHMENT_JOB_ID = 'a1b2c3d4-0000-0000-0000-000000000100';
const COMPANY_UNIVERSAL_ID = '20202020-b374-4779-a561-80086cb2e17f';
const PERSON_UNIVERSAL_ID = '20202020-e674-48e5-a542-72570eee7213';
const MOCK_LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="#1a2744"><ellipse cx="38" cy="20" rx="28" ry="10"/><rect x="10" y="20" width="56" height="50"/><ellipse cx="38" cy="70" rx="28" ry="10"/><ellipse cx="38" cy="35" rx="28" ry="10" fill="none" stroke="#fff" stroke-width="3"/><ellipse cx="38" cy="52" rx="28" ry="10" fill="none" stroke="#fff" stroke-width="3"/><circle cx="72" cy="62" r="22" fill="#1a2744"/><circle cx="72" cy="62" r="18" fill="#fff"/><path d="M72 50 L72 74 M62 58 L72 48 L82 58" stroke="#1a2744" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>`;
const ENCODED_MOCK_LOGO = `data:image/svg+xml,${encodeURIComponent(MOCK_LOGO_SVG)}`;
export const MARKETPLACE_CATALOG_INDEX: CuratedAppEntry[] = [
{
universalIdentifier: MOCK_ENRICHMENT_APP_ID,
universalIdentifier: 'a1b2c3d4-0000-0000-0000-000000000001',
sourcePackage: '@twentyhq/app-data-enrichment',
isFeatured: true,
name: 'Data Enrichment',
@@ -34,146 +24,6 @@ export const MARKETPLACE_CATALOG_INDEX: CuratedAppEntry[] = [
author: 'Twenty',
logoUrl: ENCODED_MOCK_LOGO,
websiteUrl: 'https://twenty.com',
richDisplayData: {
icon: 'IconSparkles',
version: '1.0.0',
category: 'Data',
logo: ENCODED_MOCK_LOGO,
screenshots: [
'https://placehold.co/800x400/f5f5f5/666?text=Screenshot+1',
'https://placehold.co/800x400/f5f5f5/666?text=Screenshot+2',
'https://placehold.co/800x400/f5f5f5/666?text=Screenshot+3',
],
aboutDescription:
'Enhance your workspace with automated data intelligence. This app monitors your new records and automatically populates missing details such as job titles, company size, social profiles, and industry insights.',
providers: ['Clearbit', 'Apollo', 'Hunter.io'],
objects: [
{
universalIdentifier: MOCK_ENRICHMENT_JOB_ID,
nameSingular: 'enrichmentJob',
namePlural: 'enrichmentJobs',
labelSingular: 'Enrichment Job',
labelPlural: 'Enrichment Jobs',
description: 'Tracks data enrichment requests and their status',
icon: 'IconSparkles',
fields: [
{
name: 'status',
type: 'SELECT',
label: 'Status',
description: 'Current status of the enrichment job',
icon: 'IconProgressCheck',
universalIdentifier: 'a1b2c3d4-0000-0000-0000-000000000101',
objectUniversalIdentifier: MOCK_ENRICHMENT_JOB_ID,
},
{
name: 'provider',
type: 'TEXT',
label: 'Provider',
description: 'Enrichment provider used',
icon: 'IconCloud',
universalIdentifier: 'a1b2c3d4-0000-0000-0000-000000000102',
objectUniversalIdentifier: MOCK_ENRICHMENT_JOB_ID,
},
],
},
],
fields: [
{
name: 'industry',
type: 'TEXT',
label: 'Industry',
description: 'Company industry from enrichment',
icon: 'IconBuildingFactory2',
objectUniversalIdentifier: COMPANY_UNIVERSAL_ID,
universalIdentifier: 'a1b2c3d4-0000-0000-0000-000000000201',
},
{
name: 'linkedInUrl',
type: 'LINKS',
label: 'LinkedIn URL',
description: 'LinkedIn profile URL from enrichment',
icon: 'IconBrandLinkedin',
objectUniversalIdentifier: PERSON_UNIVERSAL_ID,
universalIdentifier: 'a1b2c3d4-0000-0000-0000-000000000203',
},
],
logicFunctions: [
{
name: 'enrich-on-create',
description:
'Automatically enriches new records when they are created',
timeoutSeconds: 30,
},
],
frontComponents: [],
defaultRole: {
id: 'a1b2c3d4-0000-0000-0000-000000000010',
label: 'Data Enrichment default role',
description: 'Default permissions for the Data Enrichment app',
canReadAllObjectRecords: true,
canUpdateAllObjectRecords: true,
canSoftDeleteAllObjectRecords: true,
canDestroyAllObjectRecords: true,
canUpdateAllSettings: false,
canAccessAllTools: false,
objectPermissions: [
{
objectUniversalIdentifier: COMPANY_UNIVERSAL_ID,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
objectUniversalIdentifier: PERSON_UNIVERSAL_ID,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: false,
},
],
fieldPermissions: [],
permissionFlags: ['DATA_MODEL', 'API_KEYS_AND_WEBHOOKS'],
},
},
},
{
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
sourcePackage: '@twentyhq/hello-world',
isFeatured: false,
name: 'Hello World',
description: 'A simple hello world app to get started with Twenty apps.',
author: 'Twenty',
websiteUrl: 'https://twenty.com',
richDisplayData: {
icon: 'IconWorld',
version: '0.2.2',
category: 'Getting Started',
screenshots: [],
aboutDescription:
'A minimal example app that demonstrates the Twenty app framework. Creates a PostCard object and a logic function to generate new postcards. Great starting point for building your own apps.',
providers: [],
objects: [
{
universalIdentifier: 'e2c3d4f5-0000-0000-0000-000000000001',
nameSingular: 'postCard',
namePlural: 'postCards',
labelSingular: 'Post Card',
labelPlural: 'Post Cards',
description: 'A simple postcard object',
icon: 'IconMail',
fields: [],
},
],
fields: [],
logicFunctions: [
{
name: 'create-new-post-card',
description: 'Creates a new postcard record',
},
],
frontComponents: [],
},
latestAvailableVersion: '1.0.0',
},
];
@@ -0,0 +1,19 @@
import { Command, CommandRunner } from 'nest-commander';
import { MarketplaceCatalogSyncService } from 'src/engine/core-modules/application/application-marketplace/marketplace-catalog-sync.service';
@Command({
name: 'marketplace:catalog-sync',
description: 'Sync the marketplace catalog into ApplicationRegistration',
})
export class MarketplaceCatalogSyncCommand extends CommandRunner {
constructor(
private readonly marketplaceCatalogSyncService: MarketplaceCatalogSyncService,
) {
super();
}
async run(): Promise<void> {
await this.marketplaceCatalogSyncService.syncCatalog();
}
}
@@ -0,0 +1,49 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { GraphQLJSON } from 'graphql-type-json';
import { type Manifest } from 'twenty-shared/application';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
@ObjectType('MarketplaceAppDetail')
export class MarketplaceAppDetailDTO {
@IsString()
@IsNotEmpty()
@Field()
universalIdentifier: string;
@IsString()
@IsNotEmpty()
@Field()
id: string;
@IsString()
@IsNotEmpty()
@Field()
name: string;
@Field(() => ApplicationRegistrationSourceType)
sourceType: ApplicationRegistrationSourceType;
@IsOptional()
@IsString()
@Field({ nullable: true })
sourcePackage?: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
latestAvailableVersion?: string;
@IsBoolean()
@Field(() => Boolean)
isListed: boolean;
@IsBoolean()
@Field(() => Boolean)
isFeatured: boolean;
@Field(() => GraphQLJSON, { nullable: true })
manifest?: Manifest;
}
@@ -1,204 +1,12 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { Field, ObjectType } from '@nestjs/graphql';
import {
IsArray,
IsBoolean,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
MaxLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
@ObjectType('MarketplaceAppField')
export class MarketplaceAppFieldDTO {
@IsString()
@Field()
name: string;
@IsString()
@Field()
type: string;
@IsString()
@Field()
label: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
description?: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
icon?: string;
@IsString()
@Field({ nullable: true })
objectUniversalIdentifier: string;
@IsString()
@Field({ nullable: true })
universalIdentifier: string;
}
@ObjectType('MarketplaceAppObject')
export class MarketplaceAppObjectDTO {
@IsString()
@Field()
universalIdentifier: string;
@IsString()
@Field()
nameSingular: string;
@IsString()
@Field()
namePlural: string;
@IsString()
@Field()
labelSingular: string;
@IsString()
@Field()
labelPlural: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
description?: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
icon?: string;
@IsArray()
@Field(() => [MarketplaceAppFieldDTO])
fields: MarketplaceAppFieldDTO[];
}
@ObjectType('MarketplaceAppLogicFunction')
export class MarketplaceAppLogicFunctionDTO {
@IsString()
@Field()
name: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
description?: string;
@IsOptional()
@IsNumber()
@Field(() => Int, { nullable: true })
timeoutSeconds?: number;
}
@ObjectType('MarketplaceAppFrontComponent')
export class MarketplaceAppFrontComponentDTO {
@IsString()
@Field()
name: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
description?: string;
}
@ObjectType('MarketplaceAppRoleObjectPermission')
export class MarketplaceAppRoleObjectPermissionDTO {
@IsString()
@Field()
objectUniversalIdentifier: string;
@IsOptional()
@Field(() => Boolean, { nullable: true })
canReadObjectRecords?: boolean;
@IsOptional()
@Field(() => Boolean, { nullable: true })
canUpdateObjectRecords?: boolean;
@IsOptional()
@Field(() => Boolean, { nullable: true })
canSoftDeleteObjectRecords?: boolean;
@IsOptional()
@Field(() => Boolean, { nullable: true })
canDestroyObjectRecords?: boolean;
}
@ObjectType('MarketplaceAppRoleFieldPermission')
export class MarketplaceAppRoleFieldPermissionDTO {
@IsString()
@Field()
objectUniversalIdentifier: string;
@IsString()
@Field()
fieldUniversalIdentifier: string;
@IsOptional()
@Field(() => Boolean, { nullable: true })
canReadFieldValue?: boolean;
@IsOptional()
@Field(() => Boolean, { nullable: true })
canUpdateFieldValue?: boolean;
}
@ObjectType('MarketplaceAppDefaultRole')
export class MarketplaceAppDefaultRoleDTO {
@IsString()
@IsNotEmpty()
@Field()
id: string;
@IsString()
@Field()
label: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
description?: string;
@Field(() => Boolean)
canReadAllObjectRecords: boolean;
@Field(() => Boolean)
canUpdateAllObjectRecords: boolean;
@Field(() => Boolean)
canSoftDeleteAllObjectRecords: boolean;
@Field(() => Boolean)
canDestroyAllObjectRecords: boolean;
@Field(() => Boolean)
canUpdateAllSettings: boolean;
@Field(() => Boolean)
canAccessAllTools: boolean;
@IsArray()
@Field(() => [MarketplaceAppRoleObjectPermissionDTO])
objectPermissions: MarketplaceAppRoleObjectPermissionDTO[];
@IsArray()
@Field(() => [MarketplaceAppRoleFieldPermissionDTO])
fieldPermissions: MarketplaceAppRoleFieldPermissionDTO[];
@IsArray()
@Field(() => [String])
permissionFlags: string[];
}
@ObjectType('MarketplaceApp')
export class MarketplaceAppDTO {
@@ -221,10 +29,6 @@ export class MarketplaceAppDTO {
@Field()
icon: string;
@IsString()
@Field()
version: string;
@IsString()
@Field()
author: string;
@@ -238,50 +42,6 @@ export class MarketplaceAppDTO {
@Field({ nullable: true })
logo?: string;
@IsArray()
@Field(() => [String])
screenshots: string[];
@IsString()
@Field()
aboutDescription: string;
@IsArray()
@Field(() => [String])
providers: string[];
@IsOptional()
@IsString()
@Field({ nullable: true })
websiteUrl?: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
termsUrl?: string;
@IsArray()
@Field(() => [MarketplaceAppObjectDTO])
objects: MarketplaceAppObjectDTO[];
@IsArray()
@Field(() => [MarketplaceAppFieldDTO])
fields: MarketplaceAppFieldDTO[];
@IsArray()
@Field(() => [MarketplaceAppLogicFunctionDTO])
logicFunctions: MarketplaceAppLogicFunctionDTO[];
@IsArray()
@Field(() => [MarketplaceAppFrontComponentDTO])
frontComponents: MarketplaceAppFrontComponentDTO[];
@IsOptional()
@ValidateNested()
@Type(() => MarketplaceAppDefaultRoleDTO)
@Field(() => MarketplaceAppDefaultRoleDTO, { nullable: true })
defaultRole?: MarketplaceAppDefaultRoleDTO;
@IsOptional()
@IsString()
@Field({ nullable: true })
@@ -4,6 +4,9 @@ import { ApplicationRegistrationService } from 'src/engine/core-modules/applicat
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';
import { buildRegistryCdnUrl } from 'src/engine/core-modules/application/application-marketplace/utils/build-registry-cdn-url.util';
import { resolveManifestAssetUrls } from 'src/engine/core-modules/application/application-marketplace/utils/resolve-manifest-asset-urls.util';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
export class MarketplaceCatalogSyncService {
@@ -12,11 +15,12 @@ export class MarketplaceCatalogSyncService {
constructor(
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly marketplaceService: MarketplaceService,
private readonly twentyConfigService: TwentyConfigService,
) {}
async syncCatalog(): Promise<void> {
await this.syncCuratedApps();
await this.syncNpmApps();
await this.syncRegistryApps();
this.logger.log('Marketplace catalog sync completed');
}
@@ -27,18 +31,12 @@ export class MarketplaceCatalogSyncService {
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,
latestAvailableVersion: entry.latestAvailableVersion ?? null,
isListed: true,
isFeatured: entry.isFeatured,
marketplaceDisplayData: entry.richDisplayData,
manifest: null,
ownerWorkspaceId: null,
});
} catch (error) {
@@ -49,38 +47,59 @@ export class MarketplaceCatalogSyncService {
}
}
private async syncNpmApps(): Promise<void> {
const npmApps = await this.marketplaceService.fetchAppsFromNpmRegistry();
private async syncRegistryApps(): Promise<void> {
const packages = await this.marketplaceService.fetchAppsFromRegistry();
const curatedIdentifiers = new Set(
MARKETPLACE_CATALOG_INDEX.map((entry) => entry.universalIdentifier),
);
for (const app of npmApps) {
if (curatedIdentifiers.has(app.id)) {
continue;
}
for (const pkg of packages) {
try {
const manifest =
await this.marketplaceService.fetchManifestFromRegistryCdn(
pkg.name,
pkg.version,
);
if (!manifest) {
this.logger.debug(`Skipping ${pkg.name}: no manifest found on CDN`);
continue;
}
const universalIdentifier = manifest.application.universalIdentifier;
if (curatedIdentifiers.has(universalIdentifier)) {
continue;
}
const cdnBaseUrl = this.twentyConfigService.get('APP_REGISTRY_CDN_URL');
const manifestWithResolvedUrls = resolveManifestAssetUrls(
manifest,
(filePath) =>
buildRegistryCdnUrl({
cdnBaseUrl,
packageName: pkg.name,
version: pkg.version,
filePath,
}),
);
await this.applicationRegistrationService.upsertFromCatalog({
universalIdentifier: app.id,
name: app.name,
description: app.description,
author: app.author,
universalIdentifier,
name: manifest.application.displayName ?? pkg.name,
sourceType: ApplicationRegistrationSourceType.NPM,
sourcePackage: app.sourcePackage ?? app.name,
logoUrl: null,
websiteUrl: app.websiteUrl ?? null,
termsUrl: null,
latestAvailableVersion: app.version ?? null,
sourcePackage: pkg.name,
latestAvailableVersion: pkg.version ?? null,
isListed: true,
isFeatured: false,
marketplaceDisplayData: null,
manifest: manifestWithResolvedUrls,
ownerWorkspaceId: null,
});
} catch (error) {
this.logger.error(
`Failed to sync npm app "${app.name}": ${error instanceof Error ? error.message : String(error)}`,
`Failed to sync registry app "${pkg.name}": ${error instanceof Error ? error.message : String(error)}`,
);
}
}
@@ -10,17 +10,14 @@ import {
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 { MarketplaceAppDetailDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app-detail.dto';
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';
const MARKETPLACE_CACHE_TTL_MS = 5 * 60 * 1000;
@Injectable()
export class MarketplaceQueryService {
private readonly logger = new Logger(MarketplaceQueryService.name);
private cachedApps: MarketplaceAppDTO[] | null = null;
private cacheExpiresAt = 0;
private hasSyncBeenEnqueued = false;
constructor(
@@ -30,10 +27,6 @@ export class MarketplaceQueryService {
) {}
async findManyMarketplaceApps(): Promise<MarketplaceAppDTO[]> {
if (this.cachedApps !== null && Date.now() < this.cacheExpiresAt) {
return this.cachedApps;
}
const registrations =
await this.applicationRegistrationService.findManyListed();
@@ -46,27 +39,25 @@ export class MarketplaceQueryService {
await this.messageQueueService.add(
MarketplaceCatalogSyncCronJob.name,
{},
{ id: 'marketplace-catalog-sync' }, // Avoids triggering multiple pending jobs
);
}
return [];
}
this.cachedApps = registrations.map((registration) =>
return registrations.map((registration) =>
this.toMarketplaceAppDTO(registration),
);
this.cacheExpiresAt = Date.now() + MARKETPLACE_CACHE_TTL_MS;
return this.cachedApps;
}
async findOneMarketplaceApp(
async findMarketplaceAppDetail(
universalIdentifier: string,
): Promise<MarketplaceAppDTO> {
): Promise<MarketplaceAppDetailDTO> {
const registration =
await this.findRegistrationByUniversalIdentifier(universalIdentifier);
return this.toMarketplaceAppDTO(registration);
return this.toMarketplaceAppDetailDTO(registration);
}
async findRegistrationByUniversalIdentifier(
@@ -87,34 +78,37 @@ export class MarketplaceQueryService {
return registration;
}
toMarketplaceAppDTO(
private toMarketplaceAppDTO(
registration: ApplicationRegistrationEntity,
): MarketplaceAppDTO {
const displayData = registration.marketplaceDisplayData;
const app = registration.manifest?.application;
return {
id: registration.universalIdentifier,
name: registration.name,
description: registration.description ?? '',
icon: displayData?.icon ?? 'IconApps',
version:
displayData?.version ?? registration.latestAvailableVersion ?? '0.0.0',
author: registration.author ?? 'Unknown',
category: displayData?.category ?? '',
logo: displayData?.logo,
screenshots: displayData?.screenshots ?? [],
aboutDescription:
displayData?.aboutDescription ?? registration.description ?? '',
providers: displayData?.providers ?? [],
websiteUrl: registration.websiteUrl ?? undefined,
termsUrl: registration.termsUrl ?? undefined,
objects: displayData?.objects ?? [],
fields: displayData?.fields ?? [],
logicFunctions: displayData?.logicFunctions ?? [],
frontComponents: displayData?.frontComponents ?? [],
name: app?.displayName ?? registration.name,
description: app?.description ?? '',
icon: app?.icon ?? 'IconApps',
author: app?.author ?? 'Unknown',
category: app?.category ?? '',
logo: app?.logoUrl ?? undefined,
sourcePackage: registration.sourcePackage ?? undefined,
defaultRole: displayData?.defaultRole,
isFeatured: registration.isFeatured,
};
}
private toMarketplaceAppDetailDTO(
registration: ApplicationRegistrationEntity,
): MarketplaceAppDetailDTO {
return {
id: registration.id,
universalIdentifier: registration.universalIdentifier,
name: registration.name,
sourceType: registration.sourceType,
sourcePackage: registration.sourcePackage ?? undefined,
latestAvailableVersion: registration.latestAvailableVersion ?? undefined,
isListed: registration.isListed,
isFeatured: registration.isFeatured,
manifest: registration.manifest ?? undefined,
};
}
}
@@ -11,6 +11,7 @@ import { MarketplaceService } from 'src/engine/core-modules/application/applicat
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';
import { MarketplaceCatalogSyncCommand } from 'src/engine/core-modules/application/application-marketplace/crons/commands/marketplace-catalog-sync.command';
@Module({
imports: [
@@ -26,6 +27,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
MarketplaceQueryService,
MarketplaceCatalogSyncCronJob,
MarketplaceCatalogSyncCronCommand,
MarketplaceCatalogSyncCommand,
MarketplaceResolver,
],
exports: [
@@ -6,6 +6,7 @@ import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorato
import { ApplicationRegistrationExceptionFilter } from 'src/engine/core-modules/application/application-registration/application-registration-exception-filter';
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
import { MarketplaceAppDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app.dto';
import { MarketplaceAppDetailDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app-detail.dto';
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';
@@ -13,6 +14,10 @@ import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { MarketplaceCatalogSyncCronJob } from 'src/engine/core-modules/application/application-marketplace/crons/marketplace-catalog-sync.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';
@MetadataResolver()
@UseFilters(ApplicationRegistrationExceptionFilter)
@@ -21,6 +26,8 @@ export class MarketplaceResolver {
constructor(
private readonly marketplaceQueryService: MarketplaceQueryService,
private readonly applicationInstallService: ApplicationInstallService,
@InjectMessageQueue(MessageQueue.cronQueue)
private readonly messageQueueService: MessageQueueService,
) {}
@Query(() => [MarketplaceAppDTO])
@@ -28,11 +35,11 @@ export class MarketplaceResolver {
return this.marketplaceQueryService.findManyMarketplaceApps();
}
@Query(() => MarketplaceAppDTO)
async findOneMarketplaceApp(
@Query(() => MarketplaceAppDetailDTO)
async findMarketplaceAppDetail(
@Args('universalIdentifier') universalIdentifier: string,
): Promise<MarketplaceAppDTO> {
return this.marketplaceQueryService.findOneMarketplaceApp(
): Promise<MarketplaceAppDetailDTO> {
return this.marketplaceQueryService.findMarketplaceAppDetail(
universalIdentifier,
);
}
@@ -56,4 +63,16 @@ export class MarketplaceResolver {
workspaceId: workspace.id,
});
}
@Mutation(() => Boolean)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.MARKETPLACE_APPS))
async syncMarketplaceCatalog(): Promise<boolean> {
await this.messageQueueService.add(
MarketplaceCatalogSyncCronJob.name,
{},
{ id: 'marketplace-catalog-sync' }, // Avoids triggering multiple pending jobs
);
return true;
}
}
@@ -1,13 +1,21 @@
import { Injectable, Logger } from '@nestjs/common';
import axios from 'axios';
import { isDefined } from 'twenty-shared/utils';
import { type Manifest } from 'twenty-shared/application';
import { z } from 'zod';
import { MarketplaceAppDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app.dto';
import { buildRegistryCdnUrl } from 'src/engine/core-modules/application/application-marketplace/utils/build-registry-cdn-url.util';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
const npmSearchResultSchema = z.object({
export type RegistryPackageInfo = {
name: string;
version: string;
description: string;
author: string;
websiteUrl?: string;
};
const registrySearchResultSchema = z.object({
objects: z.array(
z.object({
package: z.object({
@@ -16,7 +24,12 @@ const npmSearchResultSchema = z.object({
description: z.string().optional(),
keywords: z.array(z.string()).optional(),
author: z.object({ name: z.string().optional() }).optional(),
links: z.object({ homepage: z.string().optional() }).optional(),
links: z
.object({
homepage: z.string().optional(),
npm: z.string().optional(),
})
.optional(),
}),
}),
),
@@ -28,7 +41,39 @@ export class MarketplaceService {
constructor(private readonly twentyConfigService: TwentyConfigService) {}
async fetchAppsFromNpmRegistry(): Promise<MarketplaceAppDTO[]> {
async fetchManifestFromRegistryCdn(
packageName: string,
version: string,
): Promise<Manifest | null> {
const cdnBaseUrl = this.twentyConfigService.get('APP_REGISTRY_CDN_URL');
const url = buildRegistryCdnUrl({
cdnBaseUrl,
packageName,
version,
filePath: 'manifest.json',
});
try {
const { data } = await axios.get(url, {
headers: { 'User-Agent': 'Twenty-Marketplace' },
timeout: 5_000,
});
if (!data?.application) {
return null;
}
return data as Manifest;
} catch {
this.logger.debug(
`Could not fetch manifest from CDN for ${packageName}@${version}`,
);
return null;
}
}
async fetchAppsFromRegistry(): Promise<RegistryPackageInfo[]> {
const registryUrl = this.twentyConfigService.get('APP_REGISTRY_URL');
try {
@@ -40,53 +85,30 @@ export class MarketplaceService {
},
);
const parsed = npmSearchResultSchema.safeParse(data);
const parsed = registrySearchResultSchema.safeParse(data);
if (!parsed.success) {
this.logger.warn(
`Unexpected npm search response shape: ${parsed.error.message}`,
`Unexpected registry search response shape: ${parsed.error.message}`,
);
return [];
}
return parsed.data.objects
.map((result) => {
const { name, version, description, author, links } = result.package;
const twentyKeyword = (result.package.keywords ?? []).find(
(keyword) => keyword.startsWith('twenty-uid:'),
);
return parsed.data.objects.map((result) => {
const { name, version, description, author, links } = result.package;
if (!isDefined(twentyKeyword)) {
return null;
}
const universalIdentifier = twentyKeyword.replace('twenty-uid:', '');
return {
id: universalIdentifier,
name,
description: description ?? '',
icon: 'IconApps',
version,
author: author?.name ?? 'Unknown',
category: '',
screenshots: [],
aboutDescription: description ?? '',
providers: [],
websiteUrl: links?.homepage,
objects: [],
fields: [],
logicFunctions: [],
frontComponents: [],
sourcePackage: name,
isFeatured: false,
};
})
.filter(isDefined);
return {
name,
version,
description: description ?? '',
author: author?.name ?? 'Unknown',
websiteUrl: links?.homepage ?? links?.npm,
};
});
} catch (error) {
this.logger.warn(
`Failed to fetch apps from npm registry: ${error instanceof Error ? error.message : String(error)}`,
`Failed to fetch apps from registry ${registryUrl}: ${error instanceof Error ? error.message : String(error)}`,
);
return [];
@@ -1,75 +0,0 @@
// Rich display data stored alongside ApplicationRegistration for marketplace
// rendering. This is denormalized from the catalog source so it can be displayed
// pre-install without resolving the package.
export type MarketplaceDisplayData = {
icon?: string;
version?: string;
category?: string;
logo?: string;
screenshots?: string[];
aboutDescription?: string;
providers?: string[];
objects?: MarketplaceDisplayObject[];
fields?: MarketplaceDisplayField[];
logicFunctions?: MarketplaceDisplayLogicFunction[];
frontComponents?: MarketplaceDisplayFrontComponent[];
defaultRole?: MarketplaceDisplayDefaultRole;
};
type MarketplaceDisplayObject = {
universalIdentifier: string;
nameSingular: string;
namePlural: string;
labelSingular: string;
labelPlural: string;
description?: string;
icon?: string;
fields: MarketplaceDisplayField[];
};
type MarketplaceDisplayField = {
name: string;
type: string;
label: string;
description?: string;
icon?: string;
objectUniversalIdentifier: string;
universalIdentifier: string;
};
type MarketplaceDisplayLogicFunction = {
name: string;
description?: string;
timeoutSeconds?: number;
};
type MarketplaceDisplayFrontComponent = {
name: string;
description?: string;
};
type MarketplaceDisplayDefaultRole = {
id: string;
label: string;
description?: string;
canReadAllObjectRecords: boolean;
canUpdateAllObjectRecords: boolean;
canSoftDeleteAllObjectRecords: boolean;
canDestroyAllObjectRecords: boolean;
canUpdateAllSettings: boolean;
canAccessAllTools: boolean;
objectPermissions: Array<{
objectUniversalIdentifier: string;
canReadObjectRecords?: boolean;
canUpdateObjectRecords?: boolean;
canSoftDeleteObjectRecords?: boolean;
canDestroyObjectRecords?: boolean;
}>;
fieldPermissions: Array<{
objectUniversalIdentifier: string;
fieldUniversalIdentifier: string;
canReadFieldValue?: boolean;
canUpdateFieldValue?: boolean;
}>;
permissionFlags: string[];
};
@@ -0,0 +1,145 @@
import { type Manifest } from 'twenty-shared/application';
import { resolveManifestAssetUrls } from 'src/engine/core-modules/application/application-marketplace/utils/resolve-manifest-asset-urls.util';
const buildMinimalManifest = (
overrides: Partial<Manifest['application']> = {},
): Manifest => ({
application: {
universalIdentifier: 'app-1',
defaultRoleUniversalIdentifier: 'role-1',
displayName: 'Test App',
description: 'A test app',
packageJsonChecksum: null,
yarnLockChecksum: null,
...overrides,
},
objects: [],
fields: [],
logicFunctions: [],
frontComponents: [],
roles: [],
skills: [],
agents: [],
publicAssets: [],
views: [],
navigationMenuItems: [],
pageLayouts: [],
});
describe('resolveManifestAssetUrls', () => {
const urlBuilder = (filePath: string) =>
`https://cdn.example.com/pkg/${filePath}`;
it('should resolve a relative logoUrl', () => {
const manifest = buildMinimalManifest({ logoUrl: 'logo.png' });
const result = resolveManifestAssetUrls(manifest, urlBuilder);
expect(result.application.logoUrl).toBe(
'https://cdn.example.com/pkg/logo.png',
);
});
it('should not modify an absolute logoUrl', () => {
const manifest = buildMinimalManifest({
logoUrl: 'https://example.com/logo.png',
});
const result = resolveManifestAssetUrls(manifest, urlBuilder);
expect(result.application.logoUrl).toBe('https://example.com/logo.png');
});
it('should not modify an http logoUrl', () => {
const manifest = buildMinimalManifest({
logoUrl: 'http://example.com/logo.png',
});
const result = resolveManifestAssetUrls(manifest, urlBuilder);
expect(result.application.logoUrl).toBe('http://example.com/logo.png');
});
it('should leave logoUrl undefined when not set', () => {
const manifest = buildMinimalManifest();
const result = resolveManifestAssetUrls(manifest, urlBuilder);
expect(result.application.logoUrl).toBeUndefined();
});
it('should resolve relative screenshot paths', () => {
const manifest = buildMinimalManifest({
screenshots: ['screen1.png', 'screen2.png'],
});
const result = resolveManifestAssetUrls(manifest, urlBuilder);
expect(result.application.screenshots).toEqual([
'https://cdn.example.com/pkg/screen1.png',
'https://cdn.example.com/pkg/screen2.png',
]);
});
it('should not modify absolute screenshot URLs', () => {
const manifest = buildMinimalManifest({
screenshots: [
'https://example.com/screen1.png',
'https://example.com/screen2.png',
],
});
const result = resolveManifestAssetUrls(manifest, urlBuilder);
expect(result.application.screenshots).toEqual([
'https://example.com/screen1.png',
'https://example.com/screen2.png',
]);
});
it('should handle a mix of relative and absolute screenshot URLs', () => {
const manifest = buildMinimalManifest({
screenshots: ['relative.png', 'https://example.com/absolute.png'],
});
const result = resolveManifestAssetUrls(manifest, urlBuilder);
expect(result.application.screenshots).toEqual([
'https://cdn.example.com/pkg/relative.png',
'https://example.com/absolute.png',
]);
});
it('should handle empty screenshots array', () => {
const manifest = buildMinimalManifest({ screenshots: [] });
const result = resolveManifestAssetUrls(manifest, urlBuilder);
expect(result.application.screenshots).toEqual([]);
});
it('should handle undefined screenshots', () => {
const manifest = buildMinimalManifest();
const result = resolveManifestAssetUrls(manifest, urlBuilder);
expect(result.application.screenshots).toEqual([]);
});
it('should preserve all other manifest properties', () => {
const manifest = buildMinimalManifest({
logoUrl: 'logo.png',
author: 'Test Author',
websiteUrl: 'https://test.com',
});
manifest.objects = [{ universalIdentifier: 'obj-1' } as never];
const result = resolveManifestAssetUrls(manifest, urlBuilder);
expect(result.application.author).toBe('Test Author');
expect(result.application.websiteUrl).toBe('https://test.com');
expect(result.application.displayName).toBe('Test App');
expect(result.objects).toEqual([{ universalIdentifier: 'obj-1' }]);
});
});
@@ -0,0 +1,8 @@
export const buildRegistryCdnUrl = (params: {
cdnBaseUrl: string;
packageName: string;
version: string;
filePath: string;
}): string => {
return `${params.cdnBaseUrl}/${params.packageName}@${params.version}/${params.filePath}`;
};
@@ -0,0 +1,23 @@
import { type Manifest } from 'twenty-shared/application';
const isAbsoluteUrl = (url: string): boolean =>
url.startsWith('http://') || url.startsWith('https://');
export const resolveManifestAssetUrls = (
manifest: Manifest,
urlBuilder: (filePath: string) => string,
): Manifest => {
const resolveUrl = (url: string): string =>
isAbsoluteUrl(url) ? url : urlBuilder(url);
return {
...manifest,
application: {
...manifest.application,
logoUrl: manifest.application.logoUrl
? resolveUrl(manifest.application.logoUrl)
: undefined,
screenshots: (manifest.application.screenshots ?? []).map(resolveUrl),
},
};
};
@@ -140,9 +140,6 @@ export class OAuthRegistrationController {
const registration = this.applicationRegistrationRepository.create({
universalIdentifier: v4(),
name: body.client_name,
description: null,
logoUrl: body.logo_uri ?? null,
author: null,
oAuthClientId: clientId,
oAuthClientSecretHash: null,
oAuthRedirectUris: body.redirect_uris,
@@ -150,7 +147,6 @@ export class OAuthRegistrationController {
createdByUserId: null,
ownerWorkspaceId: null,
sourceType: ApplicationRegistrationSourceType.OAUTH_ONLY,
websiteUrl: body.client_uri ?? null,
});
await this.applicationRegistrationRepository.save(registration);
@@ -586,7 +586,6 @@ export class OAuthService {
return this.applicationService.create({
universalIdentifier: applicationRegistration.universalIdentifier,
name: applicationRegistration.name,
description: applicationRegistration.description,
version: '0.0.0',
sourcePath: 'oauth-install',
applicationRegistrationId: applicationRegistration.id,
@@ -18,7 +18,7 @@ import {
} from 'typeorm';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { type MarketplaceDisplayData } from 'src/engine/core-modules/application/application-marketplace/types/marketplace-display-data.type';
import { type Manifest } from 'twenty-shared/application';
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';
@@ -62,18 +62,6 @@ export class ApplicationRegistrationEntity {
@Column({ nullable: false, type: 'text' })
name: string;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
description: string | null;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
logoUrl: string | null;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
author: string | null;
@Field()
@Column({ nullable: false, type: 'text' })
oAuthClientId: string;
@@ -128,14 +116,6 @@ export class ApplicationRegistrationEntity {
@Column({ nullable: true, type: 'text' })
latestAvailableVersion: string | null;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
websiteUrl: string | null;
@Field(() => String, { nullable: true })
@Column({ nullable: true, type: 'text' })
termsUrl: string | null;
@Field(() => Boolean)
@Column({ type: 'boolean', default: false })
isListed: boolean;
@@ -145,7 +125,7 @@ export class ApplicationRegistrationEntity {
isFeatured: boolean;
@Column({ type: 'jsonb', nullable: true })
marketplaceDisplayData: MarketplaceDisplayData | null;
manifest: Manifest | null;
@OneToMany(
() => ApplicationRegistrationVariableEntity,
@@ -8,6 +8,7 @@ import { ApplicationRegistrationVariableModule } from 'src/engine/core-modules/a
import { ApplicationTarballService } from 'src/engine/core-modules/application/application-registration/application-tarball.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain-server-config/domain-server-config.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 { FileUrlModule } from 'src/engine/core-modules/file/file-url/file-url.module';
@@ -24,6 +25,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
]),
ApplicationRegistrationVariableModule,
ApplicationModule,
DomainServerConfigModule,
FeatureFlagModule,
PermissionsModule,
FileStorageModule,
@@ -32,6 +32,7 @@ import { RotateClientSecretDTO } from 'src/engine/core-modules/application/appli
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 { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service';
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';
@@ -59,6 +60,7 @@ export class ApplicationRegistrationResolver {
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
private readonly applicationTarballService: ApplicationTarballService,
private readonly fileUrlService: FileUrlService,
private readonly domainServerConfigService: DomainServerConfigService,
) {}
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
@@ -290,6 +292,25 @@ export class ApplicationRegistrationResolver {
});
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
)
@Query(() => String)
async getApplicationShareLink(
@Args('id') id: string,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<string> {
const registration = await this.applicationRegistrationService.findOneById(
id,
workspaceId,
);
const frontUrl = this.domainServerConfigService.getFrontUrl();
return `${frontUrl.origin}/settings/applications/available/${registration.universalIdentifier}`;
}
@UseGuards(
WorkspaceAuthGuard,
SettingsPermissionGuard(PermissionFlagType.APPLICATIONS),
@@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import crypto from 'crypto';
import * as bcrypt from 'bcrypt';
import { type Manifest } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, type Repository } from 'typeorm';
import { v4 } from 'uuid';
@@ -102,7 +103,7 @@ export class ApplicationRegistrationService {
): Promise<PublicApplicationRegistrationDTO | null> {
const registration = await this.applicationRegistrationRepository.findOne({
where: { oAuthClientId: clientId },
select: ['id', 'name', 'logoUrl', 'websiteUrl', 'oAuthScopes'],
select: ['id', 'name', 'manifest', 'oAuthScopes'],
});
if (!registration) {
@@ -112,22 +113,12 @@ export class ApplicationRegistrationService {
return {
id: registration.id,
name: registration.name,
logoUrl: registration.logoUrl,
websiteUrl: registration.websiteUrl,
logoUrl: registration.manifest?.application?.logoUrl ?? null,
websiteUrl: registration.manifest?.application?.websiteUrl ?? null,
oAuthScopes: registration.oAuthScopes,
};
}
async isOwnedByWorkspace(id: string, workspaceId: string): Promise<boolean> {
const registration = await this.applicationRegistrationRepository.findOne({
where: { id },
select: ['id', 'ownerWorkspaceId'],
});
return registration?.ownerWorkspaceId === workspaceId;
}
// Global lookup — used by app sync to find existing registrations
async findOneByUniversalIdentifier(
universalIdentifier: string,
): Promise<ApplicationRegistrationEntity | null> {
@@ -172,17 +163,12 @@ export class ApplicationRegistrationService {
this.applicationRegistrationRepository.create({
universalIdentifier,
name: input.name,
description: input.description ?? null,
logoUrl: input.logoUrl ?? null,
author: input.author ?? null,
oAuthClientId: clientId,
oAuthClientSecretHash: clientSecretHash,
oAuthRedirectUris: input.oAuthRedirectUris ?? [],
oAuthScopes: input.oAuthScopes ?? [],
createdByUserId,
ownerWorkspaceId,
websiteUrl: input.websiteUrl ?? null,
termsUrl: input.termsUrl ?? null,
});
const saved = await this.applicationRegistrationRepository.save(
@@ -211,16 +197,10 @@ export class ApplicationRegistrationService {
const updateData: Record<string, unknown> = {};
if (isDefined(update.name)) updateData.name = update.name;
if (isDefined(update.description))
updateData.description = update.description;
if (isDefined(update.logoUrl)) updateData.logoUrl = update.logoUrl;
if (isDefined(update.author)) updateData.author = update.author;
if (isDefined(update.oAuthRedirectUris))
updateData.oAuthRedirectUris = update.oAuthRedirectUris;
if (isDefined(update.oAuthScopes))
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) {
@@ -230,6 +210,21 @@ export class ApplicationRegistrationService {
return this.findOneById(id, ownerWorkspaceId);
}
async updateFromManifest(
applicationRegistrationId: string,
manifest: Manifest,
): Promise<void> {
const existing = await this.applicationRegistrationRepository.findOneOrFail(
{ where: { id: applicationRegistrationId } },
);
await this.applicationRegistrationRepository.save({
...existing,
name: manifest.application.displayName,
manifest,
});
}
async delete(id: string, ownerWorkspaceId: string): Promise<boolean> {
await this.findOneById(id, ownerWorkspaceId);
await this.applicationRegistrationRepository.softDelete(id);
@@ -269,17 +264,12 @@ export class ApplicationRegistrationService {
ApplicationRegistrationEntity,
| 'universalIdentifier'
| 'name'
| 'description'
| 'author'
| 'sourceType'
| 'sourcePackage'
| 'logoUrl'
| 'websiteUrl'
| 'termsUrl'
| 'latestAvailableVersion'
| 'isListed'
| 'isFeatured'
| 'marketplaceDisplayData'
| 'manifest'
| 'ownerWorkspaceId'
>,
): Promise<void> {
@@ -291,15 +281,12 @@ export class ApplicationRegistrationService {
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,
manifest: params.manifest,
isListed: params.isListed,
isFeatured: params.isFeatured,
});
return;
@@ -308,17 +295,12 @@ export class ApplicationRegistrationService {
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,
manifest: params.manifest,
oAuthClientId: v4(),
oAuthRedirectUris: [],
oAuthScopes: [],
@@ -341,7 +323,6 @@ export class ApplicationRegistrationService {
universalIdentifier:
TWENTY_CLI_APPLICATION_REGISTRATION.universalIdentifier,
name: TWENTY_CLI_APPLICATION_REGISTRATION.name,
description: TWENTY_CLI_APPLICATION_REGISTRATION.description,
oAuthClientId: v4(),
oAuthClientSecretHash: null,
oAuthRedirectUris: [],
@@ -63,6 +63,10 @@ export class ApplicationTarballService {
};
}>(contentDir, 'manifest.json');
const packageJson = await readJsonFile<{
version: string;
}>(contentDir, 'package.json');
if (manifest === null) {
throw new ApplicationRegistrationException(
'manifest.json not found or invalid in tarball',
@@ -104,6 +108,10 @@ export class ApplicationTarballService {
universalIdentifier,
name: manifest.application?.displayName ?? 'Unknown App',
sourceType: ApplicationRegistrationSourceType.TARBALL,
manifest,
latestAvailableVersion: packageJson?.version ?? null,
isListed: false,
isFeatured: false,
oAuthClientId: v4(),
oAuthRedirectUris: [],
oAuthScopes: [],
@@ -137,6 +145,12 @@ export class ApplicationTarballService {
await this.appRegistrationRepository.update(appRegistration.id, {
sourceType: ApplicationRegistrationSourceType.TARBALL,
tarballFileId: savedFile.id,
name: manifest.application?.displayName ?? 'Unknown App',
manifest,
latestAvailableVersion: packageJson?.version ?? null,
isListed: false,
isFeatured: false,
ownerWorkspaceId: params.ownerWorkspaceId,
});
this.logger.log(
@@ -16,24 +16,6 @@ export class CreateApplicationRegistrationInput {
@MaxLength(256)
name: string;
@Field({ nullable: true })
@IsString()
@MaxLength(2000)
@IsOptional()
description?: string;
@Field({ nullable: true })
@IsString()
@MaxLength(2048)
@IsOptional()
logoUrl?: string;
@Field({ nullable: true })
@IsString()
@MaxLength(256)
@IsOptional()
author?: string;
@Field({ nullable: true })
@IsUUID()
@IsOptional()
@@ -54,16 +36,4 @@ export class CreateApplicationRegistrationInput {
@MaxLength(256, { each: true })
@IsOptional()
oAuthScopes?: string[];
@Field({ nullable: true })
@IsString()
@MaxLength(2048)
@IsOptional()
websiteUrl?: string;
@Field({ nullable: true })
@IsString()
@MaxLength(2048)
@IsOptional()
termsUrl?: string;
}
@@ -21,24 +21,6 @@ export class UpdateApplicationRegistrationPayload {
@IsOptional()
name?: string;
@Field({ nullable: true })
@IsString()
@MaxLength(2000)
@IsOptional()
description?: string;
@Field({ nullable: true })
@IsString()
@MaxLength(2048)
@IsOptional()
logoUrl?: string;
@Field({ nullable: true })
@IsString()
@MaxLength(256)
@IsOptional()
author?: string;
@Field(() => [String], { nullable: true })
@IsArray()
@ArrayMaxSize(20)
@@ -55,18 +37,6 @@ export class UpdateApplicationRegistrationPayload {
@IsOptional()
oAuthScopes?: string[];
@Field({ nullable: true })
@IsString()
@MaxLength(2048)
@IsOptional()
websiteUrl?: string;
@Field({ nullable: true })
@IsString()
@MaxLength(2048)
@IsOptional()
termsUrl?: string;
@Field(() => Boolean, { nullable: true })
@IsBoolean()
@IsOptional()