App installation workspace version check engines constraint (#22613)

## What

Makes the app-installation version gate **workspace-scoped**. App
installation now validates a manifest's `engines.twenty` requirement
against the version the **target workspace has actually finished
upgrading to**, instead of the instance/server's inferred version.

## Why

The server binary and a given workspace's migration state can diverge.
In a multi-workspace deployment the instance can already report version
`X` while an individual workspace still hasn't completed its
workspace-scoped upgrade commands for `X` (it's mid-upgrade or a
migration failed). Gating on the instance version let an app that
requires `X` install into a workspace whose schema/metadata is
effectively still at `X-1`, which can break the app. The requirement
should be checked against what the *workspace* has completed, not what
the server reports.

## How

- **`UpgradeStatusService.getWorkspaceCompletedVersion(workspaceId)`**
(new): resolves the last fully-completed upgrade version for a workspace
by reading its upgrade cursor and walking the upgrade sequence:
- Returns the cursor's version when the cursor sits on the **last step
of its version segment** and its status is `completed`.
- Otherwise walks backwards to the previous fully-completed version
segment.
- Returns `null` when the cursor is missing, not found in the sequence,
or otherwise uninterpretable.
- **`ApplicationVersionValidationService`**:
- Adds `validateWorkspaceCompatibility({ requiredServerVersion,
workspaceId })`.
- Extracts the shared semver logic into a private
`validateVersionAgainstRange({ version, requiredVersionRange, scope })`
and makes error messages scope-aware (workspace vs. instance).
`validateServerCompatibility` is preserved and now delegates to it.
  - New failure reason `INVALID_WORKSPACE_VERSION`.
- **`ApplicationInstallService`** now calls
`validateWorkspaceCompatibility` with the `workspaceId` instead of
`validateServerCompatibility`.
- **Exception plumbing**: new
`ApplicationExceptionCode.INVALID_WORKSPACE_VERSION`, surfaced as a
`UserInputError` (`BAD_USER_INPUT`) with a user-friendly message ("This
workspace's upgrade state could not be determined…"). The
tarball/registration path maps it onto the existing
`INVALID_SERVER_VERSION` registration code.

## Notes

- **Publishing (app registration) is intentionally not
workspace-gated.** The tarball/registration path
(`ApplicationTarballService`) still uses the instance-level
`validateServerCompatibility` check, not the new workspace-scoped one.
Publishing an app is not tied to any particular workspace's upgrade
state, so there is no workspace version to check at that point — the
workspace-completed-version gate only applies when installing an app
into a specific workspace.

## Testing

- Unit tests for `ApplicationVersionValidationService`
(`validateServerCompatibility` + new `validateWorkspaceCompatibility`)
covering: no requirement, invalid semver range, satisfied/unsatisfied
ranges, and the uninterpretable-cursor case.
- Unit tests for `UpgradeStatusService.getWorkspaceCompletedVersion`
against a three-segment mock upgrade sequence (multi-command version,
instance-only version, workspace-terminated version).
- New integration suite
`failing-app-installation-workspace-version.integration-spec.ts` (+
snapshots) exercising the real install flow: rejects installation when
the workspace hasn't completed the required version, and when the
workspace's upgrade cursor can't be interpreted. Adds a
`create-app-tarball.util.ts` test helper.
This commit is contained in:
Paul Rastoin
2026-07-07 12:05:06 +02:00
committed by GitHub
parent cabe5545ae
commit 628ab153a8
19 changed files with 849 additions and 45 deletions
@@ -34,7 +34,9 @@ export class ApplicationExceptionFilter implements ExceptionFilter {
case ApplicationExceptionCode.APP_ALREADY_INSTALLED:
case ApplicationExceptionCode.CANNOT_DOWNGRADE_APPLICATION:
case ApplicationExceptionCode.SERVER_VERSION_INCOMPATIBLE:
case ApplicationExceptionCode.WORKSPACE_VERSION_INCOMPATIBLE:
case ApplicationExceptionCode.INVALID_APP_ENGINE_REQUIREMENT:
case ApplicationExceptionCode.INVALID_WORKSPACE_VERSION:
throw new UserInputError(exception);
case ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED:
case ApplicationExceptionCode.POST_INSTALL_ERROR:
@@ -50,7 +50,11 @@ export class ApplicationInstallService {
INVALID_REQUIRED_VERSION:
ApplicationExceptionCode.INVALID_APP_ENGINE_REQUIREMENT,
INVALID_SERVER_VERSION: ApplicationExceptionCode.INVALID_SERVER_VERSION,
INCOMPATIBLE: ApplicationExceptionCode.SERVER_VERSION_INCOMPATIBLE,
INVALID_WORKSPACE_VERSION:
ApplicationExceptionCode.INVALID_WORKSPACE_VERSION,
INSTANCE_INCOMPATIBLE: ApplicationExceptionCode.SERVER_VERSION_INCOMPATIBLE,
WORKSPACE_INCOMPATIBLE:
ApplicationExceptionCode.WORKSPACE_VERSION_INCOMPATIBLE,
};
constructor(
@@ -139,8 +143,11 @@ export class ApplicationInstallService {
resolvedPackage.packageJson.engines?.['twenty'];
const versionValidation =
await this.applicationVersionValidationService.validateServerCompatibility(
requiredServerVersion,
await this.applicationVersionValidationService.validateWorkspaceCompatibility(
{
requiredServerVersion,
workspaceId: params.workspaceId,
},
);
if (!versionValidation.compatible) {
@@ -0,0 +1,151 @@
import { Test } from '@nestjs/testing';
import { ApplicationVersionValidationService } from 'src/engine/core-modules/application/application-package/application-version-validation.service';
import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service';
import { UpgradeStatusService } from 'src/engine/core-modules/upgrade/services/upgrade-status.service';
describe('ApplicationVersionValidationService', () => {
let service: ApplicationVersionValidationService;
let getInferredVersion: jest.Mock;
let getWorkspaceCompletedVersion: jest.Mock;
beforeEach(async () => {
getInferredVersion = jest.fn();
getWorkspaceCompletedVersion = jest.fn();
const module = await Test.createTestingModule({
providers: [
ApplicationVersionValidationService,
{
provide: UpgradeMigrationService,
useValue: { getInferredVersion },
},
{
provide: UpgradeStatusService,
useValue: { getWorkspaceCompletedVersion },
},
],
}).compile();
service = module.get(ApplicationVersionValidationService);
});
describe('validateServerCompatibility', () => {
it('should be compatible when no required version is declared', async () => {
await expect(
service.validateServerCompatibility(undefined),
).resolves.toEqual({ compatible: true });
});
it('should reject an invalid semver range', async () => {
const result = await service.validateServerCompatibility('not-semver');
expect(result).toMatchObject({
compatible: false,
reason: 'INVALID_REQUIRED_VERSION',
});
});
it('should be compatible when the inferred instance version satisfies the range', async () => {
getInferredVersion.mockResolvedValue('2.19.0');
await expect(
service.validateServerCompatibility('>=2.19.0'),
).resolves.toEqual({ compatible: true });
});
it('should be incompatible when the inferred instance version does not satisfy the range', async () => {
getInferredVersion.mockResolvedValue('2.18.0');
const result = await service.validateServerCompatibility('>=2.19.0');
expect(result).toMatchObject({
compatible: false,
reason: 'INSTANCE_INCOMPATIBLE',
});
});
it('should fail when the inferred instance version is not valid semver', async () => {
getInferredVersion.mockResolvedValue(null);
const result = await service.validateServerCompatibility('>=2.19.0');
expect(result).toMatchObject({
compatible: false,
reason: 'INVALID_SERVER_VERSION',
});
});
});
describe('validateWorkspaceCompatibility', () => {
it('should be compatible when no required version is declared', async () => {
await expect(
service.validateWorkspaceCompatibility({
requiredServerVersion: undefined,
workspaceId: 'ws-1',
}),
).resolves.toEqual({ compatible: true });
expect(getWorkspaceCompletedVersion).not.toHaveBeenCalled();
});
it('should reject an invalid semver range', async () => {
const result = await service.validateWorkspaceCompatibility({
requiredServerVersion: 'not-semver',
workspaceId: 'ws-1',
});
expect(result).toMatchObject({
compatible: false,
reason: 'INVALID_REQUIRED_VERSION',
});
});
it('should be compatible when the workspace completed version satisfies the range', async () => {
getWorkspaceCompletedVersion.mockResolvedValue('2.19.0');
await expect(
service.validateWorkspaceCompatibility({
requiredServerVersion: '>=2.19.0',
workspaceId: 'ws-1',
}),
).resolves.toEqual({ compatible: true });
expect(getWorkspaceCompletedVersion).toHaveBeenCalledWith('ws-1');
expect(getInferredVersion).not.toHaveBeenCalled();
});
it('should be incompatible when the workspace has only completed an earlier version', async () => {
getWorkspaceCompletedVersion.mockResolvedValue('2.18.0');
const result = await service.validateWorkspaceCompatibility({
requiredServerVersion: '>=2.19.0',
workspaceId: 'ws-1',
});
expect(result).toMatchObject({
compatible: false,
reason: 'WORKSPACE_INCOMPATIBLE',
message:
'App requires Twenty server >=2.19.0 but this workspace has only completed the upgrade to 2.18.0.',
});
});
it('should be incompatible when the workspace has no interpretable upgrade cursor', async () => {
getWorkspaceCompletedVersion.mockResolvedValue(null);
const result = await service.validateWorkspaceCompatibility({
requiredServerVersion: '>=2.19.0',
workspaceId: 'ws-1',
});
expect(result).toMatchObject({
compatible: false,
reason: 'INVALID_WORKSPACE_VERSION',
message:
'Cannot determine the completed upgrade version for workspace ws-1: no interpretable upgrade cursor found.',
});
expect(getInferredVersion).not.toHaveBeenCalled();
});
});
});
@@ -1,13 +1,16 @@
import { Injectable } from '@nestjs/common';
import semver from 'semver';
import { isDefined } from 'twenty-shared/utils';
import { UpgradeMigrationService } from 'src/engine/core-modules/upgrade/services/upgrade-migration.service';
import { UpgradeStatusService } from 'src/engine/core-modules/upgrade/services/upgrade-status.service';
import { isDefined } from 'twenty-shared/utils';
export type VersionValidationFailureReason =
| 'INVALID_REQUIRED_VERSION'
| 'INVALID_SERVER_VERSION'
| 'INCOMPATIBLE';
| 'INVALID_WORKSPACE_VERSION'
| 'INSTANCE_INCOMPATIBLE'
| 'WORKSPACE_INCOMPATIBLE';
export type VersionValidationResult =
| { compatible: true }
@@ -21,6 +24,7 @@ export type VersionValidationResult =
export class ApplicationVersionValidationService {
constructor(
private readonly upgradeMigrationService: UpgradeMigrationService,
private readonly upgradeStatusService: UpgradeStatusService,
) {}
async validateServerCompatibility(
@@ -41,25 +45,87 @@ export class ApplicationVersionValidationService {
const inferredServerVersion =
await this.upgradeMigrationService.getInferredVersion();
if (
!isDefined(inferredServerVersion) ||
!isDefined(semver.valid(inferredServerVersion))
) {
return this.validateVersionAgainstRange({
version: inferredServerVersion,
requiredVersionRange: requiredServerVersion,
scope: 'instance',
});
}
async validateWorkspaceCompatibility({
requiredServerVersion,
workspaceId,
}: {
requiredServerVersion: string | undefined;
workspaceId: string;
}): Promise<VersionValidationResult> {
if (!isDefined(requiredServerVersion)) {
return { compatible: true };
}
if (!isDefined(semver.validRange(requiredServerVersion))) {
return {
compatible: false,
reason: 'INVALID_SERVER_VERSION',
message: `Cannot verify server compatibility: inferred server version "${inferredServerVersion ?? 'undefined'}" is not a valid semver version.`,
reason: 'INVALID_REQUIRED_VERSION',
message: `App manifest declares invalid engines.twenty value "${requiredServerVersion}". Must be a valid semver range.`,
};
}
if (!semver.satisfies(inferredServerVersion, requiredServerVersion)) {
const workspaceCompletedVersion =
await this.upgradeStatusService.getWorkspaceCompletedVersion(workspaceId);
if (!isDefined(workspaceCompletedVersion)) {
return {
compatible: false,
reason: 'INCOMPATIBLE',
message: `App requires Twenty server ${requiredServerVersion} but this server is ${inferredServerVersion}.`,
reason: 'INVALID_WORKSPACE_VERSION',
message: `Cannot determine the completed upgrade version for workspace ${workspaceId}: no interpretable upgrade cursor found.`,
};
}
return this.validateVersionAgainstRange({
version: workspaceCompletedVersion,
requiredVersionRange: requiredServerVersion,
scope: 'workspace',
});
}
private validateVersionAgainstRange({
version,
requiredVersionRange,
scope,
}: {
version: string | null;
requiredVersionRange: string;
scope: 'instance' | 'workspace';
}): VersionValidationResult {
if (!isDefined(version) || !isDefined(semver.valid(version))) {
return scope === 'workspace'
? {
compatible: false,
reason: 'INVALID_WORKSPACE_VERSION',
message: `Cannot verify workspace compatibility: workspace completed version "${version ?? 'undefined'}" is not a valid semver version.`,
}
: {
compatible: false,
reason: 'INVALID_SERVER_VERSION',
message: `Cannot verify server compatibility: inferred server version "${version ?? 'undefined'}" is not a valid semver version.`,
};
}
if (!semver.satisfies(version, requiredVersionRange)) {
return scope === 'workspace'
? {
compatible: false,
reason: 'WORKSPACE_INCOMPATIBLE',
message: `App requires Twenty server ${requiredVersionRange} but this workspace has only completed the upgrade to ${version}.`,
}
: {
compatible: false,
reason: 'INSTANCE_INCOMPATIBLE',
message: `App requires Twenty server ${requiredVersionRange} but this server is ${version}.`,
};
}
return { compatible: true };
}
}
@@ -12,6 +12,14 @@ import { Repository } from 'typeorm';
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { v4 } from 'uuid';
import {
ApplicationVersionValidationService,
type VersionValidationFailureReason,
} from 'src/engine/core-modules/application/application-package/application-version-validation.service';
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 { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
import {
ApplicationRegistrationException,
@@ -19,17 +27,9 @@ import {
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import { fromManifestApplicationToDisplayFields } from 'src/engine/core-modules/application/application-registration/utils/from-manifest-application-to-display-fields.util';
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';
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service';
@Injectable()
export class ApplicationTarballService {
@@ -43,7 +43,11 @@ export class ApplicationTarballService {
ApplicationRegistrationExceptionCode.INVALID_APP_ENGINE_REQUIREMENT,
INVALID_SERVER_VERSION:
ApplicationRegistrationExceptionCode.INVALID_SERVER_VERSION,
INCOMPATIBLE:
INVALID_WORKSPACE_VERSION:
ApplicationRegistrationExceptionCode.INVALID_SERVER_VERSION,
INSTANCE_INCOMPATIBLE:
ApplicationRegistrationExceptionCode.SERVER_VERSION_INCOMPATIBLE,
WORKSPACE_INCOMPATIBLE:
ApplicationRegistrationExceptionCode.SERVER_VERSION_INCOMPATIBLE,
};
@@ -34,7 +34,9 @@ const applicationExceptionCodeToHttpStatus = (
case ApplicationExceptionCode.APP_ALREADY_INSTALLED:
case ApplicationExceptionCode.CANNOT_DOWNGRADE_APPLICATION:
case ApplicationExceptionCode.SERVER_VERSION_INCOMPATIBLE:
case ApplicationExceptionCode.WORKSPACE_VERSION_INCOMPATIBLE:
case ApplicationExceptionCode.INVALID_APP_ENGINE_REQUIREMENT:
case ApplicationExceptionCode.INVALID_WORKSPACE_VERSION:
return 400;
case ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED:
case ApplicationExceptionCode.POST_INSTALL_ERROR:
@@ -24,8 +24,10 @@ export enum ApplicationExceptionCode {
APP_ALREADY_INSTALLED = 'APP_ALREADY_INSTALLED',
CANNOT_DOWNGRADE_APPLICATION = 'CANNOT_DOWNGRADE_APPLICATION',
SERVER_VERSION_INCOMPATIBLE = 'SERVER_VERSION_INCOMPATIBLE',
WORKSPACE_VERSION_INCOMPATIBLE = 'WORKSPACE_VERSION_INCOMPATIBLE',
INVALID_APP_ENGINE_REQUIREMENT = 'INVALID_APP_ENGINE_REQUIREMENT',
INVALID_SERVER_VERSION = 'INVALID_SERVER_VERSION',
INVALID_WORKSPACE_VERSION = 'INVALID_WORKSPACE_VERSION',
APPLICATION_INSTALLATION_FAILED = 'APPLICATION_INSTALLATION_FAILED',
}
@@ -69,10 +71,14 @@ const getApplicationExceptionUserFriendlyMessage = (
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.WORKSPACE_VERSION_INCOMPATIBLE:
return msg`This app requires a newer version than this workspace has finished upgrading to. Please try again once the workspace upgrade completes.`;
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.`;
case ApplicationExceptionCode.INVALID_WORKSPACE_VERSION:
return msg`This workspace's upgrade state could not be determined. Please try again once the workspace has finished upgrading.`;
case ApplicationExceptionCode.APPLICATION_INSTALLATION_FAILED:
return msg`We couldn't install this application because some of its metadata could not be applied to your workspace.`;
default: