From 0d13db1d9c8435ef7aa2c27b4d03125ecd8b4a3a Mon Sep 17 00:00:00 2001 From: nicoko93 Date: Fri, 17 Jul 2026 17:51:33 +0200 Subject: [PATCH] fix(twenty-server): re-list marketplace registration when catalog serves an app first installed locally (#22877) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Fixes #22872. An app first installed from a **local/CLI source** (`yarn twenty dev` / tarball upload) gets its `applicationRegistration` created with `isListed: false` — sensible for a dev app. But when that same app (same `universalIdentifier`) is later **published to the configured app registry**, the marketplace catalog sync's update branch in `upsertFromCatalog` spreads the existing entity and updates name/sourceType/sourcePackage/version/manifest **without ever setting `isListed` back to `true`** (only the create branch does). Result: the app is permanently invisible in the Marketplace tab (`findManyMarketplaceApps` → `findManyListed()`), while `installApplication(universalIdentifier)` still works — a confusing split-brain state with no error anywhere. Reproduced on a self-hosted v2.18.5 with a private Verdaccio registry (details and repro steps in the issue). ## Fix In the `upsertFromCatalog` update branch, re-list the registration **only when its previous source was local** (`TARBALL`/`LOCAL`): ```ts const isRelistedFromLocalSource = existing.sourceType === ApplicationRegistrationSourceType.TARBALL || existing.sourceType === ApplicationRegistrationSourceType.LOCAL; ... isListed: existing.isListed || isRelistedFromLocalSource, ``` This deliberately does **not** blanket-set `isListed: true` on every sync: an operator who delisted a registry-sourced app (via `updateApplicationRegistration`) keeps their decision — the hourly sync won't override it. ## Tests New unit spec `application-registration-upsert-from-catalog.spec.ts`: - re-lists a registration first created by a local install once the catalog serves it; - preserves an operator delisting of a registry-sourced registration; - keeps an already-listed registration listed; - still creates new catalog registrations as listed. Review in cubic Co-authored-by: Nicolas Chanal Co-authored-by: Claude Fable 5 --- ...n-registration-upsert-from-catalog.spec.ts | 154 ++++++++++++++++++ .../application-registration.service.ts | 9 + 2 files changed, 163 insertions(+) create mode 100644 packages/twenty-server/src/engine/core-modules/application/application-registration/__tests__/application-registration-upsert-from-catalog.spec.ts diff --git a/packages/twenty-server/src/engine/core-modules/application/application-registration/__tests__/application-registration-upsert-from-catalog.spec.ts b/packages/twenty-server/src/engine/core-modules/application/application-registration/__tests__/application-registration-upsert-from-catalog.spec.ts new file mode 100644 index 0000000000..1e17f4a44c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/application-registration/__tests__/application-registration-upsert-from-catalog.spec.ts @@ -0,0 +1,154 @@ +import { Test, type TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { ApplicationRegistrationAssetUrlService } from 'src/engine/core-modules/application/application-registration/application-registration-asset-url.service'; +import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity'; +import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service'; +import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service'; +import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum'; +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service'; +import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service'; +import { ServerFileStorageService } from 'src/engine/core-modules/file-storage/services/server-file-storage.service'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; + +describe('ApplicationRegistrationService - upsertFromCatalog', () => { + let service: ApplicationRegistrationService; + let applicationRegistrationRepository: { + findOne: jest.Mock; + save: jest.Mock; + create: jest.Mock; + }; + + const catalogParams = { + universalIdentifier: '97141c95-2870-5662-8992-44fb6536be9a', + name: 'My App', + sourceType: ApplicationRegistrationSourceType.NPM, + sourcePackage: 'twenty-app-my-app', + latestAvailableVersion: '0.2.0', + manifest: null, + }; + + const buildExistingRegistration = ( + overrides: Partial, + ) => + ({ + id: 'registration-id', + universalIdentifier: catalogParams.universalIdentifier, + name: 'My App', + galleryImages: [], + ...overrides, + }) as ApplicationRegistrationEntity; + + beforeEach(async () => { + applicationRegistrationRepository = { + findOne: jest.fn(), + save: jest.fn(), + create: jest.fn((entity) => entity), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ApplicationRegistrationService, + { + provide: getRepositoryToken(ApplicationRegistrationEntity), + useValue: applicationRegistrationRepository, + }, + { + provide: getRepositoryToken(ApplicationEntity), + useValue: { find: jest.fn(), findOne: jest.fn() }, + }, + { + provide: getRepositoryToken(WorkspaceEntity), + useValue: { find: jest.fn(), findOne: jest.fn() }, + }, + { + provide: ApplicationRegistrationVariableService, + useValue: { syncVariableSchemas: jest.fn() }, + }, + { + provide: ApplicationRegistrationAssetUrlService, + useValue: { resolveAssetUrls: jest.fn() }, + }, + { + provide: ServerFileStorageService, + useValue: { write: jest.fn(), delete: jest.fn() }, + }, + { + provide: CacheLockService, + useValue: { withLock: jest.fn((_key, fn) => fn()) }, + }, + { + provide: CoreEntityCacheService, + useValue: { invalidate: jest.fn() }, + }, + ], + }).compile(); + + service = module.get( + ApplicationRegistrationService, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should re-list a registration first created by a local install when the catalog serves it', async () => { + applicationRegistrationRepository.findOne.mockResolvedValue( + buildExistingRegistration({ + sourceType: ApplicationRegistrationSourceType.TARBALL, + isListed: false, + }), + ); + + await service.upsertFromCatalog(catalogParams); + + expect(applicationRegistrationRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + isListed: true, + sourceType: ApplicationRegistrationSourceType.NPM, + }), + ); + }); + + it('should preserve an operator delisting of a registry-sourced registration', async () => { + applicationRegistrationRepository.findOne.mockResolvedValue( + buildExistingRegistration({ + sourceType: ApplicationRegistrationSourceType.NPM, + isListed: false, + }), + ); + + await service.upsertFromCatalog(catalogParams); + + expect(applicationRegistrationRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ isListed: false }), + ); + }); + + it('should keep an already listed registration listed', async () => { + applicationRegistrationRepository.findOne.mockResolvedValue( + buildExistingRegistration({ + sourceType: ApplicationRegistrationSourceType.NPM, + isListed: true, + }), + ); + + await service.upsertFromCatalog(catalogParams); + + expect(applicationRegistrationRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ isListed: true }), + ); + }); + + it('should create new catalog registrations as listed', async () => { + applicationRegistrationRepository.findOne.mockResolvedValue(null); + + await service.upsertFromCatalog(catalogParams); + + expect(applicationRegistrationRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ isListed: true }), + ); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.service.ts index 4e25f43719..cc9fdcd494 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.service.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-registration/application-registration.service.ts @@ -629,12 +629,21 @@ export class ApplicationRegistrationService { params.latestAvailableVersion ?? null, ); + // A registration first created by a local install (CLI dev / tarball + // upload) starts unlisted on purpose. Once the catalog source serves the + // same universalIdentifier, surface it in the marketplace — while + // preserving an operator's decision to delist a registry-sourced app. + const isRelistedFromLocalSource = + existing.sourceType === ApplicationRegistrationSourceType.TARBALL || + existing.sourceType === ApplicationRegistrationSourceType.LOCAL; + await this.applicationRegistrationRepository.save({ ...existing, name: params.name, sourceType: params.sourceType, sourcePackage: params.sourcePackage, latestAvailableVersion: params.latestAvailableVersion, + isListed: existing.isListed || isRelistedFromLocalSource, isVetted, manifest: params.manifest, ...fromManifestApplicationToDisplayFields(params.manifest?.application),