feat(server): add public marketplace resolver for vetted app catalog (#22647)

## What

Adds a public GraphQL resolver so unauthenticated clients (the public
website) can read the listed/vetted marketplace catalog without a
workspace token.

- `MarketplacePublicResolver` (metadata schema) exposes two public
queries guarded by `PublicEndpointGuard` + `NoPermissionGuard`:
  - `publicMarketplaceApps`
  - `publicMarketplaceAppDetail(universalIdentifier)`
  
Both delegate to the existing `MarketplaceQueryService` (no new logic,
no new REST routing). The existing workspace-guarded
`findManyMarketplaceApps` / `findMarketplaceAppDetail` queries are
untouched.
- Adds a shared `ApplicationCategory` type in `twenty-shared` (known
values plus `string` for backward compatibility) used to type
`ApplicationManifest.category`. A warning is logged server-side when an
app declares a category outside the known set.

## Why

This is the backend half of the public apps marketplace on the website.
Splitting it out so the server-side catalog exposure can be reviewed
independently from the website UI.

## Follow-up

The website PR (the `/apps` marketplace UI) consumes
`publicMarketplaceApps` and should merge after this one.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01GBfegArtJcoiTLSsnWPH8R)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22647?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: martmull <martin@twenty.com>
This commit is contained in:
martmull
2026-07-08 17:43:17 +02:00
committed by GitHub
parent b1dee7cf26
commit 9423af7f67
17 changed files with 292 additions and 60 deletions
@@ -0,0 +1,2 @@
export const MARKETPLACE_CATALOG_CACHE_ENTITY_ID =
'00000000-0000-4000-8000-000000000001';
@@ -0,0 +1,64 @@
import { Injectable } from '@nestjs/common';
import { CoreEntityCache } from 'src/engine/core-entity-cache/decorators/core-entity-cache.decorator';
import { CoreEntityCacheProvider } from 'src/engine/core-entity-cache/interfaces/core-entity-cache-provider.service';
import { MarketplaceAppDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app.dto';
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service';
import {
type ApplicationRegistrationCatalogCard,
ApplicationRegistrationService,
} from 'src/engine/core-modules/application/application-registration/application-registration.service';
@Injectable()
@CoreEntityCache('marketplaceCatalog')
export class MarketplaceCatalogCacheProviderService extends CoreEntityCacheProvider<
Record<string, MarketplaceAppDTO>
> {
constructor(
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
) {
super();
}
async computeForCache(): Promise<Record<string, MarketplaceAppDTO>> {
const registrations =
await this.applicationRegistrationService.findManyListedCatalogCards();
if (registrations.length === 0) {
return {};
}
const configuredStatuses =
await this.applicationRegistrationVariableService.isConfiguredBatch(
registrations.map((registration) => registration.id),
);
return registrations
.filter((registration) => configuredStatuses.get(registration.id) ?? true)
.reduce<Record<string, MarketplaceAppDTO>>(
(accumulator, registration) => {
accumulator[registration.universalIdentifier] =
this.toMarketplaceAppDTO(registration);
return accumulator;
},
{},
);
}
private toMarketplaceAppDTO(
catalogCard: ApplicationRegistrationCatalogCard,
): MarketplaceAppDTO {
return {
id: catalogCard.universalIdentifier,
name: catalogCard.name,
description: catalogCard.description ?? '',
author: catalogCard.author ?? 'Unknown',
category: catalogCard.category ?? '',
logo: catalogCard.logoUrl ?? undefined,
sourcePackage: catalogCard.sourcePackage ?? undefined,
isFeatured: catalogCard.isFeatured,
};
}
}
@@ -0,0 +1,51 @@
import { UseFilters, UseGuards } from '@nestjs/common';
import { Args, Query } from '@nestjs/graphql';
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 { 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 { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
@MetadataResolver()
@UseFilters(ApplicationRegistrationExceptionFilter)
export class MarketplacePublicResolver {
constructor(
private readonly marketplaceQueryService: MarketplaceQueryService,
) {}
@Query(() => [MarketplaceAppDTO], { name: 'publicMarketplaceApps' })
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async findManyPublicMarketplaceApps(
@Args('isFeatured', { type: () => Boolean, defaultValue: true })
isFeatured: boolean,
): Promise<MarketplaceAppDTO[]> {
return this.marketplaceQueryService.findManyMarketplaceApps(isFeatured);
}
@Query(() => MarketplaceAppDetailDTO, { name: 'publicMarketplaceAppDetail' })
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
async findPublicMarketplaceAppDetail(
@Args('universalIdentifier') universalIdentifier: string,
): Promise<MarketplaceAppDetailDTO> {
const detail =
await this.marketplaceQueryService.findMarketplaceAppDetail(
universalIdentifier,
);
if (!detail.isListed) {
throw new ApplicationRegistrationException(
`No listed marketplace application found for identifier "${universalIdentifier}"`,
ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND,
);
}
return detail;
}
}
@@ -1,66 +1,43 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { type RoleManifest } from 'twenty-shared/application';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
import { MARKETPLACE_CATALOG_CACHE_ENTITY_ID } from 'src/engine/core-modules/application/application-marketplace/constants/marketplace-apps-cache.constant';
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 { MarketplaceAppRoleDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app-role.dto';
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 { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service';
import {
type ApplicationRegistrationCatalogCard,
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 { MarketplaceAppRoleDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app-role.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';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
@Injectable()
export class MarketplaceQueryService {
private readonly logger = new Logger(MarketplaceQueryService.name);
private hasSyncBeenEnqueued = false;
constructor(
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
@InjectMessageQueue(MessageQueue.cronQueue)
private readonly messageQueueService: MessageQueueService,
private readonly coreEntityCacheService: CoreEntityCacheService,
) {}
async findManyMarketplaceApps(): Promise<MarketplaceAppDTO[]> {
const registrations =
await this.applicationRegistrationService.findManyListedCatalogCards();
async findManyMarketplaceApps(
isFeatured?: boolean,
): Promise<MarketplaceAppDTO[]> {
const appsByUniversalIdentifier =
(await this.coreEntityCacheService.get(
'marketplaceCatalog',
MARKETPLACE_CATALOG_CACHE_ENTITY_ID,
)) ?? {};
if (registrations.length === 0) {
if (!this.hasSyncBeenEnqueued) {
this.hasSyncBeenEnqueued = true;
this.logger.log(
'No marketplace registrations found, enqueuing one-time sync job',
);
await this.messageQueueService.add(
MarketplaceCatalogSyncCronJob.name,
{},
{ id: 'marketplace-catalog-sync' }, // Avoids triggering multiple pending jobs
);
}
const apps = Object.values(appsByUniversalIdentifier);
return [];
if (!isDefined(isFeatured)) {
return apps;
}
const configuredStatuses =
await this.applicationRegistrationVariableService.isConfiguredBatch(
registrations.map((registration) => registration.id),
);
return registrations
.filter((registration) => configuredStatuses.get(registration.id) ?? true)
.map((registration) => this.toMarketplaceAppDTO(registration));
return apps.filter((app) => app.isFeatured === isFeatured);
}
async findMarketplaceAppDetail(
@@ -90,21 +67,6 @@ export class MarketplaceQueryService {
return registration;
}
private toMarketplaceAppDTO(
catalogCard: ApplicationRegistrationCatalogCard,
): MarketplaceAppDTO {
return {
id: catalogCard.universalIdentifier,
name: catalogCard.name,
description: catalogCard.description ?? '',
author: catalogCard.author ?? 'Unknown',
category: catalogCard.category ?? '',
logo: catalogCard.logoUrl ?? undefined,
sourcePackage: catalogCard.sourcePackage ?? undefined,
isFeatured: catalogCard.isFeatured,
};
}
private toMarketplaceAppDetailDTO(
registration: ApplicationRegistrationEntity,
): MarketplaceAppDetailDTO {
@@ -1,10 +1,13 @@
import { Module } from '@nestjs/common';
import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.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 { MarketplaceCatalogCacheProviderService } from 'src/engine/core-modules/application/application-marketplace/marketplace-catalog-cache-provider.service';
import { MarketplaceCatalogSyncService } from 'src/engine/core-modules/application/application-marketplace/marketplace-catalog-sync.service';
import { MarketplacePublicResolver } from 'src/engine/core-modules/application/application-marketplace/marketplace-public.resolver';
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';
@@ -17,6 +20,7 @@ import { MarketplaceCatalogSyncCommand } from 'src/engine/core-modules/applicati
imports: [
ApplicationModule,
ApplicationRegistrationModule,
CoreEntityCacheModule,
FeatureFlagModule,
PermissionsModule,
TwentyConfigModule,
@@ -25,10 +29,12 @@ import { MarketplaceCatalogSyncCommand } from 'src/engine/core-modules/applicati
MarketplaceService,
MarketplaceCatalogSyncService,
MarketplaceQueryService,
MarketplaceCatalogCacheProviderService,
MarketplaceCatalogSyncCronJob,
MarketplaceCatalogSyncCronCommand,
MarketplaceCatalogSyncCommand,
MarketplaceResolver,
MarketplacePublicResolver,
],
exports: [
MarketplaceCatalogSyncService,
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CoreEntityCacheModule } from 'src/engine/core-entity-cache/core-entity-cache.module';
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';
@@ -30,6 +31,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
ApplicationModule,
ApplicationPackageModule,
CacheLockModule,
CoreEntityCacheModule,
DomainServerConfigModule,
FeatureFlagModule,
PermissionsModule,
@@ -32,6 +32,8 @@ import { ApplicationEntity } from 'src/engine/core-modules/application/applicati
import { validateRedirectUri } from 'src/engine/core-modules/auth/utils/validate-redirect-uri.util';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service';
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
import { MARKETPLACE_CATALOG_CACHE_ENTITY_ID } from 'src/engine/core-modules/application/application-marketplace/constants/marketplace-apps-cache.constant';
import { MARKETPLACE_FEATURED_APPLICATIONS } from 'src/engine/core-modules/application/application-marketplace/constants/marketplace-featured-applications.constant';
const BCRYPT_SALT_ROUNDS = 10;
@@ -92,8 +94,20 @@ export class ApplicationRegistrationService {
private readonly workspaceRepository: Repository<WorkspaceEntity>,
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
private readonly cacheLockService: CacheLockService,
private readonly coreEntityCacheService: CoreEntityCacheService,
) {}
private async invalidateMarketplaceAppsCache(): Promise<void> {
try {
await this.coreEntityCacheService.invalidate(
'marketplaceCatalog',
MARKETPLACE_CATALOG_CACHE_ENTITY_ID,
);
} catch (error) {
this.logger.error('Failed to invalidate marketplace apps cache', error);
}
}
async findMany(
ownerWorkspaceId: string,
): Promise<ApplicationRegistrationEntity[]> {
@@ -233,6 +247,8 @@ export class ApplicationRegistrationService {
applicationRegistration,
);
await this.invalidateMarketplaceAppsCache();
return { applicationRegistration: saved, clientSecret };
}
@@ -285,6 +301,7 @@ export class ApplicationRegistrationService {
if (Object.keys(updateData).length > 0) {
await this.applicationRegistrationRepository.update(id, updateData);
await this.invalidateMarketplaceAppsCache();
}
}
@@ -332,6 +349,8 @@ export class ApplicationRegistrationService {
latestAvailableVersion,
}),
});
await this.invalidateMarketplaceAppsCache();
}, `application-registration-update:${applicationRegistrationId}`);
}
@@ -339,6 +358,8 @@ export class ApplicationRegistrationService {
await this.findOneById(id, ownerWorkspaceId);
await this.applicationRegistrationRepository.softDelete(id);
await this.invalidateMarketplaceAppsCache();
return true;
}
@@ -355,6 +376,8 @@ export class ApplicationRegistrationService {
oAuthClientSecretHash: clientSecretHash,
});
await this.invalidateMarketplaceAppsCache();
return clientSecret;
}
@@ -399,6 +422,7 @@ export class ApplicationRegistrationService {
sourceType: params.sourceType,
sourcePackage: params.sourcePackage,
latestAvailableVersion: params.latestAvailableVersion,
isFeatured,
manifest: params.manifest,
...fromManifestApplicationToDisplayFields(params.manifest?.application),
});
@@ -422,6 +446,8 @@ export class ApplicationRegistrationService {
await this.applicationRegistrationRepository.save(registration);
}
await this.invalidateMarketplaceAppsCache();
if (!isDefined(params.manifest?.application?.serverVariables)) {
return;
}
@@ -462,7 +488,12 @@ export class ApplicationRegistrationService {
createdByUserId: null,
});
return this.applicationRegistrationRepository.save(registration);
const saved =
await this.applicationRegistrationRepository.save(registration);
await this.invalidateMarketplaceAppsCache();
return saved;
}
async findManyListedCatalogCards(): Promise<
@@ -645,6 +676,8 @@ export class ApplicationRegistrationService {
);
}
await this.invalidateMarketplaceAppsCache();
return this.applicationRegistrationRepository.findOneOrFail({
where: { id: registration.id },
});
@@ -682,6 +715,8 @@ export class ApplicationRegistrationService {
ownerWorkspaceId: targetWorkspace.id,
});
await this.invalidateMarketplaceAppsCache();
return this.applicationRegistrationRepository.findOneOrFail({
where: { id: registration.id },
});