Allow CLI dev mode on catalog-synced apps without mutating the shared registration (#22756)

This commit is contained in:
martmull
2026-07-10 12:10:00 +02:00
committed by GitHub
parent f8d3555fe7
commit 1c8b8970fd
14 changed files with 780 additions and 292 deletions
@@ -5,6 +5,7 @@ import { ApplicationManifestModule } from 'src/engine/core-modules/application/a
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { ApplicationPackageModule } from 'src/engine/core-modules/application/application-package/application-package.module';
import { ApplicationDevelopmentResolver } from 'src/engine/core-modules/application/application-development/application-development.resolver';
import { ApplicationDevelopmentService } from 'src/engine/core-modules/application/application-development/application-development.service';
import { CacheLockModule } from 'src/engine/core-modules/cache-lock/cache-lock.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
@@ -28,6 +29,7 @@ import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/wor
],
providers: [
ApplicationDevelopmentResolver,
ApplicationDevelopmentService,
WorkspaceMigrationGraphqlApiExceptionInterceptor,
],
})
@@ -8,38 +8,19 @@ import { Args, Mutation } from '@nestjs/graphql';
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
import { PermissionFlagType } from 'twenty-shared/constants';
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { ApplicationDevelopmentService } from 'src/engine/core-modules/application/application-development/application-development.service';
import { ApplicationInput } from 'src/engine/core-modules/application/application-development/dtos/application.input';
import { CreateDevelopmentApplicationInput } from 'src/engine/core-modules/application/application-development/dtos/create-development-application.input';
import { DevelopmentApplicationDTO } from 'src/engine/core-modules/application/application-development/dtos/development-application.dto';
import { UploadApplicationFileInput } from 'src/engine/core-modules/application/application-development/dtos/upload-application-file.input';
import { WorkspaceMigrationDTO } from 'src/engine/core-modules/application/application-development/dtos/workspace-migration.dto';
import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter';
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service';
import { resolveManifestAssetUrls } from 'src/engine/core-modules/application/application-marketplace/utils/resolve-manifest-asset-urls.util';
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 { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service';
import { validateFilePath } from 'src/engine/core-modules/file-storage/utils/validate-file-path.util';
import { FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { SdkClientGenerationService } from 'src/engine/core-modules/sdk-client/sdk-client-generation.service';
import { ThrottlerService } from 'src/engine/core-modules/throttler/throttler.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
@@ -47,11 +28,6 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
const APP_DEV_RATE_LIMIT_MAX = 30;
const APP_DEV_RATE_LIMIT_WINDOW_MS = 30_000;
const APP_SYNC_LOCK_OPTIONS = { ttl: 60_000, ms: 500, maxRetries: 120 };
@UsePipes(ResolverValidationPipe)
@MetadataResolver()
@UseInterceptors(WorkspaceMigrationGraphqlApiExceptionInterceptor)
@@ -62,16 +38,7 @@ const APP_SYNC_LOCK_OPTIONS = { ttl: 60_000, ms: 500, maxRetries: 120 };
)
export class ApplicationDevelopmentResolver {
constructor(
private readonly applicationService: ApplicationService,
private readonly applicationSyncService: ApplicationSyncService,
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
private readonly applicationVersionValidationService: ApplicationVersionValidationService,
private readonly fileStorageService: FileStorageService,
private readonly sdkClientGenerationService: SdkClientGenerationService,
private readonly twentyConfigService: TwentyConfigService,
private readonly throttlerService: ThrottlerService,
private readonly cacheLockService: CacheLockService,
private readonly applicationDevelopmentService: ApplicationDevelopmentService,
) {}
@Mutation(() => DevelopmentApplicationDTO)
@@ -79,36 +46,11 @@ export class ApplicationDevelopmentResolver {
@Args() { universalIdentifier, name }: CreateDevelopmentApplicationInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<DevelopmentApplicationDTO> {
await this.throttlePerApplication(universalIdentifier, workspaceId);
const applicationRegistrationId =
await this.findApplicationRegistrationId(universalIdentifier);
const existing = await this.applicationService.findByUniversalIdentifier({
universalIdentifier,
workspaceId,
});
if (existing) {
return {
id: existing.id,
universalIdentifier: existing.universalIdentifier,
};
}
const application = await this.applicationService.create({
return this.applicationDevelopmentService.createDevelopmentApplication({
universalIdentifier,
name,
sourcePath: universalIdentifier,
sourceType: ApplicationRegistrationSourceType.LOCAL,
applicationRegistrationId,
workspaceId,
});
return {
id: application.id,
universalIdentifier: application.universalIdentifier,
};
}
@Mutation(() => WorkspaceMigrationDTO)
@@ -116,101 +58,11 @@ export class ApplicationDevelopmentResolver {
@Args() { manifest, dryRun }: ApplicationInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<WorkspaceMigrationDTO> {
await this.throttlePerApplication(
manifest.application.universalIdentifier,
workspaceId,
);
const versionValidation =
await this.applicationVersionValidationService.validateWorkspaceCompatibility(
{
requiredServerVersion:
manifest.application.requiredServerVersionRange ?? undefined,
workspaceId,
},
);
if (!versionValidation.compatible) {
throw new ApplicationException(
versionValidation.message,
VERSION_REASON_TO_APPLICATION_EXCEPTION_CODE[versionValidation.reason],
);
}
if (dryRun === true) {
const { workspaceMigration } =
await this.applicationSyncService.synchronizeFromManifest({
workspaceId,
manifest,
dryRun: true,
});
return {
applicationUniversalIdentifier:
workspaceMigration.applicationUniversalIdentifier,
actions: workspaceMigration.actions,
};
}
return this.cacheLockService.withLock(
() => this.applyManifestSync(manifest, workspaceId),
`app-sync:${workspaceId}`,
APP_SYNC_LOCK_OPTIONS,
);
}
private async applyManifestSync(
manifest: ApplicationInput['manifest'],
workspaceId: string,
): Promise<WorkspaceMigrationDTO> {
const applicationRegistrationId = await this.findApplicationRegistrationId(
manifest.application.universalIdentifier,
);
const application = await this.applicationService.findByUniversalIdentifier(
{
universalIdentifier: manifest.application.universalIdentifier,
workspaceId,
},
);
if (!isDefined(application)) {
throw new ApplicationException(
`Application "${manifest.application.universalIdentifier}" not found in workspace "${workspaceId}". Run createDevelopmentApplication first.`,
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
);
}
const isFirstSync = !isDefined(application.version);
const { workspaceMigration, hasSchemaMetadataChanged } =
await this.applicationSyncService.synchronizeFromManifest({
workspaceId,
manifest,
applicationRegistrationId,
});
if (isFirstSync || hasSchemaMetadataChanged) {
await this.sdkClientGenerationService.generateSdkClientForApplication({
workspaceId,
applicationId: application.id,
applicationUniversalIdentifier:
manifest.application.universalIdentifier,
});
}
await this.syncRegistrationMetadata(
applicationRegistrationId,
return this.applicationDevelopmentService.syncApplication({
manifest,
dryRun,
workspaceId,
application.id,
);
return {
applicationUniversalIdentifier:
workspaceMigration.applicationUniversalIdentifier,
actions: workspaceMigration.actions,
};
});
}
@Mutation(() => FileDTO)
@@ -226,119 +78,12 @@ export class ApplicationDevelopmentResolver {
filePath,
}: UploadApplicationFileInput,
): Promise<FileDTO> {
await this.throttlePerApplication(
applicationUniversalIdentifier,
return this.applicationDevelopmentService.uploadApplicationFile({
workspaceId,
);
const allowedApplicationFileFolders: FileFolder[] = [
FileFolder.BuiltLogicFunction,
FileFolder.BuiltFrontComponent,
FileFolder.PublicAsset,
FileFolder.Source,
FileFolder.Dependencies,
];
if (!allowedApplicationFileFolders.includes(fileFolder)) {
throw new ApplicationException(
`Invalid fileFolder for application file upload. Allowed values: ${allowedApplicationFileFolders.join(', ')}`,
ApplicationExceptionCode.INVALID_INPUT,
);
}
const pathValidationResult = validateFilePath({
resourcePath: filePath,
fileFolder,
});
if (!pathValidationResult.isValid) {
throw new ApplicationException(
pathValidationResult.error,
ApplicationExceptionCode.INVALID_INPUT,
);
}
const application = await this.applicationService.findByUniversalIdentifier(
{
universalIdentifier: applicationUniversalIdentifier,
workspaceId,
},
);
if (!isDefined(application)) {
throw new ApplicationException(
'Application not found in workspace.',
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
);
}
const buffer = await streamToBuffer(createReadStream());
return await this.fileStorageService.writeFile({
sourceFile: buffer,
fileFolder,
applicationUniversalIdentifier,
workspaceId,
resourcePath: filePath,
settings: { isTemporaryFile: false, toDelete: false },
fileFolder,
filePath,
getFileBuffer: () => streamToBuffer(createReadStream()),
});
}
private async throttlePerApplication(
applicationIdentifier: string,
workspaceId: string,
): Promise<void> {
await this.throttlerService.tokenBucketThrottleOrThrow(
`app-dev:${workspaceId}:${applicationIdentifier}`,
1,
APP_DEV_RATE_LIMIT_MAX,
APP_DEV_RATE_LIMIT_WINDOW_MS,
);
}
private async findApplicationRegistrationId(
universalIdentifier: string,
): Promise<string> {
const existingRegistration =
await this.applicationRegistrationService.findOneByUniversalIdentifier(
universalIdentifier,
);
if (!existingRegistration) {
throw new ApplicationException(
`No registration found for "${universalIdentifier}". Create one first with createApplicationRegistration.`,
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
);
}
return existingRegistration.id;
}
private async syncRegistrationMetadata(
applicationRegistrationId: string,
manifest: ApplicationInput['manifest'],
workspaceId: string,
applicationId: string,
): Promise<void> {
const serverUrl = this.twentyConfigService.get('SERVER_URL');
const manifestWithResolvedUrls = resolveManifestAssetUrls(
manifest,
(filePath) =>
`${serverUrl}/public-assets/${workspaceId}/${applicationId}/${filePath}`,
);
await this.applicationRegistrationService.updateFromManifest({
applicationRegistrationId,
manifest: manifestWithResolvedUrls,
sourceType: ApplicationRegistrationSourceType.LOCAL,
});
if (manifest.application.serverVariables) {
await this.applicationRegistrationVariableService.syncVariableSchemas(
applicationRegistrationId,
manifest.application.serverVariables,
);
}
}
}
@@ -0,0 +1,340 @@
import { Injectable } from '@nestjs/common';
import { FileFolder } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type ApplicationInput } from 'src/engine/core-modules/application/application-development/dtos/application.input';
import { type DevelopmentApplicationDTO } from 'src/engine/core-modules/application/application-development/dtos/development-application.dto';
import { type WorkspaceMigrationDTO } from 'src/engine/core-modules/application/application-development/dtos/workspace-migration.dto';
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service';
import { resolveManifestAssetUrls } from 'src/engine/core-modules/application/application-marketplace/utils/resolve-manifest-asset-urls.util';
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 { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.service';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum';
import {
ApplicationException,
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { CacheLockService } from 'src/engine/core-modules/cache-lock/cache-lock.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/services/file-storage.service';
import { validateFilePath } from 'src/engine/core-modules/file-storage/utils/validate-file-path.util';
import { type FileDTO } from 'src/engine/core-modules/file/dtos/file.dto';
import { SdkClientGenerationService } from 'src/engine/core-modules/sdk-client/sdk-client-generation.service';
import { ThrottlerService } from 'src/engine/core-modules/throttler/throttler.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
const APP_DEV_RATE_LIMIT_MAX = 30;
const APP_DEV_RATE_LIMIT_WINDOW_MS = 30_000;
const APP_SYNC_LOCK_OPTIONS = { ttl: 60_000, ms: 500, maxRetries: 120 };
const ALLOWED_APPLICATION_FILE_FOLDERS: FileFolder[] = [
FileFolder.BuiltLogicFunction,
FileFolder.BuiltFrontComponent,
FileFolder.PublicAsset,
FileFolder.Source,
FileFolder.Dependencies,
];
@Injectable()
export class ApplicationDevelopmentService {
constructor(
private readonly applicationService: ApplicationService,
private readonly applicationSyncService: ApplicationSyncService,
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
private readonly applicationVersionValidationService: ApplicationVersionValidationService,
private readonly fileStorageService: FileStorageService,
private readonly sdkClientGenerationService: SdkClientGenerationService,
private readonly twentyConfigService: TwentyConfigService,
private readonly throttlerService: ThrottlerService,
private readonly cacheLockService: CacheLockService,
) {}
async createDevelopmentApplication({
universalIdentifier,
name,
workspaceId,
}: {
universalIdentifier: string;
name: string;
workspaceId: string;
}): Promise<DevelopmentApplicationDTO> {
await this.throttlePerApplication(universalIdentifier, workspaceId);
const applicationRegistrationId =
await this.findApplicationRegistrationId(universalIdentifier);
const existing = await this.applicationService.findByUniversalIdentifier({
universalIdentifier,
workspaceId,
});
if (existing) {
return {
id: existing.id,
universalIdentifier: existing.universalIdentifier,
};
}
const application = await this.applicationService.create({
universalIdentifier,
name,
sourcePath: universalIdentifier,
sourceType: ApplicationRegistrationSourceType.LOCAL,
applicationRegistrationId,
workspaceId,
});
return {
id: application.id,
universalIdentifier: application.universalIdentifier,
};
}
async syncApplication({
manifest,
dryRun,
workspaceId,
}: {
manifest: ApplicationInput['manifest'];
dryRun?: boolean;
workspaceId: string;
}): Promise<WorkspaceMigrationDTO> {
await this.throttlePerApplication(
manifest.application.universalIdentifier,
workspaceId,
);
const versionValidation =
await this.applicationVersionValidationService.validateWorkspaceCompatibility(
{
requiredServerVersion:
manifest.application.requiredServerVersionRange ?? undefined,
workspaceId,
},
);
if (!versionValidation.compatible) {
throw new ApplicationException(
versionValidation.message,
VERSION_REASON_TO_APPLICATION_EXCEPTION_CODE[versionValidation.reason],
);
}
if (dryRun === true) {
const { workspaceMigration } =
await this.applicationSyncService.synchronizeFromManifest({
workspaceId,
manifest,
dryRun: true,
});
return {
applicationUniversalIdentifier:
workspaceMigration.applicationUniversalIdentifier,
actions: workspaceMigration.actions,
};
}
return this.cacheLockService.withLock(
() => this.applyManifestSync(manifest, workspaceId),
`app-sync:${workspaceId}`,
APP_SYNC_LOCK_OPTIONS,
);
}
async uploadApplicationFile({
workspaceId,
applicationUniversalIdentifier,
fileFolder,
filePath,
getFileBuffer,
}: {
workspaceId: string;
applicationUniversalIdentifier: string;
fileFolder: FileFolder;
filePath: string;
// Lazy so rejected or rate-limited uploads are not buffered into memory.
getFileBuffer: () => Promise<Buffer>;
}): Promise<FileDTO> {
await this.throttlePerApplication(
applicationUniversalIdentifier,
workspaceId,
);
if (!ALLOWED_APPLICATION_FILE_FOLDERS.includes(fileFolder)) {
throw new ApplicationException(
`Invalid fileFolder for application file upload. Allowed values: ${ALLOWED_APPLICATION_FILE_FOLDERS.join(', ')}`,
ApplicationExceptionCode.INVALID_INPUT,
);
}
const pathValidationResult = validateFilePath({
resourcePath: filePath,
fileFolder,
});
if (!pathValidationResult.isValid) {
throw new ApplicationException(
pathValidationResult.error,
ApplicationExceptionCode.INVALID_INPUT,
);
}
const application = await this.applicationService.findByUniversalIdentifier(
{
universalIdentifier: applicationUniversalIdentifier,
workspaceId,
},
);
if (!isDefined(application)) {
throw new ApplicationException(
'Application not found in workspace.',
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
);
}
return await this.fileStorageService.writeFile({
sourceFile: await getFileBuffer(),
fileFolder,
applicationUniversalIdentifier,
workspaceId,
resourcePath: filePath,
settings: { isTemporaryFile: false, toDelete: false },
});
}
private async applyManifestSync(
manifest: ApplicationInput['manifest'],
workspaceId: string,
): Promise<WorkspaceMigrationDTO> {
const applicationRegistrationId = await this.findApplicationRegistrationId(
manifest.application.universalIdentifier,
);
const application = await this.applicationService.findByUniversalIdentifier(
{
universalIdentifier: manifest.application.universalIdentifier,
workspaceId,
},
);
if (!isDefined(application)) {
throw new ApplicationException(
`Application "${manifest.application.universalIdentifier}" not found in workspace "${workspaceId}". Run createDevelopmentApplication first.`,
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
);
}
const isFirstSync = !isDefined(application.version);
const { workspaceMigration, hasSchemaMetadataChanged } =
await this.applicationSyncService.synchronizeFromManifest({
workspaceId,
manifest,
applicationRegistrationId,
});
if (isFirstSync || hasSchemaMetadataChanged) {
await this.sdkClientGenerationService.generateSdkClientForApplication({
workspaceId,
applicationId: application.id,
applicationUniversalIdentifier:
manifest.application.universalIdentifier,
});
}
await this.syncRegistrationMetadata(
applicationRegistrationId,
manifest,
workspaceId,
application.id,
);
return {
applicationUniversalIdentifier:
workspaceMigration.applicationUniversalIdentifier,
actions: workspaceMigration.actions,
};
}
private async throttlePerApplication(
applicationIdentifier: string,
workspaceId: string,
): Promise<void> {
await this.throttlerService.tokenBucketThrottleOrThrow(
`app-dev:${workspaceId}:${applicationIdentifier}`,
1,
APP_DEV_RATE_LIMIT_MAX,
APP_DEV_RATE_LIMIT_WINDOW_MS,
);
}
private async findApplicationRegistrationId(
universalIdentifier: string,
): Promise<string> {
const existingRegistration =
await this.applicationRegistrationService.findOneByUniversalIdentifier(
universalIdentifier,
);
if (!existingRegistration) {
throw new ApplicationException(
`No registration found for "${universalIdentifier}". Create one first with createApplicationRegistration.`,
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
);
}
return existingRegistration.id;
}
private async syncRegistrationMetadata(
applicationRegistrationId: string,
manifest: ApplicationInput['manifest'],
workspaceId: string,
applicationId: string,
): Promise<void> {
const registration =
await this.applicationRegistrationService.findOneByIdGlobal(
applicationRegistrationId,
);
// The registration is instance-global: for catalog-synced (npm) apps it is
// the marketplace entry and OAuth identity shared by every workspace, so
// dev-mode sync must not overwrite its manifest or flip its sourceType.
// Only registrations owned by the syncing workspace (and not npm-sourced)
// reflect local dev state.
if (
registration.sourceType === ApplicationRegistrationSourceType.NPM ||
registration.ownerWorkspaceId !== workspaceId
) {
return;
}
const serverUrl = this.twentyConfigService.get('SERVER_URL');
const manifestWithResolvedUrls = resolveManifestAssetUrls(
manifest,
(filePath) =>
`${serverUrl}/public-assets/${workspaceId}/${applicationId}/${filePath}`,
);
await this.applicationRegistrationService.updateFromManifest({
applicationRegistrationId,
manifest: manifestWithResolvedUrls,
sourceType: ApplicationRegistrationSourceType.LOCAL,
});
if (manifest.application.serverVariables) {
await this.applicationRegistrationVariableService.syncVariableSchemas(
applicationRegistrationId,
manifest.application.serverVariables,
);
}
}
}
@@ -0,0 +1,274 @@
import crypto from 'crypto';
import request from 'supertest';
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 { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { uploadApplicationFile } from 'test/integration/metadata/suites/application/utils/upload-application-file.util';
import { type DataSource } from 'typeorm';
// A published npm app lands on an instance through the marketplace catalog
// sync as an instance-global, unowned registration (sourceType 'npm'). A dev
// iterating locally on that app with `twenty app dev` must be able to sync it
// into their own workspace without claiming ownership, without rotating the
// shared OAuth client secret, and without mutating the shared registration
// (which would delist the app from the marketplace for every workspace and
// fight with the catalog sync cron).
describe('CLI dev mode on a catalog-synced (npm) app', () => {
const baseUrl = `http://localhost:${APP_PORT}`;
const universalIdentifier = crypto.randomUUID();
const roleId = crypto.randomUUID();
const registrationId = crypto.randomUUID();
const oAuthClientId = crypto.randomUUID();
let ds: DataSource;
const gqlRequest = (query: string, variables?: Record<string, unknown>) =>
request(baseUrl)
.post('/metadata')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send({ query, variables });
const fetchRegistrationRow = async () => {
const [row] = await ds.query(
`SELECT name, "sourceType", "workspaceId", "manifest", "isListed"
FROM core."applicationRegistration" WHERE id = $1`,
[registrationId],
);
return row;
};
beforeAll(async () => {
jest.useRealTimers();
ds = global.testDataSource;
// Simulate MarketplaceCatalogSyncService.upsertFromCatalog: unowned
// registration (workspaceId NULL), npm-sourced, no client secret.
await ds.query(
`INSERT INTO core."applicationRegistration"
(id, "universalIdentifier", name, "oAuthClientId",
"oAuthRedirectUris", "oAuthScopes", "workspaceId", "sourceType",
"sourcePackage", "latestAvailableVersion", "isListed", "manifest")
VALUES ($1, $2, $3, $4, $5, $6, NULL, 'npm', $7, '1.0.0', true, $8)`,
[
registrationId,
universalIdentifier,
'Published Catalog App',
oAuthClientId,
[],
[],
'@test/published-catalog-app',
JSON.stringify({ application: { universalIdentifier } }),
],
);
});
afterAll(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: universalIdentifier,
});
jest.useFakeTimers();
});
it('reports the universal identifier as already claimed on createApplicationRegistration', async () => {
const res = await gqlRequest(
`mutation CreateApplicationRegistration($input: CreateApplicationRegistrationInput!) {
createApplicationRegistration(input: $input) {
applicationRegistration { id }
clientSecret
}
}`,
{
input: {
name: 'Published Catalog App (dev)',
universalIdentifier,
},
},
).expect(200);
expect(res.body.errors).toBeDefined();
expect(res.body.errors[0].message).toContain('already claimed');
});
it('refuses to rotate the shared client secret for a non-owner workspace', async () => {
const res = await gqlRequest(
`mutation RotateSecret($id: String!) {
rotateApplicationRegistrationClientSecret(id: $id) {
clientSecret
}
}`,
{ id: registrationId },
).expect(200);
expect(res.body.errors).toBeDefined();
expect(res.body.errors[0].message).toContain('not found');
});
it('lets a workspace dev-sync the app and mint a dev token without claiming ownership or touching the shared registration', async () => {
const createDevAppRes = await gqlRequest(
`mutation CreateDevApp($universalIdentifier: String!, $name: String!) {
createDevelopmentApplication(universalIdentifier: $universalIdentifier, name: $name) {
id
}
}`,
{ universalIdentifier, name: 'Published Catalog App (dev)' },
).expect(200);
expect(createDevAppRes.body.errors).toBeUndefined();
const applicationId = createDevAppRes.body.data.createDevelopmentApplication
.id as string;
// The CLI uploads the app's package.json before syncing the manifest.
await uploadApplicationFile({
applicationUniversalIdentifier: universalIdentifier,
fileFolder: 'Dependencies',
filePath: 'package.json',
fileBuffer: Buffer.from(
JSON.stringify({ name: 'published-catalog-app', version: '1.0.1' }),
),
filename: 'package.json',
expectToFail: false,
});
const syncRes = await syncApplication({
manifest: buildBaseManifest({
appId: universalIdentifier,
roleId,
overrides: {
application: {
universalIdentifier,
defaultRoleUniversalIdentifier: roleId,
displayName: 'Published Catalog App (dev)',
description: 'Local dev iteration of a published app',
applicationVariables: {},
packageJsonChecksum: null,
yarnLockChecksum: null,
},
},
}),
expectToFail: false,
});
expect(syncRes.errors).toBeUndefined();
// The shared registration is untouched: still npm-sourced, unowned,
// listed in the marketplace catalog, and carrying the published name and
// manifest.
const registrationRow = await fetchRegistrationRow();
expect(registrationRow.sourceType).toBe('npm');
expect(registrationRow.workspaceId).toBeNull();
expect(registrationRow.name).toBe('Published Catalog App');
expect(registrationRow.isListed).toBe(true);
expect(registrationRow.manifest).toEqual({
application: { universalIdentifier },
});
// Dev tooling gets a workspace-scoped app token via
// generateApplicationToken instead of rotating the shared client secret.
const tokenRes = await gqlRequest(
`mutation GenerateApplicationToken($applicationId: UUID!) {
generateApplicationToken(applicationId: $applicationId) {
applicationAccessToken { token expiresAt }
applicationRefreshToken { token expiresAt }
}
}`,
{ applicationId },
).expect(200);
expect(tokenRes.body.errors).toBeUndefined();
expect(
tokenRes.body.data.generateApplicationToken.applicationAccessToken.token,
).toBeDefined();
});
it('still lets a workspace that owns a non-npm registration sync its metadata', async () => {
// Control: a local registration owned by the workspace keeps receiving
// manifest updates from dev sync (the guard only protects npm/unowned
// registrations).
const ownedUid = crypto.randomUUID();
const ownedRoleId = crypto.randomUUID();
const createRes = await gqlRequest(
`mutation CreateApplicationRegistration($input: CreateApplicationRegistrationInput!) {
createApplicationRegistration(input: $input) {
applicationRegistration { id }
}
}`,
{ input: { name: 'Owned Local App', universalIdentifier: ownedUid } },
).expect(200);
expect(createRes.body.errors).toBeUndefined();
const ownedRegistrationId =
createRes.body.data.createApplicationRegistration.applicationRegistration
.id;
try {
const createDevAppRes = await gqlRequest(
`mutation CreateDevApp($universalIdentifier: String!, $name: String!) {
createDevelopmentApplication(universalIdentifier: $universalIdentifier, name: $name) {
id
}
}`,
{ universalIdentifier: ownedUid, name: 'Owned Local App' },
).expect(200);
expect(createDevAppRes.body.errors).toBeUndefined();
await uploadApplicationFile({
applicationUniversalIdentifier: ownedUid,
fileFolder: 'Dependencies',
filePath: 'package.json',
fileBuffer: Buffer.from(
JSON.stringify({ name: 'owned-local-app', version: '0.0.1' }),
),
filename: 'package.json',
expectToFail: false,
});
const syncRes = await syncApplication({
manifest: buildBaseManifest({
appId: ownedUid,
roleId: ownedRoleId,
overrides: {
application: {
universalIdentifier: ownedUid,
defaultRoleUniversalIdentifier: ownedRoleId,
displayName: 'Owned Local App (renamed)',
description: 'Owned local app',
applicationVariables: {},
packageJsonChecksum: null,
yarnLockChecksum: null,
},
roles: [
{
universalIdentifier: ownedRoleId,
label: 'Owned Local App Role',
description: 'A test role',
},
],
},
}),
expectToFail: false,
});
expect(syncRes.errors).toBeUndefined();
const [row] = await ds.query(
`SELECT name, "sourceType" FROM core."applicationRegistration" WHERE id = $1`,
[ownedRegistrationId],
);
expect(row.name).toBe('Owned Local App (renamed)');
expect(row.sourceType).toBe('local');
} finally {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: ownedUid,
});
}
});
});