Unify application version gate, registration writes and upgrade paths across sources (#22931)

Continues the source-unification arc after #22921. Three related
consolidations, one commit each.

## 1. Single semver version gate (`793f4849`)

The "incoming version must move forward" rule was hand-rolled twice:
workspace installs (validate semver, reject equal as
`APP_ALREADY_INSTALLED`, lower as `CANNOT_DOWNGRADE_APPLICATION`) and
tarball deploys (reject `lte` as `VERSION_ALREADY_EXISTS`).
`ApplicationVersionValidationService.validateVersionProgression` now
owns the comparison rules and messages; new maps in
`version-reason-to-exception-code.constant.ts` translate failure reasons
to each caller's existing exception codes, so error contracts observed
by the frontend/CLI are unchanged. A non-semver current version never
blocks, matching both previous behaviors.

## 2. One registration-metadata writer (`13eb5f91`)

Tarball upload and marketplace catalog sync wrote registration metadata
with their own repository calls, duplicating the gallery-image fileId
preservation and variable-schema sync, and bypassing the
per-registration lock and transaction that `updateFromManifest`
provides. Both now delegate to `updateFromManifest` (new
`additionalFields` allowlist for their extra columns: `tarballFileId`,
`isListed`, `isVetted`, `ownerWorkspaceId`, `sourcePackage`, `name`), so
every manifest-bearing registration write serializes on the same lock
and applies the same rules. The shared gallery fileId preservation moved
to a `buildRegistrationManifestUpdateFields` util. Tarball uploads can
no longer race installs on the registration row.

Behavior notes: a tarball re-upload whose manifest lacks
`application.displayName` now keeps the existing registration name
instead of resetting it to "Unknown App", and a re-upload without a
`package.json` version keeps the stored `latestAvailableVersion` instead
of nulling it — both strictly less destructive.

## 3. TARBALL upgrades (`b20aaba9`)

`upgradeApplication` only supported NPM; TARBALL apps had no update path
for installing workspaces. It now accepts TARBALL registrations and
re-installs the stored tarball, whose contents define the target version
— the install flow already gates same-version and downgrade installs.
The settings UI shows the latest-version row and the Upgrade button for
both NPM and TARBALL apps via a shared
`isUpgradableApplicationSourceType` util. LOCAL (dev-sync updates) and
OAUTH_ONLY (no code artifacts) stay rejected with a clearer message.

## Validation

- New tests: `validateVersionProgression` matrix in
`application-version-validation.service.spec.ts`,
`buildRegistrationManifestUpdateFields` gallery-preservation spec
- All 27 application suites (148 tests) pass; typecheck and lint green
on twenty-server and twenty-front
This commit is contained in:
martmull
2026-07-17 09:06:20 +02:00
committed by GitHub
parent c6f0380070
commit 6360599943
15 changed files with 732 additions and 262 deletions
@@ -33,13 +33,13 @@ import {
IconSettings,
} from 'twenty-ui/icon';
import {
ApplicationRegistrationSourceType,
FindMarketplaceAppDetailDocument,
FindMarketplaceAppManifestDocument,
FindOneApplicationDocument,
PermissionFlagType,
UninstallApplicationDocument,
} from '~/generated-metadata/graphql';
import { isUpgradableApplicationSourceType } from '~/pages/settings/applications/utils/isUpgradableApplicationSourceType';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
import { SettingsSectionSkeletonLoader } from '@/settings/components/SettingsSectionSkeletonLoader';
import { CUSTOM_APPLICATION_ILLUSTRATIONS } from '~/pages/settings/applications/constants/CustomApplicationIllustrations';
@@ -115,7 +115,6 @@ export const SettingsApplicationDetails = () => {
);
const sourceType = application?.applicationRegistration?.sourceType;
const isNpmApp = sourceType === ApplicationRegistrationSourceType.NPM;
const registrationId = detail?.id ?? application?.applicationRegistration?.id;
const currentVersion = application?.version;
const latestAvailableVersion =
@@ -123,7 +122,7 @@ export const SettingsApplicationDetails = () => {
application?.applicationRegistration?.latestAvailableVersion;
const hasUpdate =
isNpmApp &&
isUpgradableApplicationSourceType(sourceType) &&
isDefined(latestAvailableVersion) &&
isDefined(currentVersion) &&
isNewerSemver(latestAvailableVersion, currentVersion);
@@ -41,6 +41,7 @@ import { SettingsApplicationDetailAboutTab } from '~/pages/settings/applications
import { SettingsApplicationDetailContentTab } from '~/pages/settings/applications/tabs/SettingsApplicationDetailContentTab';
import { SettingsApplicationPermissionsTab } from '~/pages/settings/applications/tabs/SettingsApplicationPermissionsTab';
import { isNewerSemver } from '~/pages/settings/applications/utils/isNewerSemver';
import { isUpgradableApplicationSourceType } from '~/pages/settings/applications/utils/isUpgradableApplicationSourceType';
const AVAILABLE_APPLICATION_DETAIL_ID = 'available-application-detail';
@@ -103,7 +104,7 @@ export const SettingsAvailableApplicationDetails = () => {
const defaultRole = getMarketplaceAppDefaultRoleManifest(detail);
const hasUpdate =
isNpmApp &&
isUpgradableApplicationSourceType(sourceType) &&
isDefined(latestAvailableVersion) &&
isDefined(currentVersion) &&
isNewerSemver(latestAvailableVersion, currentVersion);
@@ -7,11 +7,9 @@ import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconCircleDot, IconStatusChange, IconUpload } from 'twenty-ui/icon';
import { Button } from 'twenty-ui/input';
import {
ApplicationRegistrationSourceType,
type Application,
} from '~/generated-metadata/graphql';
import { type Application } from '~/generated-metadata/graphql';
import { isNewerSemver } from '~/pages/settings/applications/utils/isNewerSemver';
import { isUpgradableApplicationSourceType } from '~/pages/settings/applications/utils/isUpgradableApplicationSourceType';
const StyledContainer = styled.div`
display: flex;
@@ -37,14 +35,14 @@ export const SettingsApplicationVersionContainer = ({
const currentVersion = application?.version;
const sourceType = application?.applicationRegistration?.sourceType;
const isNpmApp = sourceType === ApplicationRegistrationSourceType.NPM;
const isUpgradableApp = isUpgradableApplicationSourceType(sourceType);
const latestVersion = isNpmApp
const latestVersion = isUpgradableApp
? (latestAvailableVersion ?? currentVersion)
: currentVersion;
const hasUpdate =
isNpmApp &&
isUpgradableApp &&
isDefined(latestAvailableVersion) &&
isDefined(currentVersion) &&
isNewerSemver(latestAvailableVersion, currentVersion);
@@ -74,7 +72,7 @@ export const SettingsApplicationVersionContainer = ({
/>
),
},
...(isNpmApp
...(isUpgradableApp
? [
{
Icon: IconStatusChange,
@@ -0,0 +1,7 @@
import { ApplicationRegistrationSourceType } from '~/generated-metadata/graphql';
export const isUpgradableApplicationSourceType = (
sourceType: ApplicationRegistrationSourceType | null | undefined,
): boolean =>
sourceType === ApplicationRegistrationSourceType.NPM ||
sourceType === ApplicationRegistrationSourceType.TARBALL;
@@ -4,7 +4,6 @@ import { InjectRepository } from '@nestjs/typeorm';
import { promises as fs } from 'fs';
import { isAbsolute, relative, resolve } from 'path';
import semver from 'semver';
import { Manifest } from 'twenty-shared/application';
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
@@ -24,7 +23,10 @@ import {
ApplicationPackageFetcherService,
} from 'src/engine/core-modules/application/application-package/application-package-fetcher.service';
import { ApplicationVersionValidationService } from 'src/engine/core-modules/application/application-package/application-version-validation.service';
import { VERSION_REASON_TO_APPLICATION_EXCEPTION_CODE } from 'src/engine/core-modules/application/application-package/constants/version-reason-to-exception-code.constant';
import {
VERSION_PROGRESSION_REASON_TO_INSTALL_EXCEPTION_CODE,
VERSION_REASON_TO_APPLICATION_EXCEPTION_CODE,
} from 'src/engine/core-modules/application/application-package/constants/version-reason-to-exception-code.constant';
import { ApplicationManifestApplyService } from 'src/engine/core-modules/application/application-manifest/application-manifest-apply.service';
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service';
import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service';
@@ -113,9 +115,37 @@ export class ApplicationInstallService {
}
private async doInstallApplication(
appRegistration: ApplicationRegistrationEntity,
preLockAppRegistration: ApplicationRegistrationEntity,
params: { version?: string; workspaceId: string },
): Promise<boolean> {
// Re-read inside the lock so the authorization below cannot act on stale
// listing or ownership state.
const appRegistration = await this.appRegistrationRepository.findOne({
where: { id: preLockAppRegistration.id },
});
if (!appRegistration) {
throw new ApplicationException(
`Application registration with id ${preLockAppRegistration.id} not found`,
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
);
}
// Tarball registrations that are neither listed nor pre-installed are
// only installable by their owner workspace.
if (
appRegistration.sourceType ===
ApplicationRegistrationSourceType.TARBALL &&
!appRegistration.isListed &&
!appRegistration.isPreInstalled &&
appRegistration.ownerWorkspaceId !== params.workspaceId
) {
throw new ApplicationException(
`Application registration ${appRegistration.universalIdentifier} is not available for this workspace`,
ApplicationExceptionCode.FORBIDDEN,
);
}
const resolvedPackage =
await this.applicationPackageFetcherService.resolvePackage(
appRegistration,
@@ -265,28 +295,22 @@ export class ApplicationInstallService {
isDefined(application.version) &&
isDefined(incomingVersion)
) {
if (!isDefined(semver.valid(incomingVersion))) {
const progression =
this.applicationVersionValidationService.validateVersionProgression({
incomingVersion,
currentVersion: application.version,
universalIdentifier,
action: 'install',
});
if (!progression.allowed) {
throw new ApplicationException(
`Invalid version "${incomingVersion}" in package.json. Must be a valid semver version.`,
ApplicationExceptionCode.INVALID_INPUT,
progression.message,
VERSION_PROGRESSION_REASON_TO_INSTALL_EXCEPTION_CODE[
progression.reason
],
);
}
if (isDefined(semver.valid(application.version))) {
if (semver.eq(incomingVersion, application.version)) {
throw new ApplicationException(
`${universalIdentifier}@${incomingVersion} is already installed in this workspace.`,
ApplicationExceptionCode.APP_ALREADY_INSTALLED,
);
}
if (semver.lt(incomingVersion, application.version)) {
throw new ApplicationException(
`Cannot install ${universalIdentifier}@${incomingVersion}: version ${application.version} is already installed and downgrading is not allowed.`,
ApplicationExceptionCode.CANNOT_DOWNGRADE_APPLICATION,
);
}
}
}
await this.writeFilesToStorage(
@@ -148,4 +148,93 @@ describe('ApplicationVersionValidationService', () => {
expect(getInferredVersion).not.toHaveBeenCalled();
});
});
describe('validateVersionProgression', () => {
it('should reject an invalid incoming version', () => {
const result = service.validateVersionProgression({
incomingVersion: 'not-semver',
currentVersion: '1.0.0',
universalIdentifier: 'my-app',
action: 'install',
});
expect(result).toMatchObject({
allowed: false,
reason: 'INVALID_INCOMING_VERSION',
});
});
it('should allow any progression when the current version is not valid semver', () => {
expect(
service.validateVersionProgression({
incomingVersion: '1.0.0',
currentVersion: 'unknown',
universalIdentifier: 'my-app',
action: 'install',
}),
).toEqual({ allowed: true });
});
it('should allow a higher version', () => {
for (const action of ['install', 'deploy'] as const) {
expect(
service.validateVersionProgression({
incomingVersion: '1.1.0',
currentVersion: '1.0.0',
universalIdentifier: 'my-app',
action,
}),
).toEqual({ allowed: true });
}
});
it('should reject the same version on install as already installed', () => {
const result = service.validateVersionProgression({
incomingVersion: '1.0.0',
currentVersion: '1.0.0',
universalIdentifier: 'my-app',
action: 'install',
});
expect(result).toMatchObject({
allowed: false,
reason: 'SAME_VERSION',
message: 'my-app@1.0.0 is already installed in this workspace.',
});
});
it('should reject a lower version on install as a downgrade', () => {
const result = service.validateVersionProgression({
incomingVersion: '0.9.0',
currentVersion: '1.0.0',
universalIdentifier: 'my-app',
action: 'install',
});
expect(result).toMatchObject({
allowed: false,
reason: 'DOWNGRADE',
});
});
it('should reject same and lower versions on deploy with the bump message', () => {
for (const [incomingVersion, reason] of [
['1.0.0', 'SAME_VERSION'],
['0.9.0', 'DOWNGRADE'],
] as const) {
const result = service.validateVersionProgression({
incomingVersion,
currentVersion: '1.0.0',
universalIdentifier: 'my-app',
action: 'deploy',
});
expect(result).toMatchObject({
allowed: false,
reason,
message: `Cannot deploy my-app@${incomingVersion}: version must be higher than the currently deployed version 1.0.0. Please bump the version in package.json.`,
});
}
});
});
});
@@ -20,6 +20,19 @@ export type VersionValidationResult =
message: string;
};
export type VersionProgressionFailureReason =
| 'INVALID_INCOMING_VERSION'
| 'SAME_VERSION'
| 'DOWNGRADE';
export type VersionProgressionResult =
| { allowed: true }
| {
allowed: false;
reason: VersionProgressionFailureReason;
message: string;
};
@Injectable()
export class ApplicationVersionValidationService {
constructor(
@@ -89,6 +102,60 @@ export class ApplicationVersionValidationService {
});
}
// A current version that is not valid semver never blocks: there is
// nothing reliable to compare against.
validateVersionProgression({
incomingVersion,
currentVersion,
universalIdentifier,
action,
}: {
incomingVersion: string;
currentVersion: string;
universalIdentifier: string;
action: 'install' | 'deploy';
}): VersionProgressionResult {
if (!isDefined(semver.valid(incomingVersion))) {
return {
allowed: false,
reason: 'INVALID_INCOMING_VERSION',
message: `Invalid version "${incomingVersion}" in package.json. Must be a valid semver version.`,
};
}
if (!isDefined(semver.valid(currentVersion))) {
return { allowed: true };
}
if (action === 'deploy' && semver.lte(incomingVersion, currentVersion)) {
return {
allowed: false,
reason: semver.eq(incomingVersion, currentVersion)
? 'SAME_VERSION'
: 'DOWNGRADE',
message: `Cannot deploy ${universalIdentifier}@${incomingVersion}: version must be higher than the currently deployed version ${currentVersion}. Please bump the version in package.json.`,
};
}
if (action === 'install' && semver.eq(incomingVersion, currentVersion)) {
return {
allowed: false,
reason: 'SAME_VERSION',
message: `${universalIdentifier}@${incomingVersion} is already installed in this workspace.`,
};
}
if (action === 'install' && semver.lt(incomingVersion, currentVersion)) {
return {
allowed: false,
reason: 'DOWNGRADE',
message: `Cannot install ${universalIdentifier}@${incomingVersion}: version ${currentVersion} is already installed and downgrading is not allowed.`,
};
}
return { allowed: true };
}
private validateVersionAgainstRange({
version,
requiredVersionRange,
@@ -1,4 +1,7 @@
import { type VersionValidationFailureReason } from 'src/engine/core-modules/application/application-package/application-version-validation.service';
import {
type VersionProgressionFailureReason,
type VersionValidationFailureReason,
} from 'src/engine/core-modules/application/application-package/application-version-validation.service';
import { ApplicationRegistrationExceptionCode } from 'src/engine/core-modules/application/application-registration/application-registration.exception';
import { ApplicationExceptionCode } from 'src/engine/core-modules/application/application.exception';
@@ -32,3 +35,21 @@ export const VERSION_REASON_TO_APPLICATION_REGISTRATION_EXCEPTION_CODE: Record<
WORKSPACE_INCOMPATIBLE:
ApplicationRegistrationExceptionCode.SERVER_VERSION_INCOMPATIBLE,
};
export const VERSION_PROGRESSION_REASON_TO_INSTALL_EXCEPTION_CODE: Record<
VersionProgressionFailureReason,
ApplicationExceptionCode
> = {
INVALID_INCOMING_VERSION: ApplicationExceptionCode.INVALID_INPUT,
SAME_VERSION: ApplicationExceptionCode.APP_ALREADY_INSTALLED,
DOWNGRADE: ApplicationExceptionCode.CANNOT_DOWNGRADE_APPLICATION,
};
export const VERSION_PROGRESSION_REASON_TO_DEPLOY_EXCEPTION_CODE: Record<
VersionProgressionFailureReason,
ApplicationRegistrationExceptionCode
> = {
INVALID_INCOMING_VERSION: ApplicationRegistrationExceptionCode.INVALID_INPUT,
SAME_VERSION: ApplicationRegistrationExceptionCode.VERSION_ALREADY_EXISTS,
DOWNGRADE: ApplicationRegistrationExceptionCode.VERSION_ALREADY_EXISTS,
};
@@ -7,6 +7,7 @@ import * as bcrypt from 'bcrypt';
import { type Manifest } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
import { ILike, IsNull, type FindOptionsWhere, type Repository } from 'typeorm';
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { v4 } from 'uuid';
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
@@ -31,6 +32,7 @@ import {
type UpdateApplicationRegistrationPayload,
} 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 { buildRegistrationManifestUpdateFields } from 'src/engine/core-modules/application/application-registration/utils/build-registration-manifest-update-fields.util';
import { fromManifestApplicationToDisplayFields } from 'src/engine/core-modules/application/application-registration/utils/from-manifest-application-to-display-fields.util';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { validateRedirectUri } from 'src/engine/core-modules/auth/utils/validate-redirect-uri.util';
@@ -45,8 +47,6 @@ const BCRYPT_SALT_ROUNDS = 10;
const MAX_APPLICATION_REGISTRATIONS_PAGE_SIZE = 100;
// Sized well above the manifest save + variable schema sync duration so the
// lease cannot expire mid-update and let a concurrent refresh interleave.
const APPLICATION_REGISTRATION_UPDATE_LOCK_OPTIONS = {
ttl: 60_000,
ms: 500,
@@ -432,12 +432,25 @@ export class ApplicationRegistrationService {
sourceType,
latestAvailableVersion,
preventVersionDowngrade = false,
additionalFields,
}: {
applicationRegistrationId: string;
manifest: Manifest;
sourceType?: ApplicationRegistrationSourceType;
latestAvailableVersion?: string;
// null clears the stored version; undefined leaves it untouched.
latestAvailableVersion?: string | null;
preventVersionDowngrade?: boolean;
additionalFields?: Partial<
Pick<
ApplicationRegistrationEntity,
| 'name'
| 'sourcePackage'
| 'tarballFileId'
| 'isListed'
| 'isVetted'
| 'ownerWorkspaceId'
>
>;
}): Promise<boolean> {
return this.cacheLockService.withLock(
async () => {
@@ -461,43 +474,35 @@ export class ApplicationRegistrationService {
return false;
}
const displayFields = fromManifestApplicationToDisplayFields(
manifest.application,
);
const manifestUpdateFields = buildRegistrationManifestUpdateFields({
manifestApplication: manifest.application,
existingGalleryImages: existing.galleryImages,
});
// Gallery image files are stored by the source-specific flows (tarball
// upload, dev sync); keep their fileIds for paths that did not change.
const existingFileIdByPath = new Map(
(existing.galleryImages ?? []).map(({ path, fileId }) => [
path,
fileId,
]),
);
// The stored logo file belongs to the previous logo path.
const hasLogoPathChanged =
(manifestUpdateFields.logo ?? null) !== (existing.logo ?? null);
// One transaction so the registration row and its variable schemas
// always come from the same manifest, even when the sync fails midway.
// Partial update in one transaction: the row and its variable schemas
// stay on the same manifest without clobbering columns written by
// flows outside this lock.
await this.applicationRegistrationRepository.manager.transaction(
async (entityManager) => {
await entityManager
.getRepository(ApplicationRegistrationEntity)
.save({
...existing,
name: manifest.application.displayName,
.update(applicationRegistrationId, {
name: manifest.application?.displayName ?? existing.name,
manifest,
...displayFields,
galleryImages: displayFields.galleryImages.map(
(galleryImage) => ({
...galleryImage,
fileId: existingFileIdByPath.get(galleryImage.path) ?? null,
}),
),
...manifestUpdateFields,
...(hasLogoPathChanged && { logoFileId: null }),
...(sourceType !== undefined && { sourceType }),
...(latestAvailableVersion !== undefined && {
latestAvailableVersion,
}),
});
...additionalFields,
} as QueryDeepPartialEntity<ApplicationRegistrationEntity>);
if (isDefined(manifest.application.serverVariables)) {
if (isDefined(manifest.application?.serverVariables)) {
await this.applicationRegistrationVariableService.syncVariableSchemas(
applicationRegistrationId,
manifest.application.serverVariables,
@@ -587,18 +592,38 @@ export class ApplicationRegistrationService {
const isVetted = vettedIdentifiers.has(params.universalIdentifier);
if (isDefined(existing) && isDefined(params.manifest)) {
const isNewVersion = await this.setLatestAvailableVersionIfChanged(
existing.id,
params.latestAvailableVersion ?? null,
);
await this.updateFromManifest({
applicationRegistrationId: existing.id,
manifest: params.manifest,
sourceType: params.sourceType,
latestAvailableVersion: params.latestAvailableVersion,
additionalFields: {
name: params.name,
sourcePackage: params.sourcePackage,
isVetted,
},
});
if (isNewVersion) {
this.emitRegistrationPublishMetric({
isNewRegistration: false,
universalIdentifier: params.universalIdentifier,
name: params.name,
sourceType: params.sourceType,
version: params.latestAvailableVersion,
});
}
return;
}
if (isDefined(existing)) {
const displayFields = fromManifestApplicationToDisplayFields(
params.manifest?.application,
);
const existingFileIdByPath = new Map(
(existing.galleryImages ?? []).map(({ path, fileId }) => [
path,
fileId,
]),
);
const isNewVersion = await this.setLatestAvailableVersionIfChanged(
existing.id,
params.latestAvailableVersion ?? null,
@@ -612,13 +637,11 @@ export class ApplicationRegistrationService {
latestAvailableVersion: params.latestAvailableVersion,
isVetted,
manifest: params.manifest,
...displayFields,
galleryImages: displayFields.galleryImages.map((galleryImage) => ({
...galleryImage,
fileId: existingFileIdByPath.get(galleryImage.path) ?? null,
})),
...fromManifestApplicationToDisplayFields(params.manifest?.application),
});
await this.invalidateMarketplaceAppsCache();
if (isNewVersion) {
this.emitRegistrationPublishMetric({
isNewRegistration: false,
@@ -628,48 +651,42 @@ export class ApplicationRegistrationService {
version: params.latestAvailableVersion,
});
}
} else {
const registration = this.applicationRegistrationRepository.create({
universalIdentifier: params.universalIdentifier,
name: params.name,
sourceType: params.sourceType,
sourcePackage: params.sourcePackage,
latestAvailableVersion: params.latestAvailableVersion,
isListed: true,
isVetted,
manifest: params.manifest,
...fromManifestApplicationToDisplayFields(params.manifest?.application),
oAuthClientId: v4(),
oAuthRedirectUris: [],
oAuthScopes: [],
ownerWorkspaceId: null,
});
await this.applicationRegistrationRepository.save(registration);
this.emitRegistrationPublishMetric({
isNewRegistration: true,
universalIdentifier: params.universalIdentifier,
name: params.name,
sourceType: params.sourceType,
version: params.latestAvailableVersion,
});
return;
}
const registration = this.applicationRegistrationRepository.create({
universalIdentifier: params.universalIdentifier,
name: params.name,
sourceType: params.sourceType,
sourcePackage: params.sourcePackage,
latestAvailableVersion: params.latestAvailableVersion,
isListed: true,
isVetted,
manifest: params.manifest,
...fromManifestApplicationToDisplayFields(params.manifest?.application),
oAuthClientId: v4(),
oAuthRedirectUris: [],
oAuthScopes: [],
ownerWorkspaceId: null,
});
await this.applicationRegistrationRepository.save(registration);
this.emitRegistrationPublishMetric({
isNewRegistration: true,
universalIdentifier: params.universalIdentifier,
name: params.name,
sourceType: params.sourceType,
version: params.latestAvailableVersion,
});
await this.invalidateMarketplaceAppsCache();
if (!isDefined(params.manifest?.application?.serverVariables)) {
return;
}
const registration = await this.findOneByUniversalIdentifier(
params.universalIdentifier,
);
if (!isDefined(registration)) {
return;
}
await this.applicationRegistrationVariableService.syncVariableSchemas(
registration.id,
params.manifest.application.serverVariables,
@@ -5,19 +5,19 @@ import { promises as fs } from 'fs';
import { tmpdir } from 'os';
import { isAbsolute, join, relative, resolve } from 'path';
import semver from 'semver';
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { v4 } from 'uuid';
import { ApplicationVersionValidationService } from 'src/engine/core-modules/application/application-package/application-version-validation.service';
import { VERSION_REASON_TO_APPLICATION_REGISTRATION_EXCEPTION_CODE } from 'src/engine/core-modules/application/application-package/constants/version-reason-to-exception-code.constant';
import {
VERSION_PROGRESSION_REASON_TO_DEPLOY_EXCEPTION_CODE,
VERSION_REASON_TO_APPLICATION_REGISTRATION_EXCEPTION_CODE,
} from 'src/engine/core-modules/application/application-package/constants/version-reason-to-exception-code.constant';
import { extractTarballSecurely } from 'src/engine/core-modules/application/application-package/utils/extract-tarball-securely.util';
import { readJsonFile } from 'src/engine/core-modules/application/application-package/utils/read-json-file.util';
import { resolvePackageContentDir } from 'src/engine/core-modules/application/application-package/utils/tarball-utils';
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service';
import { ApplicationRegistrationAssetService } from 'src/engine/core-modules/application/application-registration/application-registration-asset.service';
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import {
@@ -29,7 +29,7 @@ import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/appli
import { fromManifestApplicationToDisplayFields } from 'src/engine/core-modules/application/application-registration/utils/from-manifest-application-to-display-fields.util';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service';
import type { ApplicationManifest } from 'twenty-shared/application';
import type { ApplicationManifest, Manifest } from 'twenty-shared/application';
@Injectable()
export class ApplicationTarballService {
@@ -41,7 +41,6 @@ export class ApplicationTarballService {
private readonly fileStorageService: FileStorageService,
private readonly applicationRegistrationAssetService: ApplicationRegistrationAssetService,
private readonly applicationService: ApplicationService,
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
private readonly applicationVersionValidationService: ApplicationVersionValidationService,
private readonly applicationRegistrationService: ApplicationRegistrationService,
) {}
@@ -56,48 +55,8 @@ export class ApplicationTarballService {
await fs.mkdir(tempDir, { recursive: true });
try {
const tarballPath = join(tempDir, 'app.tar.gz');
await fs.writeFile(tarballPath, params.tarballBuffer);
const extractDir = join(tempDir, 'extracted');
await fs.mkdir(extractDir, { recursive: true });
await extractTarballSecurely(tarballPath, extractDir);
const contentDir = await resolvePackageContentDir(extractDir);
const manifest = await readJsonFile<{
application?: ApplicationManifest;
}>(contentDir, 'manifest.json');
const packageJson = await readJsonFile<{
version: string;
engines?: { twenty?: string };
}>(contentDir, 'package.json');
if (manifest === null) {
throw new ApplicationRegistrationException(
'manifest.json not found or invalid in tarball',
ApplicationRegistrationExceptionCode.INVALID_INPUT,
);
}
const requiredServerVersion = packageJson?.engines?.twenty;
const versionValidation =
await this.applicationVersionValidationService.validateServerCompatibility(
requiredServerVersion,
);
if (!versionValidation.compatible) {
throw new ApplicationRegistrationException(
versionValidation.message,
VERSION_REASON_TO_APPLICATION_REGISTRATION_EXCEPTION_CODE[
versionValidation.reason
],
);
}
const { contentDir, manifest, packageJson } =
await this.extractAndValidateTarball(tempDir, params.tarballBuffer);
const universalIdentifier =
params.universalIdentifier ?? manifest.application?.universalIdentifier;
@@ -109,120 +68,57 @@ export class ApplicationTarballService {
);
}
let appRegistration = await this.appRegistrationRepository.findOne({
where: {
universalIdentifier,
ownerWorkspaceId: params.ownerWorkspaceId,
const existingRegistration = await this.appRegistrationRepository.findOne(
{
where: {
universalIdentifier,
ownerWorkspaceId: params.ownerWorkspaceId,
},
},
);
const isNewRegistration = !isDefined(existingRegistration);
const previousLatestAvailableVersion =
existingRegistration?.latestAvailableVersion ?? null;
const appRegistration = isDefined(existingRegistration)
? this.assertTarballCanReplaceRegistration({
registration: existingRegistration,
incomingVersion: packageJson?.version,
universalIdentifier,
})
: await this.createTarballRegistration({
universalIdentifier,
manifest,
packageJsonVersion: packageJson?.version ?? null,
ownerWorkspaceId: params.ownerWorkspaceId,
});
const savedFile = await this.storeTarballFile({
appRegistration,
tarballBuffer: params.tarballBuffer,
ownerWorkspaceId: params.ownerWorkspaceId,
});
const isNewRegistration = !isDefined(appRegistration);
const previousLatestAvailableVersion =
appRegistration?.latestAvailableVersion ?? null;
if (isDefined(appRegistration)) {
if (
appRegistration.sourceType !==
ApplicationRegistrationSourceType.LOCAL &&
appRegistration.sourceType !==
ApplicationRegistrationSourceType.TARBALL
) {
throw new ApplicationRegistrationException(
`This app is registered as ${appRegistration.sourceType}. Cannot upload tarball.`,
ApplicationRegistrationExceptionCode.SOURCE_CHANNEL_MISMATCH,
);
}
if (
appRegistration.sourceType ===
ApplicationRegistrationSourceType.TARBALL &&
isDefined(appRegistration.latestAvailableVersion) &&
isDefined(packageJson?.version)
) {
const incomingVersion = packageJson.version;
const currentVersion = appRegistration.latestAvailableVersion;
if (!isDefined(semver.valid(incomingVersion))) {
throw new ApplicationRegistrationException(
`Invalid version "${incomingVersion}" in package.json. Must be a valid semver version.`,
ApplicationRegistrationExceptionCode.INVALID_INPUT,
);
}
if (
isDefined(semver.valid(currentVersion)) &&
semver.lte(incomingVersion, currentVersion)
) {
throw new ApplicationRegistrationException(
`Cannot deploy ${universalIdentifier}@${incomingVersion}: version must be higher than the currently deployed version ${currentVersion}. Please bump the version in package.json.`,
ApplicationRegistrationExceptionCode.VERSION_ALREADY_EXISTS,
);
}
}
} else {
appRegistration = this.appRegistrationRepository.create({
universalIdentifier,
name: manifest.application?.displayName ?? 'Unknown App',
sourceType: ApplicationRegistrationSourceType.TARBALL,
manifest,
...fromManifestApplicationToDisplayFields(manifest.application),
latestAvailableVersion: packageJson?.version ?? null,
await this.applicationRegistrationService.updateFromManifest({
applicationRegistrationId: appRegistration.id,
manifest: manifest as Manifest,
sourceType: ApplicationRegistrationSourceType.TARBALL,
latestAvailableVersion: packageJson?.version ?? null,
additionalFields: {
tarballFileId: savedFile.id,
isListed: false,
isVetted: false,
oAuthClientId: v4(),
oAuthRedirectUris: [],
oAuthScopes: [],
ownerWorkspaceId: params.ownerWorkspaceId,
});
appRegistration =
await this.appRegistrationRepository.save(appRegistration);
}
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId: params.ownerWorkspaceId },
);
const savedFile = await this.fileStorageService.writeFile({
sourceFile: params.tarballBuffer,
resourcePath: `${appRegistration.id}/app.tar.gz`,
fileFolder: FileFolder.AppTarball,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
workspaceId: params.ownerWorkspaceId,
fileId: appRegistration.tarballFileId ?? v4(),
settings: {
isTemporaryFile: false,
toDelete: false,
},
});
await this.appRegistrationRepository.update(appRegistration.id, {
sourceType: ApplicationRegistrationSourceType.TARBALL,
tarballFileId: savedFile.id,
name: manifest.application?.displayName ?? 'Unknown App',
manifest,
...fromManifestApplicationToDisplayFields(manifest.application),
latestAvailableVersion: packageJson?.version ?? null,
isListed: false,
isVetted: false,
ownerWorkspaceId: params.ownerWorkspaceId,
} as QueryDeepPartialEntity<ApplicationRegistrationEntity>);
await this.applicationRegistrationAssetService.storeRegistrationAssets({
applicationRegistrationId: appRegistration.id,
manifestApplication: manifest.application,
readAsset: (path) => this.readAssetFromContentDir(contentDir, path),
});
if (manifest.application?.serverVariables) {
await this.applicationRegistrationVariableService.syncVariableSchemas(
appRegistration.id,
manifest.application.serverVariables,
);
}
this.logger.log(
`Tarball uploaded for app ${universalIdentifier} (registration ${appRegistration.id})`,
);
@@ -249,6 +145,161 @@ export class ApplicationTarballService {
}
}
private async extractAndValidateTarball(
tempDir: string,
tarballBuffer: Buffer,
): Promise<{
contentDir: string;
manifest: { application?: ApplicationManifest };
packageJson: { version: string; engines?: { twenty?: string } } | null;
}> {
const tarballPath = join(tempDir, 'app.tar.gz');
await fs.writeFile(tarballPath, tarballBuffer);
const extractDir = join(tempDir, 'extracted');
await fs.mkdir(extractDir, { recursive: true });
await extractTarballSecurely(tarballPath, extractDir);
const contentDir = await resolvePackageContentDir(extractDir);
const manifest = await readJsonFile<{
application?: ApplicationManifest;
}>(contentDir, 'manifest.json');
const packageJson = await readJsonFile<{
version: string;
engines?: { twenty?: string };
}>(contentDir, 'package.json');
if (manifest === null) {
throw new ApplicationRegistrationException(
'manifest.json not found or invalid in tarball',
ApplicationRegistrationExceptionCode.INVALID_INPUT,
);
}
const versionValidation =
await this.applicationVersionValidationService.validateServerCompatibility(
packageJson?.engines?.twenty,
);
if (!versionValidation.compatible) {
throw new ApplicationRegistrationException(
versionValidation.message,
VERSION_REASON_TO_APPLICATION_REGISTRATION_EXCEPTION_CODE[
versionValidation.reason
],
);
}
return { contentDir, manifest, packageJson };
}
private assertTarballCanReplaceRegistration({
registration,
incomingVersion,
universalIdentifier,
}: {
registration: ApplicationRegistrationEntity;
incomingVersion: string | undefined;
universalIdentifier: string;
}): ApplicationRegistrationEntity {
if (
registration.sourceType !== ApplicationRegistrationSourceType.LOCAL &&
registration.sourceType !== ApplicationRegistrationSourceType.TARBALL
) {
throw new ApplicationRegistrationException(
`This app is registered as ${registration.sourceType}. Cannot upload tarball.`,
ApplicationRegistrationExceptionCode.SOURCE_CHANNEL_MISMATCH,
);
}
if (
registration.sourceType === ApplicationRegistrationSourceType.TARBALL &&
isDefined(registration.latestAvailableVersion) &&
isDefined(incomingVersion)
) {
const progression =
this.applicationVersionValidationService.validateVersionProgression({
incomingVersion,
currentVersion: registration.latestAvailableVersion,
universalIdentifier,
action: 'deploy',
});
if (!progression.allowed) {
throw new ApplicationRegistrationException(
progression.message,
VERSION_PROGRESSION_REASON_TO_DEPLOY_EXCEPTION_CODE[
progression.reason
],
);
}
}
return registration;
}
private async createTarballRegistration({
universalIdentifier,
manifest,
packageJsonVersion,
ownerWorkspaceId,
}: {
universalIdentifier: string;
manifest: { application?: ApplicationManifest };
packageJsonVersion: string | null;
ownerWorkspaceId: string;
}): Promise<ApplicationRegistrationEntity> {
return this.appRegistrationRepository.save(
this.appRegistrationRepository.create({
universalIdentifier,
name: manifest.application?.displayName ?? 'Unknown App',
sourceType: ApplicationRegistrationSourceType.TARBALL,
manifest,
...fromManifestApplicationToDisplayFields(manifest.application),
latestAvailableVersion: packageJsonVersion,
isListed: false,
isVetted: false,
oAuthClientId: v4(),
oAuthRedirectUris: [],
oAuthScopes: [],
ownerWorkspaceId,
}),
);
}
private async storeTarballFile({
appRegistration,
tarballBuffer,
ownerWorkspaceId,
}: {
appRegistration: ApplicationRegistrationEntity;
tarballBuffer: Buffer;
ownerWorkspaceId: string;
}) {
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId: ownerWorkspaceId },
);
return this.fileStorageService.writeFile({
sourceFile: tarballBuffer,
resourcePath: `${appRegistration.id}/app.tar.gz`,
fileFolder: FileFolder.AppTarball,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
workspaceId: ownerWorkspaceId,
fileId: appRegistration.tarballFileId ?? v4(),
settings: {
isTemporaryFile: false,
toDelete: false,
},
});
}
private async readAssetFromContentDir(
contentDir: string,
path: string,
@@ -0,0 +1,33 @@
import { type ApplicationManifest } from 'twenty-shared/application';
import { buildRegistrationManifestUpdateFields } from 'src/engine/core-modules/application/application-registration/utils/build-registration-manifest-update-fields.util';
describe('buildRegistrationManifestUpdateFields', () => {
it('should keep stored fileIds for gallery paths that did not change', () => {
const result = buildRegistrationManifestUpdateFields({
manifestApplication: {
universalIdentifier: 'my-app',
galleryImages: ['images/kept.png', 'images/new.png'],
} as unknown as ApplicationManifest,
existingGalleryImages: [
{ path: 'images/kept.png', fileId: 'file-kept' },
{ path: 'images/removed.png', fileId: 'file-removed' },
],
});
expect(result.galleryImages).toEqual([
{ path: 'images/kept.png', fileId: 'file-kept' },
{ path: 'images/new.png', fileId: null },
]);
});
it('should handle a missing manifest application and no stored images', () => {
const result = buildRegistrationManifestUpdateFields({
manifestApplication: undefined,
existingGalleryImages: null,
});
expect(result.galleryImages).toEqual([]);
expect(result.logo).toBeNull();
});
});
@@ -0,0 +1,29 @@
import { type ApplicationManifest } from 'twenty-shared/application';
import { type ApplicationRegistrationGalleryImage } from 'src/engine/core-modules/application/application-registration/types/application-registration-gallery-image.type';
import { fromManifestApplicationToDisplayFields } from 'src/engine/core-modules/application/application-registration/utils/from-manifest-application-to-display-fields.util';
// Gallery fileIds are kept for unchanged paths so a metadata refresh cannot
// drop already stored assets.
export const buildRegistrationManifestUpdateFields = ({
manifestApplication,
existingGalleryImages,
}: {
manifestApplication: ApplicationManifest | undefined;
existingGalleryImages: ApplicationRegistrationGalleryImage[] | null;
}) => {
const displayFields =
fromManifestApplicationToDisplayFields(manifestApplication);
const existingFileIdByPath = new Map(
(existingGalleryImages ?? []).map(({ path, fileId }) => [path, fileId]),
);
return {
...displayFields,
galleryImages: displayFields.galleryImages.map((galleryImage) => ({
...galleryImage,
fileId: existingFileIdByPath.get(galleryImage.path) ?? null,
})),
};
};
@@ -110,15 +110,15 @@ export class ApplicationUpgradeService {
where: { id: params.appRegistrationId },
});
// LOCAL apps are updated by dev sync and OAUTH_ONLY registrations have no
// code artifacts.
if (
appRegistration.sourceType === ApplicationRegistrationSourceType.LOCAL ||
appRegistration.sourceType ===
ApplicationRegistrationSourceType.TARBALL ||
appRegistration.sourceType ===
ApplicationRegistrationSourceType.OAUTH_ONLY
) {
throw new ApplicationException(
'Cannot upgrade an app installed from a tarball, local source, or OAuth-only registration',
'Cannot upgrade an app installed from a local source or OAuth-only registration',
ApplicationExceptionCode.UPGRADE_FAILED,
);
}
@@ -0,0 +1,37 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Application version progression gate rejects deploying a version lower than the deployed one 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "VERSION_ALREADY_EXISTS",
"userFriendlyMessage": "This version is not higher than the currently deployed version. Please bump the version in package.json before deploying again.",
},
"message": "Cannot deploy 20202020-6b0e-4b1e-9d3a-000000002931@<version>: version must be higher than the currently deployed version <version>. Please bump the version in package.json.",
"name": "UserInputError",
}
`;
exports[`Application version progression gate rejects re-deploying the currently deployed version 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "VERSION_ALREADY_EXISTS",
"userFriendlyMessage": "This version is not higher than the currently deployed version. Please bump the version in package.json before deploying again.",
},
"message": "Cannot deploy 20202020-6b0e-4b1e-9d3a-000000002931@<version>: version must be higher than the currently deployed version <version>. Please bump the version in package.json.",
"name": "UserInputError",
}
`;
exports[`Application version progression gate rejects re-installing the already installed version 1`] = `
{
"extensions": {
"code": "BAD_USER_INPUT",
"subCode": "APP_ALREADY_INSTALLED",
"userFriendlyMessage": "This version of the application is already installed in this workspace.",
},
"message": "20202020-6b0e-4b1e-9d3a-000000002931@<version> is already installed in this workspace.",
"name": "UserInputError",
}
`;
@@ -0,0 +1,97 @@
import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util';
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { createAppTarball } from 'test/integration/metadata/suites/application/utils/create-app-tarball.util';
import { installApplication } from 'test/integration/metadata/suites/application/utils/install-application.util';
import { uploadAppTarball } from 'test/integration/metadata/suites/application/utils/upload-app-tarball.util';
import { scrubSemverVersions } from 'test/utils/scrub-semver-versions.util';
// Fixed identifiers keep the exception messages, and therefore the error
// snapshots, stable across runs.
const APP_UNIVERSAL_IDENTIFIER = '20202020-6b0e-4b1e-9d3a-000000002931';
const ROLE_UNIVERSAL_IDENTIFIER = '20202020-6b0e-4b1e-9d3a-000000002932';
// The upload flow runs cache-lock retries with real delays, so fake timers
// would hang it — mirror the other application suites.
jest.setTimeout(120000);
const buildTarball = (version: string): Promise<Buffer> =>
createAppTarball({
'manifest.json': JSON.stringify(
buildBaseManifest({
appId: APP_UNIVERSAL_IDENTIFIER,
roleId: ROLE_UNIVERSAL_IDENTIFIER,
}),
),
'package.json': JSON.stringify({
name: 'test-version-progression',
version,
}),
});
describe('Application version progression gate', () => {
beforeAll(async () => {
jest.useRealTimers();
const tarball = await buildTarball('1.0.0');
await uploadAppTarball({
tarballBuffer: tarball,
universalIdentifier: APP_UNIVERSAL_IDENTIFIER,
});
});
afterAll(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: APP_UNIVERSAL_IDENTIFIER,
});
jest.useFakeTimers();
});
it('rejects re-deploying the currently deployed version', async () => {
const tarball = await buildTarball('1.0.0');
const { errors } = await uploadAppTarball({
tarballBuffer: tarball,
universalIdentifier: APP_UNIVERSAL_IDENTIFIER,
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({
errors,
normalizeMessage: scrubSemverVersions,
});
});
it('rejects deploying a version lower than the deployed one', async () => {
const tarball = await buildTarball('0.9.0');
const { errors } = await uploadAppTarball({
tarballBuffer: tarball,
universalIdentifier: APP_UNIVERSAL_IDENTIFIER,
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({
errors,
normalizeMessage: scrubSemverVersions,
});
});
it('rejects re-installing the already installed version', async () => {
await installApplication({
input: { universalIdentifier: APP_UNIVERSAL_IDENTIFIER },
});
const { errors } = await installApplication({
input: { universalIdentifier: APP_UNIVERSAL_IDENTIFIER },
expectToFail: true,
});
expectOneNotInternalServerErrorSnapshot({
errors,
normalizeMessage: scrubSemverVersions,
});
});
});