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:
+2
@@ -0,0 +1,2 @@
|
||||
export const MARKETPLACE_CATALOG_CACHE_ENTITY_ID =
|
||||
'00000000-0000-4000-8000-000000000001';
|
||||
+64
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+51
@@ -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;
|
||||
}
|
||||
}
|
||||
+20
-58
@@ -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 {
|
||||
|
||||
+6
@@ -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,
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+36
-1
@@ -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 },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user