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:
+2
-3
@@ -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);
|
||||
|
||||
+2
-1
@@ -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);
|
||||
|
||||
+6
-8
@@ -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,
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { ApplicationRegistrationSourceType } from '~/generated-metadata/graphql';
|
||||
|
||||
export const isUpgradableApplicationSourceType = (
|
||||
sourceType: ApplicationRegistrationSourceType | null | undefined,
|
||||
): boolean =>
|
||||
sourceType === ApplicationRegistrationSourceType.NPM ||
|
||||
sourceType === ApplicationRegistrationSourceType.TARBALL;
|
||||
+46
-22
@@ -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(
|
||||
|
||||
+89
@@ -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.`,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+67
@@ -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,
|
||||
|
||||
+22
-1
@@ -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,
|
||||
};
|
||||
|
||||
+94
-77
@@ -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,
|
||||
|
||||
+198
-147
@@ -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,
|
||||
|
||||
+33
@@ -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();
|
||||
});
|
||||
});
|
||||
+29
@@ -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,
|
||||
})),
|
||||
};
|
||||
};
|
||||
+3
-3
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
+37
@@ -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",
|
||||
}
|
||||
`;
|
||||
+97
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user