Add twenty version validation (#20227)
as title, server version is checked before app deploy, and app install commands ### New section in publishing doc <img width="1344" height="912" alt="image" src="https://github.com/user-attachments/assets/2a9335e7-0a7a-4973-a2db-f30f03181001" /> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+3
@@ -31,12 +31,15 @@ export class ApplicationExceptionFilter implements ExceptionFilter {
|
||||
case ApplicationExceptionCode.SOURCE_CHANNEL_MISMATCH:
|
||||
case ApplicationExceptionCode.APP_ALREADY_INSTALLED:
|
||||
case ApplicationExceptionCode.CANNOT_DOWNGRADE_APPLICATION:
|
||||
case ApplicationExceptionCode.SERVER_VERSION_INCOMPATIBLE:
|
||||
case ApplicationExceptionCode.INVALID_APP_ENGINE_REQUIREMENT:
|
||||
throw new UserInputError(exception);
|
||||
case ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED:
|
||||
case ApplicationExceptionCode.POST_INSTALL_ERROR:
|
||||
case ApplicationExceptionCode.PRE_INSTALL_ERROR:
|
||||
case ApplicationExceptionCode.TARBALL_EXTRACTION_FAILED:
|
||||
case ApplicationExceptionCode.UPGRADE_FAILED:
|
||||
case ApplicationExceptionCode.INVALID_SERVER_VERSION:
|
||||
throw new InternalServerError(exception);
|
||||
default: {
|
||||
assertUnreachable(exception.code);
|
||||
|
||||
+36
@@ -21,6 +21,10 @@ import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/appli
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { ApplicationPackageFetcherService } from 'src/engine/core-modules/application/application-package/application-package-fetcher.service';
|
||||
import {
|
||||
ApplicationVersionValidationService,
|
||||
type VersionValidationFailureReason,
|
||||
} from 'src/engine/core-modules/application/application-package/application-version-validation.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';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
@@ -39,11 +43,22 @@ import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-func
|
||||
export class ApplicationInstallService {
|
||||
private readonly logger = new Logger(ApplicationInstallService.name);
|
||||
|
||||
private static readonly VERSION_REASON_TO_EXCEPTION_CODE: Record<
|
||||
VersionValidationFailureReason,
|
||||
ApplicationExceptionCode
|
||||
> = {
|
||||
INVALID_REQUIRED_VERSION:
|
||||
ApplicationExceptionCode.INVALID_APP_ENGINE_REQUIREMENT,
|
||||
INVALID_SERVER_VERSION: ApplicationExceptionCode.INVALID_SERVER_VERSION,
|
||||
INCOMPATIBLE: ApplicationExceptionCode.SERVER_VERSION_INCOMPATIBLE,
|
||||
};
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ApplicationRegistrationEntity)
|
||||
private readonly appRegistrationRepository: Repository<ApplicationRegistrationEntity>,
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly applicationPackageFetcherService: ApplicationPackageFetcherService,
|
||||
private readonly applicationVersionValidationService: ApplicationVersionValidationService,
|
||||
private readonly applicationSyncService: ApplicationSyncService,
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
private readonly logicFunctionExecutorService: LogicFunctionExecutorService,
|
||||
@@ -118,6 +133,27 @@ export class ApplicationInstallService {
|
||||
return true;
|
||||
}
|
||||
|
||||
const requiredServerVersion =
|
||||
resolvedPackage.packageJson.engines?.['twenty'];
|
||||
|
||||
const versionValidation =
|
||||
this.applicationVersionValidationService.validateServerCompatibility(
|
||||
requiredServerVersion,
|
||||
);
|
||||
|
||||
if (!versionValidation.compatible) {
|
||||
await this.applicationPackageFetcherService.cleanupExtractedDir(
|
||||
resolvedPackage.cleanupDir,
|
||||
);
|
||||
|
||||
throw new ApplicationException(
|
||||
versionValidation.message,
|
||||
ApplicationInstallService.VERSION_REASON_TO_EXCEPTION_CODE[
|
||||
versionValidation.reason
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
const universalIdentifier = appRegistration.universalIdentifier;
|
||||
|
||||
const existingApplication =
|
||||
|
||||
+9
-2
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
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 { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
@@ -13,7 +14,13 @@ import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty
|
||||
TwentyConfigModule,
|
||||
TypeOrmModule.forFeature([FileEntity, ApplicationEntity]),
|
||||
],
|
||||
providers: [ApplicationPackageFetcherService],
|
||||
exports: [ApplicationPackageFetcherService],
|
||||
providers: [
|
||||
ApplicationPackageFetcherService,
|
||||
ApplicationVersionValidationService,
|
||||
],
|
||||
exports: [
|
||||
ApplicationPackageFetcherService,
|
||||
ApplicationVersionValidationService,
|
||||
],
|
||||
})
|
||||
export class ApplicationPackageModule {}
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import semver from 'semver';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
export type VersionValidationFailureReason =
|
||||
| 'INVALID_REQUIRED_VERSION'
|
||||
| 'INVALID_SERVER_VERSION'
|
||||
| 'INCOMPATIBLE';
|
||||
|
||||
export type VersionValidationResult =
|
||||
| { compatible: true }
|
||||
| {
|
||||
compatible: false;
|
||||
reason: VersionValidationFailureReason;
|
||||
message: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ApplicationVersionValidationService {
|
||||
constructor(private readonly twentyConfigService: TwentyConfigService) {}
|
||||
|
||||
validateServerCompatibility(
|
||||
requiredServerVersion: string | undefined,
|
||||
): VersionValidationResult {
|
||||
if (!isDefined(requiredServerVersion)) {
|
||||
return { compatible: true };
|
||||
}
|
||||
|
||||
if (!isDefined(semver.validRange(requiredServerVersion))) {
|
||||
return {
|
||||
compatible: false,
|
||||
reason: 'INVALID_REQUIRED_VERSION',
|
||||
message: `App manifest declares invalid engines.twenty value "${requiredServerVersion}". Must be a valid semver range.`,
|
||||
};
|
||||
}
|
||||
|
||||
const serverVersion = this.twentyConfigService.get('APP_VERSION');
|
||||
|
||||
if (!isDefined(serverVersion) || !isDefined(semver.valid(serverVersion))) {
|
||||
return {
|
||||
compatible: false,
|
||||
reason: 'INVALID_SERVER_VERSION',
|
||||
message: `Cannot verify server compatibility: APP_VERSION "${serverVersion ?? 'undefined'}" is not a valid semver version. Self-hosted instances must set a valid APP_VERSION.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!semver.satisfies(serverVersion, requiredServerVersion)) {
|
||||
return {
|
||||
compatible: false,
|
||||
reason: 'INCOMPATIBLE',
|
||||
message: `App requires Twenty server ${requiredServerVersion} but this server is ${serverVersion}.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { compatible: true };
|
||||
}
|
||||
}
|
||||
+3
@@ -22,6 +22,9 @@ export class ApplicationRegistrationExceptionFilter implements ExceptionFilter {
|
||||
case ApplicationRegistrationExceptionCode.INVALID_REDIRECT_URI:
|
||||
case ApplicationRegistrationExceptionCode.SOURCE_CHANNEL_MISMATCH:
|
||||
case ApplicationRegistrationExceptionCode.UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED:
|
||||
case ApplicationRegistrationExceptionCode.SERVER_VERSION_INCOMPATIBLE:
|
||||
case ApplicationRegistrationExceptionCode.VERSION_ALREADY_EXISTS:
|
||||
case ApplicationRegistrationExceptionCode.INVALID_APP_ENGINE_REQUIREMENT:
|
||||
throw new UserInputError(exception);
|
||||
default:
|
||||
throw new InternalServerError(exception);
|
||||
|
||||
+9
@@ -13,6 +13,9 @@ export enum ApplicationRegistrationExceptionCode {
|
||||
SOURCE_CHANNEL_MISMATCH = 'SOURCE_CHANNEL_MISMATCH',
|
||||
VARIABLE_NOT_FOUND = 'VARIABLE_NOT_FOUND',
|
||||
VERSION_ALREADY_EXISTS = 'VERSION_ALREADY_EXISTS',
|
||||
SERVER_VERSION_INCOMPATIBLE = 'SERVER_VERSION_INCOMPATIBLE',
|
||||
INVALID_APP_ENGINE_REQUIREMENT = 'INVALID_APP_ENGINE_REQUIREMENT',
|
||||
INVALID_SERVER_VERSION = 'INVALID_SERVER_VERSION',
|
||||
}
|
||||
|
||||
const getExceptionUserFriendlyMessage = (
|
||||
@@ -35,6 +38,12 @@ const getExceptionUserFriendlyMessage = (
|
||||
return msg`Application registration variable not found.`;
|
||||
case ApplicationRegistrationExceptionCode.VERSION_ALREADY_EXISTS:
|
||||
return msg`This version is not higher than the currently deployed version. Please bump the version in package.json before deploying again.`;
|
||||
case ApplicationRegistrationExceptionCode.SERVER_VERSION_INCOMPATIBLE:
|
||||
return msg`This app requires a newer version of the Twenty server. Please upgrade your server or use a compatible app version.`;
|
||||
case ApplicationRegistrationExceptionCode.INVALID_APP_ENGINE_REQUIREMENT:
|
||||
return msg`The app manifest declares an invalid server version requirement.`;
|
||||
case ApplicationRegistrationExceptionCode.INVALID_SERVER_VERSION:
|
||||
return msg`The server's APP_VERSION is not a valid semver version. Self-hosted instances must configure a valid APP_VERSION.`;
|
||||
default:
|
||||
assertUnreachable(code);
|
||||
}
|
||||
|
||||
+2
@@ -6,6 +6,7 @@ import { ApplicationRegistrationResolver } from 'src/engine/core-modules/applica
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
import { ApplicationRegistrationVariableModule } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.module';
|
||||
import { ApplicationTarballService } from 'src/engine/core-modules/application/application-registration/application-tarball.service';
|
||||
import { ApplicationPackageModule } from 'src/engine/core-modules/application/application-package/application-package.module';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { DomainServerConfigModule } from 'src/engine/core-modules/domain/domain-server-config/domain-server-config.module';
|
||||
@@ -25,6 +26,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
|
||||
]),
|
||||
ApplicationRegistrationVariableModule,
|
||||
ApplicationModule,
|
||||
ApplicationPackageModule,
|
||||
DomainServerConfigModule,
|
||||
FeatureFlagModule,
|
||||
PermissionsModule,
|
||||
|
||||
+34
@@ -20,6 +20,10 @@ import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/appli
|
||||
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 {
|
||||
ApplicationVersionValidationService,
|
||||
type VersionValidationFailureReason,
|
||||
} from 'src/engine/core-modules/application/application-package/application-version-validation.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import type { ApplicationManifest } from 'twenty-shared/application';
|
||||
@@ -31,12 +35,25 @@ export const MAX_TARBALL_UPLOAD_SIZE_BYTES = 50 * 1024 * 1024;
|
||||
export class ApplicationTarballService {
|
||||
private readonly logger = new Logger(ApplicationTarballService.name);
|
||||
|
||||
private static readonly VERSION_REASON_TO_EXCEPTION_CODE: Record<
|
||||
VersionValidationFailureReason,
|
||||
ApplicationRegistrationExceptionCode
|
||||
> = {
|
||||
INVALID_REQUIRED_VERSION:
|
||||
ApplicationRegistrationExceptionCode.INVALID_APP_ENGINE_REQUIREMENT,
|
||||
INVALID_SERVER_VERSION:
|
||||
ApplicationRegistrationExceptionCode.INVALID_SERVER_VERSION,
|
||||
INCOMPATIBLE:
|
||||
ApplicationRegistrationExceptionCode.SERVER_VERSION_INCOMPATIBLE,
|
||||
};
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ApplicationRegistrationEntity)
|
||||
private readonly appRegistrationRepository: Repository<ApplicationRegistrationEntity>,
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
|
||||
private readonly applicationVersionValidationService: ApplicationVersionValidationService,
|
||||
) {}
|
||||
|
||||
async uploadTarball(params: {
|
||||
@@ -66,6 +83,7 @@ export class ApplicationTarballService {
|
||||
|
||||
const packageJson = await readJsonFile<{
|
||||
version: string;
|
||||
engines?: { twenty?: string };
|
||||
}>(contentDir, 'package.json');
|
||||
|
||||
if (manifest === null) {
|
||||
@@ -75,6 +93,22 @@ export class ApplicationTarballService {
|
||||
);
|
||||
}
|
||||
|
||||
const requiredServerVersion = packageJson?.engines?.twenty;
|
||||
|
||||
const versionValidation =
|
||||
this.applicationVersionValidationService.validateServerCompatibility(
|
||||
requiredServerVersion,
|
||||
);
|
||||
|
||||
if (!versionValidation.compatible) {
|
||||
throw new ApplicationRegistrationException(
|
||||
versionValidation.message,
|
||||
ApplicationTarballService.VERSION_REASON_TO_EXCEPTION_CODE[
|
||||
versionValidation.reason
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
const universalIdentifier =
|
||||
params.universalIdentifier ?? manifest.application?.universalIdentifier;
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ export enum ApplicationExceptionCode {
|
||||
POST_INSTALL_ERROR = 'POST_INSTALL_ERROR',
|
||||
APP_ALREADY_INSTALLED = 'APP_ALREADY_INSTALLED',
|
||||
CANNOT_DOWNGRADE_APPLICATION = 'CANNOT_DOWNGRADE_APPLICATION',
|
||||
SERVER_VERSION_INCOMPATIBLE = 'SERVER_VERSION_INCOMPATIBLE',
|
||||
INVALID_APP_ENGINE_REQUIREMENT = 'INVALID_APP_ENGINE_REQUIREMENT',
|
||||
INVALID_SERVER_VERSION = 'INVALID_SERVER_VERSION',
|
||||
}
|
||||
|
||||
const getApplicationExceptionUserFriendlyMessage = (
|
||||
@@ -62,6 +65,12 @@ const getApplicationExceptionUserFriendlyMessage = (
|
||||
return msg`This version of the application is already installed in this workspace.`;
|
||||
case ApplicationExceptionCode.CANNOT_DOWNGRADE_APPLICATION:
|
||||
return msg`A higher version of this application is already installed. Downgrading is not allowed.`;
|
||||
case ApplicationExceptionCode.SERVER_VERSION_INCOMPATIBLE:
|
||||
return msg`This app requires a newer version of the Twenty server. Please upgrade your server or use a compatible app version.`;
|
||||
case ApplicationExceptionCode.INVALID_APP_ENGINE_REQUIREMENT:
|
||||
return msg`The app manifest declares an invalid server version requirement.`;
|
||||
case ApplicationExceptionCode.INVALID_SERVER_VERSION:
|
||||
return msg`The server's APP_VERSION is not a valid semver version. Self-hosted instances must configure a valid APP_VERSION.`;
|
||||
default:
|
||||
assertUnreachable(code);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user