feat: add npm and tarball app distribution with upgrade mechanism (#18358)
## Summary - **npm + tarball app distribution**: Apps can be installed from the npm registry (public or private) or uploaded as `.tar.gz` tarballs, with `AppRegistrationSourceType` tracking the origin - **Upgrade mechanism**: `AppUpgradeService` checks for newer versions, supports rollback for npm-sourced apps, and a cron job runs every 6 hours to update `latestAvailableVersion` on registrations - **Security hardening**: Tarball extraction uses path traversal protection, and `enableScripts: false` in `.yarnrc.yml` disables all lifecycle scripts during `yarn install` to prevent RCE - **Frontend**: "Install from npm" and "Upload tarball" modals, upgrade button on app detail page, blue "Update" badge on installed apps table when a newer version is available - **Marketplace catalog sync**: Hourly cron job syncs a hardcoded catalog index into `ApplicationRegistration` entities - **Integration tests**: Coverage for install, upgrade, tarball upload, and catalog sync flows ## Backend changes | Area | Files | |------|-------| | Entity & migration | `ApplicationRegistrationEntity` (sourceType, sourcePackage, latestAvailableVersion), `ApplicationEntity` (applicationRegistrationId), migration | | Services | `AppPackageResolverService`, `ApplicationInstallService`, `AppUpgradeService`, `MarketplaceCatalogSyncService` | | Cron jobs | `MarketplaceCatalogSyncCronJob` (hourly), `AppVersionCheckCronJob` (every 6h) | | REST endpoint | `AppRegistrationUploadController` — tarball upload with secure extraction | | Resolver | `MarketplaceResolver` — simplified `installMarketplaceApp` (removed redundant `sourcePackage` arg) | | Security | `.yarnrc.yml` — `enableScripts: false` to block postinstall RCE | ## Frontend changes | Area | Files | |------|-------| | Modals | `SettingsInstallNpmAppModal`, `SettingsUploadTarballModal`, `SettingsAppModalLayout` | | Hooks | `useUploadAppTarball`, `useInstallMarketplaceApp` (cleaned up) | | Upgrade UI | `SettingsApplicationVersionContainer`, `SettingsApplicationDetailAboutTab` | | Badge | `SettingsApplicationTableRow` — blue "Update" tag, `SettingsApplicationsInstalledTab` — fetches registrations for version comparison | | Styling | Migrated to Linaria (matching main) | ## Test plan - [ ] Install an app from npm via the "Install from npm" modal - [ ] Upload a `.tar.gz` tarball via the "Upload tarball" modal - [ ] Verify upgrade badge appears when `latestAvailableVersion > version` - [ ] Verify upgrade flow from app detail page - [ ] Run integration tests: `app-distribution.integration-spec.ts`, `marketplace-catalog-sync.integration-spec.ts` - [ ] Verify `enableScripts: false` blocks postinstall scripts during yarn install Made with [Cursor](https://cursor.com)
This commit is contained in:
+28
@@ -0,0 +1,28 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
|
||||
import { ApplicationInstallModule } from 'src/engine/core-modules/application/application-install/application-install.module';
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { ApplicationDevelopmentResolver } from 'src/engine/core-modules/application/application-development/application-development.resolver';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.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';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ApplicationInstallModule,
|
||||
ApplicationRegistrationModule,
|
||||
ApplicationModule,
|
||||
FeatureFlagModule,
|
||||
TokenModule,
|
||||
FileStorageModule,
|
||||
PermissionsModule,
|
||||
],
|
||||
providers: [
|
||||
ApplicationDevelopmentResolver,
|
||||
WorkspaceMigrationGraphqlApiExceptionInterceptor,
|
||||
],
|
||||
})
|
||||
export class ApplicationDevelopmentModule {}
|
||||
+121
-4
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
Logger,
|
||||
UseFilters,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
@@ -12,6 +13,9 @@ import { FileFolder, FeatureFlagKey } from 'twenty-shared/types';
|
||||
|
||||
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
|
||||
|
||||
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration/application-registration-variable.service';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter';
|
||||
import {
|
||||
@@ -24,8 +28,8 @@ import { CreateApplicationInput } from 'src/engine/core-modules/application/dtos
|
||||
import { GenerateApplicationTokenInput } from 'src/engine/core-modules/application/dtos/generate-application-token.input';
|
||||
import { UploadApplicationFileInput } from 'src/engine/core-modules/application/dtos/uploadApplicationFileInput';
|
||||
import { WorkspaceMigrationDTO } from 'src/engine/core-modules/application/dtos/workspace-migration.dto';
|
||||
import { ApplicationSyncService } from 'src/engine/core-modules/application/services/application-sync.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
|
||||
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-install/application-sync.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { ApplicationTokenPairDTO } from 'src/engine/core-modules/application/dtos/application-token-pair.dto';
|
||||
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
@@ -34,7 +38,10 @@ import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/re
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { DevelopmentGuard } from 'src/engine/guards/development.guard';
|
||||
import { RequireFeatureFlag } from 'src/engine/guards/feature-flag.guard';
|
||||
import {
|
||||
FeatureFlagGuard,
|
||||
RequireFeatureFlag,
|
||||
} from 'src/engine/guards/feature-flag.guard';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
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';
|
||||
@@ -46,14 +53,19 @@ import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
@UseFilters(ApplicationExceptionFilter)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
DevelopmentGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.APPLICATIONS),
|
||||
)
|
||||
export class ApplicationDevelopmentResolver {
|
||||
private readonly logger = new Logger(ApplicationDevelopmentResolver.name);
|
||||
|
||||
constructor(
|
||||
private readonly applicationTokenService: ApplicationTokenService,
|
||||
private readonly applicationSyncService: ApplicationSyncService,
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly applicationRegistrationService: ApplicationRegistrationService,
|
||||
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
) {}
|
||||
|
||||
@@ -75,12 +87,33 @@ export class ApplicationDevelopmentResolver {
|
||||
@Args() { manifest }: ApplicationInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<WorkspaceMigrationDTO> {
|
||||
const applicationRegistrationId =
|
||||
await this.resolveApplicationRegistrationId(
|
||||
manifest.application.universalIdentifier,
|
||||
{
|
||||
name: manifest.application.displayName,
|
||||
description: manifest.application.description,
|
||||
logoUrl: manifest.application.logoUrl,
|
||||
author: manifest.application.author,
|
||||
websiteUrl: manifest.application.websiteUrl,
|
||||
termsUrl: manifest.application.termsUrl,
|
||||
},
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const workspaceMigration =
|
||||
await this.applicationSyncService.synchronizeFromManifest({
|
||||
workspaceId,
|
||||
manifest,
|
||||
applicationRegistrationId,
|
||||
});
|
||||
|
||||
await this.syncRegistrationMetadata(
|
||||
applicationRegistrationId,
|
||||
manifest,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
return {
|
||||
applicationUniversalIdentifier:
|
||||
workspaceMigration.applicationUniversalIdentifier,
|
||||
@@ -96,7 +129,7 @@ export class ApplicationDevelopmentResolver {
|
||||
) {
|
||||
return await this.applicationService.create({
|
||||
...input,
|
||||
sourceType: 'local',
|
||||
sourceType: AppRegistrationSourceType.LOCAL,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
@@ -142,4 +175,88 @@ export class ApplicationDevelopmentResolver {
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveApplicationRegistrationId(
|
||||
universalIdentifier: string,
|
||||
metadata: {
|
||||
name: string;
|
||||
description?: string;
|
||||
logoUrl?: string;
|
||||
author?: string;
|
||||
websiteUrl?: string;
|
||||
termsUrl?: string;
|
||||
},
|
||||
workspaceId: string,
|
||||
): Promise<string> {
|
||||
const existingRegistration =
|
||||
await this.applicationRegistrationService.findOneByUniversalIdentifier(
|
||||
universalIdentifier,
|
||||
);
|
||||
|
||||
if (existingRegistration) {
|
||||
const isOwner =
|
||||
await this.applicationRegistrationService.isOwnedByWorkspace(
|
||||
existingRegistration.id,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!isOwner) {
|
||||
throw new ApplicationException(
|
||||
'Cannot sync application: registration is owned by another workspace',
|
||||
ApplicationExceptionCode.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
|
||||
return existingRegistration.id;
|
||||
}
|
||||
|
||||
const { applicationRegistration: newRegistration } =
|
||||
await this.applicationRegistrationService.create(
|
||||
{ ...metadata, universalIdentifier },
|
||||
workspaceId,
|
||||
null,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Created app registration for ${metadata.name} (${universalIdentifier})`,
|
||||
);
|
||||
|
||||
return newRegistration.id;
|
||||
}
|
||||
|
||||
private async syncRegistrationMetadata(
|
||||
applicationRegistrationId: string,
|
||||
manifest: { application: ApplicationInput['manifest']['application'] },
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const isOwner =
|
||||
await this.applicationRegistrationService.isOwnedByWorkspace(
|
||||
applicationRegistrationId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (isOwner) {
|
||||
await this.applicationRegistrationService.update(
|
||||
{
|
||||
id: applicationRegistrationId,
|
||||
update: {
|
||||
name: manifest.application.displayName,
|
||||
description: manifest.application.description,
|
||||
logoUrl: manifest.application.logoUrl,
|
||||
author: manifest.application.author,
|
||||
websiteUrl: manifest.application.websiteUrl,
|
||||
termsUrl: manifest.application.termsUrl,
|
||||
},
|
||||
},
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (manifest.application.serverVariables) {
|
||||
await this.applicationRegistrationVariableService.syncVariableSchemas(
|
||||
applicationRegistrationId,
|
||||
manifest.application.serverVariables,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
@@ -7,6 +7,8 @@ import {
|
||||
ApplicationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application.exception';
|
||||
import {
|
||||
ForbiddenError,
|
||||
InternalServerError,
|
||||
NotFoundError,
|
||||
UserInputError,
|
||||
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
@@ -23,8 +25,14 @@ export class ApplicationExceptionFilter implements ExceptionFilter {
|
||||
case ApplicationExceptionCode.FRONT_COMPONENT_NOT_FOUND:
|
||||
throw new NotFoundError(exception);
|
||||
case ApplicationExceptionCode.FORBIDDEN:
|
||||
throw new ForbiddenError(exception);
|
||||
case ApplicationExceptionCode.INVALID_INPUT:
|
||||
case ApplicationExceptionCode.SOURCE_CHANNEL_MISMATCH:
|
||||
throw new UserInputError(exception);
|
||||
case ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED:
|
||||
case ApplicationExceptionCode.TARBALL_EXTRACTION_FAILED:
|
||||
case ApplicationExceptionCode.UPGRADE_FAILED:
|
||||
throw new InternalServerError(exception);
|
||||
default: {
|
||||
assertUnreachable(exception.code);
|
||||
}
|
||||
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
|
||||
|
||||
import { execFile } from 'child_process';
|
||||
import { promises as fs } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { promisify } from 'util';
|
||||
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
import { type PackageJson } from 'type-fest';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
|
||||
import {
|
||||
ApplicationException,
|
||||
ApplicationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application.exception';
|
||||
import { YARN_ENGINE_DIRNAME } from 'src/engine/core-modules/application/constants/yarn-engine-dirname';
|
||||
import { assertValidNpmPackageName } from 'src/engine/core-modules/application/utils/assert-valid-npm-package-name.util';
|
||||
import { extractTarballSecurely } from 'src/engine/core-modules/application/utils/extract-tarball-securely.util';
|
||||
import { readJsonFileOrThrow } from 'src/engine/core-modules/application/utils/read-json-file.util';
|
||||
import { resolvePackageContentDir } from 'src/engine/core-modules/application/utils/tarball-utils';
|
||||
import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
|
||||
const execFilePromise = promisify(execFile);
|
||||
|
||||
const APP_FETCHER_TMPDIR = join(tmpdir(), 'twenty-app-fetcher');
|
||||
const RESOLUTION_TIMEOUT_MS = 30_000;
|
||||
|
||||
export type ResolvedPackage = {
|
||||
extractedDir: string;
|
||||
cleanupDir: string;
|
||||
manifest: Manifest;
|
||||
packageJson: PackageJson;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AppPackageFetcherService implements OnModuleInit {
|
||||
private readonly logger = new Logger(AppPackageFetcherService.name);
|
||||
|
||||
constructor(
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
private readonly fileStorageDriverFactory: FileStorageDriverFactory,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
try {
|
||||
await fs.rm(APP_FETCHER_TMPDIR, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort cleanup of stale temp files from previous runs
|
||||
}
|
||||
}
|
||||
|
||||
async resolvePackage(
|
||||
appRegistration: ApplicationRegistrationEntity,
|
||||
options?: { targetVersion?: string },
|
||||
): Promise<ResolvedPackage | null> {
|
||||
switch (appRegistration.sourceType) {
|
||||
case AppRegistrationSourceType.NPM:
|
||||
return this.resolveFromNpm(appRegistration, options?.targetVersion);
|
||||
case AppRegistrationSourceType.TARBALL:
|
||||
return this.resolveFromTarball(appRegistration);
|
||||
case AppRegistrationSourceType.LOCAL:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async cleanupExtractedDir(extractedDir: string): Promise<void> {
|
||||
try {
|
||||
await fs.rm(extractedDir, { recursive: true, force: true });
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to clean up ${extractedDir}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveFromNpm(
|
||||
appRegistration: ApplicationRegistrationEntity,
|
||||
targetVersion?: string,
|
||||
): Promise<ResolvedPackage> {
|
||||
const workDir = join(APP_FETCHER_TMPDIR, v4());
|
||||
|
||||
await fs.mkdir(workDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const registryUrl = this.twentyConfigService.get('APP_REGISTRY_URL');
|
||||
|
||||
const authToken = this.twentyConfigService.get('APP_REGISTRY_TOKEN');
|
||||
|
||||
if (!appRegistration.sourcePackage) {
|
||||
throw new ApplicationException(
|
||||
`App registration ${appRegistration.id} has sourceType=npm but no sourcePackage`,
|
||||
ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
const sourcePackage = appRegistration.sourcePackage;
|
||||
|
||||
assertValidNpmPackageName(sourcePackage);
|
||||
|
||||
const versionSpec = targetVersion ?? 'latest';
|
||||
|
||||
await this.writeNpmrc({
|
||||
workDir,
|
||||
packageName: sourcePackage,
|
||||
registryUrl,
|
||||
authToken,
|
||||
});
|
||||
await this.setupYarnEngine(workDir);
|
||||
await this.writeMinimalPackageJson(workDir, sourcePackage, versionSpec);
|
||||
await this.runYarnInstall(workDir);
|
||||
|
||||
const packageDir = join(workDir, 'node_modules', sourcePackage);
|
||||
const manifest = await readJsonFileOrThrow<Manifest>(
|
||||
packageDir,
|
||||
'manifest.json',
|
||||
);
|
||||
const packageJson = await readJsonFileOrThrow<PackageJson>(
|
||||
packageDir,
|
||||
'package.json',
|
||||
);
|
||||
|
||||
return {
|
||||
extractedDir: packageDir,
|
||||
cleanupDir: workDir,
|
||||
manifest,
|
||||
packageJson,
|
||||
};
|
||||
} catch (error) {
|
||||
await this.cleanupExtractedDir(workDir);
|
||||
throw new ApplicationException(
|
||||
`Failed to resolve npm package ${appRegistration.sourcePackage}: ${error}`,
|
||||
ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveFromTarball(
|
||||
appRegistration: ApplicationRegistrationEntity,
|
||||
): Promise<ResolvedPackage> {
|
||||
const workDir = join(APP_FETCHER_TMPDIR, v4());
|
||||
|
||||
await fs.mkdir(workDir, { recursive: true });
|
||||
|
||||
try {
|
||||
const storagePath = join('app-tarball', appRegistration.id, 'app.tar.gz');
|
||||
const driver = this.fileStorageDriverFactory.getCurrentDriver();
|
||||
const tarballStream = await driver.readFile({
|
||||
filePath: storagePath,
|
||||
});
|
||||
const tarballBuffer = await streamToBuffer(tarballStream);
|
||||
const tarballPath = join(workDir, 'app.tar.gz');
|
||||
|
||||
await fs.writeFile(tarballPath, tarballBuffer);
|
||||
await extractTarballSecurely(tarballPath, workDir);
|
||||
await fs.rm(tarballPath);
|
||||
|
||||
const contentDir = await resolvePackageContentDir(workDir);
|
||||
const manifest = await readJsonFileOrThrow<Manifest>(
|
||||
contentDir,
|
||||
'manifest.json',
|
||||
);
|
||||
const packageJson = await readJsonFileOrThrow<PackageJson>(
|
||||
contentDir,
|
||||
'package.json',
|
||||
);
|
||||
|
||||
return {
|
||||
extractedDir: contentDir,
|
||||
cleanupDir: workDir,
|
||||
manifest,
|
||||
packageJson,
|
||||
};
|
||||
} catch (error) {
|
||||
await this.cleanupExtractedDir(workDir);
|
||||
|
||||
if (error instanceof ApplicationException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new ApplicationException(
|
||||
`Failed to resolve tarball for app ${appRegistration.universalIdentifier}: ${error}`,
|
||||
ApplicationExceptionCode.TARBALL_EXTRACTION_FAILED,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Note: .npmrc settings take precedence over publishConfig.registry in
|
||||
// individual packages. This is correct for our use case since we want
|
||||
// to control the registry at the resolver level.
|
||||
private async writeNpmrc(config: {
|
||||
workDir: string;
|
||||
packageName: string;
|
||||
registryUrl: string;
|
||||
authToken?: string;
|
||||
}): Promise<void> {
|
||||
const lines: string[] = [];
|
||||
const registryHost = new URL(config.registryUrl).host;
|
||||
|
||||
if (config.packageName.startsWith('@')) {
|
||||
const scope = config.packageName.split('/')[0];
|
||||
|
||||
lines.push(`${scope}:registry=${config.registryUrl}`);
|
||||
} else if (config.registryUrl !== 'https://registry.npmjs.org') {
|
||||
lines.push(`registry=${config.registryUrl}`);
|
||||
}
|
||||
|
||||
if (config.authToken) {
|
||||
lines.push(`//${registryHost}/:_authToken=${config.authToken}`);
|
||||
}
|
||||
|
||||
if (lines.length > 0) {
|
||||
await fs.writeFile(
|
||||
join(config.workDir, '.npmrc'),
|
||||
lines.join('\n') + '\n',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async setupYarnEngine(workDir: string): Promise<void> {
|
||||
await fs.cp(YARN_ENGINE_DIRNAME, workDir, { recursive: true });
|
||||
}
|
||||
|
||||
private async writeMinimalPackageJson(
|
||||
workDir: string,
|
||||
packageName: string,
|
||||
versionSpec: string,
|
||||
): Promise<void> {
|
||||
const packageJson = {
|
||||
name: 'twenty-app-resolver-workspace',
|
||||
private: true,
|
||||
dependencies: {
|
||||
[packageName]: versionSpec,
|
||||
},
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
join(workDir, 'package.json'),
|
||||
JSON.stringify(packageJson, null, 2),
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveLocalYarnPath(workDir: string): Promise<string> {
|
||||
const yarnrcPath = join(workDir, '.yarnrc.yml');
|
||||
const yarnrcContent = await fs.readFile(yarnrcPath, 'utf-8');
|
||||
const match = yarnrcContent.match(/^yarnPath:\s*(.+)$/m);
|
||||
|
||||
if (!match) {
|
||||
throw new ApplicationException(
|
||||
'yarnPath not found in .yarnrc.yml',
|
||||
ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
return join(workDir, match[1].trim());
|
||||
}
|
||||
|
||||
private async runYarnInstall(workDir: string): Promise<void> {
|
||||
const localYarnPath = await this.resolveLocalYarnPath(workDir);
|
||||
|
||||
const { NODE_OPTIONS: _nodeOptions, ...cleanEnv } = process.env;
|
||||
|
||||
try {
|
||||
await execFilePromise(
|
||||
process.execPath,
|
||||
[localYarnPath, 'install', '--no-immutable'],
|
||||
{
|
||||
cwd: workDir,
|
||||
env: cleanEnv,
|
||||
timeout: RESOLUTION_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
throw new ApplicationException(
|
||||
`yarn install failed: ${errorMessage}`,
|
||||
ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import axios from 'axios';
|
||||
import { Repository } from 'typeorm';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import {
|
||||
ApplicationException,
|
||||
ApplicationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application.exception';
|
||||
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
const npmPackageMetadataSchema = z.object({
|
||||
version: z.string(),
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class AppUpgradeService {
|
||||
private readonly logger = new Logger(AppUpgradeService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ApplicationRegistrationEntity)
|
||||
private readonly appRegistrationRepository: Repository<ApplicationRegistrationEntity>,
|
||||
@InjectRepository(ApplicationEntity)
|
||||
private readonly applicationRepository: Repository<ApplicationEntity>,
|
||||
private readonly applicationInstallService: ApplicationInstallService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
async checkForUpdates(
|
||||
appRegistration: ApplicationRegistrationEntity,
|
||||
): Promise<string | null> {
|
||||
if (appRegistration.sourceType !== AppRegistrationSourceType.NPM) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const registryUrl = this.twentyConfigService.get('APP_REGISTRY_URL');
|
||||
|
||||
if (!appRegistration.sourcePackage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const encodedPackage = encodeURIComponent(appRegistration.sourcePackage);
|
||||
|
||||
const { data } = await axios.get(
|
||||
`${registryUrl}/${encodedPackage}/latest`,
|
||||
{
|
||||
headers: { 'User-Agent': 'Twenty-AppUpgrade' },
|
||||
timeout: 10_000,
|
||||
},
|
||||
);
|
||||
|
||||
const parsed = npmPackageMetadataSchema.safeParse(data);
|
||||
|
||||
if (!parsed.success) {
|
||||
this.logger.warn(
|
||||
`Unexpected response shape from registry for ${appRegistration.sourcePackage}`,
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.appRegistrationRepository.update(appRegistration.id, {
|
||||
latestAvailableVersion: parsed.data.version,
|
||||
});
|
||||
|
||||
return parsed.data.version;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to check updates for ${appRegistration.sourcePackage}: ${error}`,
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async checkAllForUpdates(): Promise<void> {
|
||||
const npmRegistrations = await this.appRegistrationRepository.find({
|
||||
where: { sourceType: AppRegistrationSourceType.NPM },
|
||||
});
|
||||
|
||||
for (const registration of npmRegistrations) {
|
||||
await this.checkForUpdates(registration);
|
||||
}
|
||||
}
|
||||
|
||||
async upgradeApplication(params: {
|
||||
appRegistrationId: string;
|
||||
targetVersion: string;
|
||||
workspaceId: string;
|
||||
}): Promise<boolean> {
|
||||
const appRegistration = await this.appRegistrationRepository.findOneOrFail({
|
||||
where: { id: params.appRegistrationId },
|
||||
});
|
||||
|
||||
if (
|
||||
appRegistration.sourceType === AppRegistrationSourceType.LOCAL ||
|
||||
appRegistration.sourceType === AppRegistrationSourceType.TARBALL
|
||||
) {
|
||||
throw new ApplicationException(
|
||||
'Cannot upgrade an app installed from a tarball or local source',
|
||||
ApplicationExceptionCode.UPGRADE_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.applicationInstallService.installApplication({
|
||||
appRegistrationId: params.appRegistrationId,
|
||||
version: params.targetVersion,
|
||||
workspaceId: params.workspaceId,
|
||||
});
|
||||
} catch (error) {
|
||||
const appName =
|
||||
appRegistration.sourcePackage ?? appRegistration.universalIdentifier;
|
||||
|
||||
this.logger.error(`Upgrade failed for ${appName}`, error);
|
||||
|
||||
if (error instanceof ApplicationException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new ApplicationException(
|
||||
`Upgrade failed for ${appName}`,
|
||||
ApplicationExceptionCode.UPGRADE_FAILED,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
-14
@@ -1,20 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application-registration/application-registration.module';
|
||||
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 { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { ApplicationDevelopmentResolver } from 'src/engine/core-modules/application/resolvers/application-development.resolver';
|
||||
import { ApplicationResolver } from 'src/engine/core-modules/application/resolvers/application.resolver';
|
||||
import { MarketplaceResolver } from 'src/engine/core-modules/application/resolvers/marketplace.resolver';
|
||||
import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/services/application-manifest-migration.service';
|
||||
import { ApplicationSyncService } from 'src/engine/core-modules/application/services/application-sync.service';
|
||||
import { ApplicationVariableEntityModule } from 'src/engine/core-modules/applicationVariable/application-variable.module';
|
||||
import { ApplicationInstallResolver } from 'src/engine/core-modules/application/application-install/application-install.resolver';
|
||||
import { AppPackageFetcherService } from 'src/engine/core-modules/application/application-install/app-package-fetcher.service';
|
||||
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
|
||||
import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/application-install/application-manifest-migration.service';
|
||||
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-install/application-sync.service';
|
||||
import { AppUpgradeService } from 'src/engine/core-modules/application/application-install/app-upgrade.service';
|
||||
import { ApplicationVariableEntityModule } from 'src/engine/core-modules/application/application-variable/application-variable.module';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { ObjectPermissionModule } from 'src/engine/metadata-modules/object-permission/object-permission.module';
|
||||
import { PermissionFlagModule } from 'src/engine/metadata-modules/permission-flag/permission-flag.module';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor';
|
||||
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/workspace-migration-runner.module';
|
||||
@@ -24,9 +29,14 @@ import { CodeStepBuildModule } from 'src/modules/workflow/workflow-builder/workf
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([FileEntity]),
|
||||
ApplicationRegistrationModule,
|
||||
TypeOrmModule.forFeature([
|
||||
FileEntity,
|
||||
ApplicationRegistrationEntity,
|
||||
ApplicationEntity,
|
||||
]),
|
||||
ApplicationModule,
|
||||
CacheLockModule,
|
||||
FeatureFlagModule,
|
||||
ApplicationVariableEntityModule,
|
||||
TokenModule,
|
||||
WorkspaceMigrationModule,
|
||||
@@ -38,15 +48,22 @@ import { CodeStepBuildModule } from 'src/modules/workflow/workflow-builder/workf
|
||||
FileStorageModule,
|
||||
WorkspaceCacheModule,
|
||||
WorkspaceMigrationRunnerModule,
|
||||
TwentyConfigModule,
|
||||
],
|
||||
providers: [
|
||||
ApplicationResolver,
|
||||
ApplicationDevelopmentResolver,
|
||||
MarketplaceResolver,
|
||||
ApplicationInstallResolver,
|
||||
ApplicationManifestMigrationService,
|
||||
ApplicationSyncService,
|
||||
AppPackageFetcherService,
|
||||
ApplicationInstallService,
|
||||
AppUpgradeService,
|
||||
WorkspaceMigrationGraphqlApiExceptionInterceptor,
|
||||
],
|
||||
exports: [ApplicationSyncService],
|
||||
exports: [
|
||||
ApplicationSyncService,
|
||||
AppPackageFetcherService,
|
||||
ApplicationInstallService,
|
||||
AppUpgradeService,
|
||||
],
|
||||
})
|
||||
export class ApplicationSyncModule {}
|
||||
export class ApplicationInstallModule {}
|
||||
+15
-11
@@ -12,22 +12,25 @@ import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { ApplicationExceptionFilter } from 'src/engine/core-modules/application/application-exception-filter';
|
||||
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
|
||||
import {
|
||||
ApplicationException,
|
||||
ApplicationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application.exception';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-install/application-sync.service';
|
||||
import { ApplicationTokenPairDTO } from 'src/engine/core-modules/application/dtos/application-token-pair.dto';
|
||||
import { ApplicationDTO } from 'src/engine/core-modules/application/dtos/application.dto';
|
||||
import { InstallApplicationInput } from 'src/engine/core-modules/application/dtos/install-application.input';
|
||||
import { UninstallApplicationInput } from 'src/engine/core-modules/application/dtos/uninstallApplicationInput';
|
||||
import { ApplicationSyncService } from 'src/engine/core-modules/application/services/application-sync.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
|
||||
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
|
||||
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { RequireFeatureFlag } from 'src/engine/guards/feature-flag.guard';
|
||||
import {
|
||||
FeatureFlagGuard,
|
||||
RequireFeatureFlag,
|
||||
} from 'src/engine/guards/feature-flag.guard';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
@@ -39,19 +42,18 @@ import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/wo
|
||||
@MetadataResolver()
|
||||
@UseInterceptors(WorkspaceMigrationGraphqlApiExceptionInterceptor)
|
||||
@UseFilters(ApplicationExceptionFilter, AuthGraphqlApiExceptionFilter)
|
||||
@UseGuards(WorkspaceAuthGuard)
|
||||
export class ApplicationResolver {
|
||||
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard)
|
||||
export class ApplicationInstallResolver {
|
||||
constructor(
|
||||
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
|
||||
private readonly applicationSyncService: ApplicationSyncService,
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly applicationTokenService: ApplicationTokenService,
|
||||
private readonly applicationSyncService: ApplicationSyncService,
|
||||
private readonly workspaceMigrationRunnerService: WorkspaceMigrationRunnerService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
) {}
|
||||
|
||||
@Query(() => [ApplicationDTO])
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
async findManyApplications(
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
) {
|
||||
@@ -60,11 +62,13 @@ export class ApplicationResolver {
|
||||
|
||||
@Query(() => ApplicationDTO)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.APPLICATIONS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
async findOneApplication(
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
@Args('id', { type: () => UUIDScalarType, nullable: true }) id?: string,
|
||||
@Args('universalIdentifier', { type: () => UUIDScalarType, nullable: true })
|
||||
@Args('universalIdentifier', {
|
||||
type: () => UUIDScalarType,
|
||||
nullable: true,
|
||||
})
|
||||
universalIdentifier?: string,
|
||||
) {
|
||||
return await this.applicationService.findOneApplicationOrThrow({
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import { join, relative } from 'path';
|
||||
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import {
|
||||
AppPackageFetcherService,
|
||||
type ResolvedPackage,
|
||||
} from 'src/engine/core-modules/application/application-install/app-package-fetcher.service';
|
||||
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-install/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';
|
||||
|
||||
const FILE_FOLDER_MAPPING: Record<string, FileFolder> = {
|
||||
'package.json': FileFolder.Dependencies,
|
||||
'yarn.lock': FileFolder.Dependencies,
|
||||
};
|
||||
|
||||
const FILE_FOLDER_PATTERN_MAPPING: Array<{
|
||||
pattern: RegExp;
|
||||
folder: FileFolder;
|
||||
}> = [
|
||||
{ pattern: /\.function\.mjs$/, folder: FileFolder.BuiltLogicFunction },
|
||||
{
|
||||
pattern: /\.front-component\.mjs$/,
|
||||
folder: FileFolder.BuiltFrontComponent,
|
||||
},
|
||||
{ pattern: /^public\//, folder: FileFolder.PublicAsset },
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class ApplicationInstallService {
|
||||
private readonly logger = new Logger(ApplicationInstallService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ApplicationRegistrationEntity)
|
||||
private readonly appRegistrationRepository: Repository<ApplicationRegistrationEntity>,
|
||||
@InjectRepository(ApplicationEntity)
|
||||
private readonly applicationRepository: Repository<ApplicationEntity>,
|
||||
private readonly appPackageFetcherService: AppPackageFetcherService,
|
||||
private readonly applicationSyncService: ApplicationSyncService,
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
private readonly cacheLockService: CacheLockService,
|
||||
) {}
|
||||
|
||||
async installApplication(params: {
|
||||
appRegistrationId: string;
|
||||
version?: string;
|
||||
workspaceId: string;
|
||||
}): Promise<boolean> {
|
||||
const appRegistration = await this.appRegistrationRepository.findOneOrFail({
|
||||
where: { id: params.appRegistrationId },
|
||||
});
|
||||
|
||||
if (appRegistration.sourceType === AppRegistrationSourceType.LOCAL) {
|
||||
this.logger.log(
|
||||
`Skipping install for LOCAL app ${appRegistration.universalIdentifier} (files synced by CLI watcher in dev mode)`,
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const lockKey = `app-install:${params.workspaceId}:${appRegistration.universalIdentifier}`;
|
||||
|
||||
return this.cacheLockService.withLock(
|
||||
() =>
|
||||
this.doInstallApplication(appRegistration, {
|
||||
version: params.version,
|
||||
workspaceId: params.workspaceId,
|
||||
}),
|
||||
lockKey,
|
||||
{ ttl: 60_000, ms: 500, maxRetries: 120 },
|
||||
);
|
||||
}
|
||||
|
||||
private async doInstallApplication(
|
||||
appRegistration: ApplicationRegistrationEntity,
|
||||
params: { version?: string; workspaceId: string },
|
||||
): Promise<boolean> {
|
||||
let resolvedPackage: ResolvedPackage | null = null;
|
||||
|
||||
try {
|
||||
resolvedPackage = await this.appPackageFetcherService.resolvePackage(
|
||||
appRegistration,
|
||||
{ targetVersion: params.version },
|
||||
);
|
||||
|
||||
if (!resolvedPackage) {
|
||||
return true;
|
||||
}
|
||||
|
||||
await this.writeFilesToStorage(
|
||||
resolvedPackage.extractedDir,
|
||||
appRegistration.universalIdentifier,
|
||||
params.workspaceId,
|
||||
);
|
||||
|
||||
await this.applicationSyncService.synchronizeFromManifest({
|
||||
workspaceId: params.workspaceId,
|
||||
manifest: resolvedPackage.manifest,
|
||||
applicationRegistrationId: appRegistration.id,
|
||||
});
|
||||
|
||||
await this.updateApplicationSourceType(
|
||||
appRegistration.universalIdentifier,
|
||||
params.workspaceId,
|
||||
appRegistration.sourceType,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Successfully installed app ${appRegistration.universalIdentifier} v${resolvedPackage.packageJson.version ?? 'unknown'}`,
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to install app ${appRegistration.universalIdentifier}: ${error}`,
|
||||
);
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
if (resolvedPackage) {
|
||||
await this.appPackageFetcherService.cleanupExtractedDir(
|
||||
resolvedPackage.cleanupDir,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async writeFilesToStorage(
|
||||
extractedDir: string,
|
||||
applicationUniversalIdentifier: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const files = await this.collectFiles(extractedDir);
|
||||
|
||||
for (const filePath of files) {
|
||||
const relativePath = relative(extractedDir, filePath);
|
||||
const fileFolder = this.resolveFileFolder(relativePath);
|
||||
const content = await fs.readFile(filePath);
|
||||
|
||||
await this.fileStorageService.writeFile({
|
||||
sourceFile: content,
|
||||
mimeType: undefined,
|
||||
fileFolder,
|
||||
applicationUniversalIdentifier,
|
||||
workspaceId,
|
||||
resourcePath: relativePath,
|
||||
settings: { isTemporaryFile: false, toDelete: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private resolveFileFolder(relativePath: string): FileFolder {
|
||||
const exact = FILE_FOLDER_MAPPING[relativePath];
|
||||
|
||||
if (isDefined(exact)) {
|
||||
return exact;
|
||||
}
|
||||
|
||||
for (const { pattern, folder } of FILE_FOLDER_PATTERN_MAPPING) {
|
||||
if (pattern.test(relativePath)) {
|
||||
return folder;
|
||||
}
|
||||
}
|
||||
|
||||
return FileFolder.Source;
|
||||
}
|
||||
|
||||
private async collectFiles(dir: string): Promise<string[]> {
|
||||
const result: string[] = [];
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
|
||||
if (entry.name === 'node_modules' || entry.name === '.yarn') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const subFiles = await this.collectFiles(fullPath);
|
||||
|
||||
result.push(...subFiles);
|
||||
} else {
|
||||
result.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async updateApplicationSourceType(
|
||||
universalIdentifier: string,
|
||||
workspaceId: string,
|
||||
sourceType: AppRegistrationSourceType,
|
||||
): Promise<void> {
|
||||
await this.applicationRepository.update(
|
||||
{ universalIdentifier, workspaceId },
|
||||
{ sourceType },
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -8,7 +8,7 @@ import {
|
||||
ApplicationException,
|
||||
ApplicationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application.exception';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
|
||||
import { buildFromToAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/utils/build-from-to-all-universal-flat-entity-maps.util';
|
||||
import { computeApplicationManifestAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/utils/compute-application-manifest-all-universal-flat-entity-maps.util';
|
||||
+19
-94
@@ -1,25 +1,23 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
import { ALL_METADATA_NAME } from 'twenty-shared/metadata';
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { PackageJson } from 'type-fest';
|
||||
|
||||
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application-registration/application-registration-variable.service';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application-registration/application-registration.service';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import {
|
||||
ApplicationException,
|
||||
ApplicationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application.exception';
|
||||
import { ApplicationInput } from 'src/engine/core-modules/application/dtos/application.input';
|
||||
import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/services/application-manifest-migration.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
|
||||
import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/application-install/application-manifest-migration.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
|
||||
import { buildFromToAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/utils/build-from-to-all-universal-flat-entity-maps.util';
|
||||
import { getApplicationSubAllFlatEntityMaps } from 'src/engine/core-modules/application/utils/get-application-sub-all-flat-entity-maps.util';
|
||||
import { getDefaultApplicationPackageFields } from 'src/engine/core-modules/application/utils/get-default-application-package-fields.util';
|
||||
import { ApplicationVariableEntityService } from 'src/engine/core-modules/applicationVariable/application-variable.service';
|
||||
import { ApplicationVariableEntityService } from 'src/engine/core-modules/application/application-variable/application-variable.service';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { createEmptyAllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-all-flat-entity-maps.constant';
|
||||
import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util';
|
||||
@@ -40,19 +38,21 @@ export class ApplicationSyncService {
|
||||
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
private readonly applicationRegistrationService: ApplicationRegistrationService,
|
||||
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
|
||||
) {}
|
||||
|
||||
public async synchronizeFromManifest({
|
||||
workspaceId,
|
||||
manifest,
|
||||
}: ApplicationInput & {
|
||||
applicationRegistrationId,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
manifest: Manifest;
|
||||
applicationRegistrationId?: string;
|
||||
}): Promise<WorkspaceMigration> {
|
||||
const application = await this.syncApplication({
|
||||
workspaceId,
|
||||
manifest,
|
||||
applicationRegistrationId,
|
||||
});
|
||||
|
||||
const ownerFlatApplication: FlatApplication = application;
|
||||
@@ -64,7 +64,7 @@ export class ApplicationSyncService {
|
||||
ownerFlatApplication,
|
||||
});
|
||||
|
||||
this.logger.log('✅ Application sync from manifest completed');
|
||||
this.logger.log('Application sync from manifest completed');
|
||||
|
||||
return workspaceMigration;
|
||||
}
|
||||
@@ -72,8 +72,11 @@ export class ApplicationSyncService {
|
||||
private async syncApplication({
|
||||
workspaceId,
|
||||
manifest,
|
||||
}: ApplicationInput & {
|
||||
applicationRegistrationId,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
manifest: Manifest;
|
||||
applicationRegistrationId?: string;
|
||||
}): Promise<ApplicationEntity> {
|
||||
const name = manifest.application.displayName;
|
||||
const packageJson = JSON.parse(
|
||||
@@ -103,7 +106,7 @@ export class ApplicationSyncService {
|
||||
name,
|
||||
description: manifest.application.description,
|
||||
version: packageJson.version,
|
||||
sourcePath: 'cli-sync', // Placeholder for CLI-synced apps
|
||||
sourcePath: 'cli-sync',
|
||||
defaultRoleId: null,
|
||||
workspaceId,
|
||||
packageJsonChecksum: defaultPackageFields.packageJsonChecksum,
|
||||
@@ -122,47 +125,8 @@ export class ApplicationSyncService {
|
||||
},
|
||||
);
|
||||
|
||||
const applicationRegistrationMetadata = {
|
||||
name,
|
||||
description: manifest.application.description,
|
||||
logoUrl: manifest.application.logoUrl,
|
||||
author: manifest.application.author,
|
||||
websiteUrl: manifest.application.websiteUrl,
|
||||
termsUrl: manifest.application.termsUrl,
|
||||
};
|
||||
|
||||
const applicationRegistrationId =
|
||||
await this.resolveApplicationRegistrationId(
|
||||
application.applicationRegistrationId,
|
||||
manifest.application.universalIdentifier,
|
||||
applicationRegistrationMetadata,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
// Only update registration metadata if this workspace owns it.
|
||||
// Other workspaces that install the same app attach to the existing
|
||||
// registration but must not be able to modify its metadata.
|
||||
if (
|
||||
await this.applicationRegistrationService.isOwnedByWorkspace(
|
||||
applicationRegistrationId,
|
||||
workspaceId,
|
||||
)
|
||||
) {
|
||||
await this.applicationRegistrationService.update(
|
||||
{
|
||||
id: applicationRegistrationId,
|
||||
update: applicationRegistrationMetadata,
|
||||
},
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
if (manifest.application.serverVariables) {
|
||||
await this.applicationRegistrationVariableService.syncVariableSchemas(
|
||||
applicationRegistrationId,
|
||||
manifest.application.serverVariables,
|
||||
);
|
||||
}
|
||||
const resolvedRegistrationId =
|
||||
applicationRegistrationId ?? application.applicationRegistrationId;
|
||||
|
||||
return await this.applicationService.update(application.id, {
|
||||
name,
|
||||
@@ -170,7 +134,8 @@ export class ApplicationSyncService {
|
||||
version: packageJson.version,
|
||||
packageJsonChecksum: manifest.application.packageJsonChecksum,
|
||||
yarnLockChecksum: manifest.application.yarnLockChecksum,
|
||||
applicationRegistrationId,
|
||||
applicationRegistrationId: resolvedRegistrationId,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -250,44 +215,4 @@ export class ApplicationSyncService {
|
||||
|
||||
return validateAndBuildResult.workspaceMigration;
|
||||
}
|
||||
|
||||
private async resolveApplicationRegistrationId(
|
||||
existingId: string | null,
|
||||
universalIdentifier: string,
|
||||
metadata: {
|
||||
name: string;
|
||||
description?: string;
|
||||
logoUrl?: string;
|
||||
author?: string;
|
||||
websiteUrl?: string;
|
||||
termsUrl?: string;
|
||||
},
|
||||
workspaceId: string,
|
||||
): Promise<string> {
|
||||
if (existingId) {
|
||||
return existingId;
|
||||
}
|
||||
|
||||
const existingRegistration =
|
||||
await this.applicationRegistrationService.findOneByUniversalIdentifier(
|
||||
universalIdentifier,
|
||||
);
|
||||
|
||||
if (existingRegistration) {
|
||||
return existingRegistration.id;
|
||||
}
|
||||
|
||||
const { applicationRegistration: newRegistration } =
|
||||
await this.applicationRegistrationService.create(
|
||||
{ ...metadata, universalIdentifier },
|
||||
workspaceId,
|
||||
null,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Created app registration for ${metadata.name} (${universalIdentifier})`,
|
||||
);
|
||||
|
||||
return newRegistration.id;
|
||||
}
|
||||
}
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
import { type MarketplaceDisplayData } from 'src/engine/core-modules/application/application-marketplace/types/marketplace-display-data.type';
|
||||
|
||||
export type CuratedAppEntry = {
|
||||
universalIdentifier: string;
|
||||
sourcePackage: string;
|
||||
isFeatured: boolean;
|
||||
|
||||
name: string;
|
||||
description: string;
|
||||
author: string;
|
||||
logoUrl?: string;
|
||||
websiteUrl?: string;
|
||||
termsUrl?: string;
|
||||
|
||||
richDisplayData: MarketplaceDisplayData;
|
||||
};
|
||||
|
||||
const MOCK_ENRICHMENT_APP_ID = 'a1b2c3d4-0000-0000-0000-000000000001';
|
||||
const MOCK_ENRICHMENT_JOB_ID = 'a1b2c3d4-0000-0000-0000-000000000100';
|
||||
|
||||
const COMPANY_UNIVERSAL_ID = '20202020-b374-4779-a561-80086cb2e17f';
|
||||
const PERSON_UNIVERSAL_ID = '20202020-e674-48e5-a542-72570eee7213';
|
||||
|
||||
const MOCK_LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="#1a2744"><ellipse cx="38" cy="20" rx="28" ry="10"/><rect x="10" y="20" width="56" height="50"/><ellipse cx="38" cy="70" rx="28" ry="10"/><ellipse cx="38" cy="35" rx="28" ry="10" fill="none" stroke="#fff" stroke-width="3"/><ellipse cx="38" cy="52" rx="28" ry="10" fill="none" stroke="#fff" stroke-width="3"/><circle cx="72" cy="62" r="22" fill="#1a2744"/><circle cx="72" cy="62" r="18" fill="#fff"/><path d="M72 50 L72 74 M62 58 L72 48 L82 58" stroke="#1a2744" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>`;
|
||||
const ENCODED_MOCK_LOGO = `data:image/svg+xml,${encodeURIComponent(MOCK_LOGO_SVG)}`;
|
||||
|
||||
export const MARKETPLACE_CATALOG_INDEX: CuratedAppEntry[] = [
|
||||
{
|
||||
universalIdentifier: MOCK_ENRICHMENT_APP_ID,
|
||||
sourcePackage: '@twentyhq/app-data-enrichment',
|
||||
isFeatured: true,
|
||||
name: 'Data Enrichment',
|
||||
description: 'Enrich your data easily. Choose your provider.',
|
||||
author: 'Twenty',
|
||||
logoUrl: ENCODED_MOCK_LOGO,
|
||||
websiteUrl: 'https://twenty.com',
|
||||
richDisplayData: {
|
||||
icon: 'IconSparkles',
|
||||
version: '1.0.0',
|
||||
category: 'Data',
|
||||
logo: ENCODED_MOCK_LOGO,
|
||||
screenshots: [
|
||||
'https://placehold.co/800x400/f5f5f5/666?text=Screenshot+1',
|
||||
'https://placehold.co/800x400/f5f5f5/666?text=Screenshot+2',
|
||||
'https://placehold.co/800x400/f5f5f5/666?text=Screenshot+3',
|
||||
],
|
||||
aboutDescription:
|
||||
'Enhance your workspace with automated data intelligence. This app monitors your new records and automatically populates missing details such as job titles, company size, social profiles, and industry insights.',
|
||||
providers: ['Clearbit', 'Apollo', 'Hunter.io'],
|
||||
objects: [
|
||||
{
|
||||
universalIdentifier: MOCK_ENRICHMENT_JOB_ID,
|
||||
nameSingular: 'enrichmentJob',
|
||||
namePlural: 'enrichmentJobs',
|
||||
labelSingular: 'Enrichment Job',
|
||||
labelPlural: 'Enrichment Jobs',
|
||||
description: 'Tracks data enrichment requests and their status',
|
||||
icon: 'IconSparkles',
|
||||
fields: [
|
||||
{
|
||||
name: 'status',
|
||||
type: 'SELECT',
|
||||
label: 'Status',
|
||||
description: 'Current status of the enrichment job',
|
||||
icon: 'IconProgressCheck',
|
||||
universalIdentifier: 'a1b2c3d4-0000-0000-0000-000000000101',
|
||||
objectUniversalIdentifier: MOCK_ENRICHMENT_JOB_ID,
|
||||
},
|
||||
{
|
||||
name: 'provider',
|
||||
type: 'TEXT',
|
||||
label: 'Provider',
|
||||
description: 'Enrichment provider used',
|
||||
icon: 'IconCloud',
|
||||
universalIdentifier: 'a1b2c3d4-0000-0000-0000-000000000102',
|
||||
objectUniversalIdentifier: MOCK_ENRICHMENT_JOB_ID,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
name: 'industry',
|
||||
type: 'TEXT',
|
||||
label: 'Industry',
|
||||
description: 'Company industry from enrichment',
|
||||
icon: 'IconBuildingFactory2',
|
||||
objectUniversalIdentifier: COMPANY_UNIVERSAL_ID,
|
||||
universalIdentifier: 'a1b2c3d4-0000-0000-0000-000000000201',
|
||||
},
|
||||
{
|
||||
name: 'linkedInUrl',
|
||||
type: 'LINKS',
|
||||
label: 'LinkedIn URL',
|
||||
description: 'LinkedIn profile URL from enrichment',
|
||||
icon: 'IconBrandLinkedin',
|
||||
objectUniversalIdentifier: PERSON_UNIVERSAL_ID,
|
||||
universalIdentifier: 'a1b2c3d4-0000-0000-0000-000000000203',
|
||||
},
|
||||
],
|
||||
logicFunctions: [
|
||||
{
|
||||
name: 'enrich-on-create',
|
||||
description:
|
||||
'Automatically enriches new records when they are created',
|
||||
timeoutSeconds: 30,
|
||||
},
|
||||
],
|
||||
frontComponents: [],
|
||||
defaultRole: {
|
||||
id: 'a1b2c3d4-0000-0000-0000-000000000010',
|
||||
label: 'Data Enrichment default role',
|
||||
description: 'Default permissions for the Data Enrichment app',
|
||||
canReadAllObjectRecords: true,
|
||||
canUpdateAllObjectRecords: true,
|
||||
canSoftDeleteAllObjectRecords: true,
|
||||
canDestroyAllObjectRecords: true,
|
||||
canUpdateAllSettings: false,
|
||||
canAccessAllTools: false,
|
||||
objectPermissions: [
|
||||
{
|
||||
objectUniversalIdentifier: COMPANY_UNIVERSAL_ID,
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
canDestroyObjectRecords: false,
|
||||
},
|
||||
{
|
||||
objectUniversalIdentifier: PERSON_UNIVERSAL_ID,
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: true,
|
||||
canDestroyObjectRecords: false,
|
||||
},
|
||||
],
|
||||
fieldPermissions: [],
|
||||
permissionFlags: ['DATA_MODEL', 'API_KEYS_AND_WEBHOOKS'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
universalIdentifier: '4ec0391d-18d5-411c-b2f3-266ddc1c3ef7',
|
||||
sourcePackage: '@twentyhq/hello-world',
|
||||
isFeatured: false,
|
||||
name: 'Hello World',
|
||||
description: 'A simple hello world app to get started with Twenty apps.',
|
||||
author: 'Twenty',
|
||||
websiteUrl: 'https://twenty.com',
|
||||
richDisplayData: {
|
||||
icon: 'IconWorld',
|
||||
version: '0.2.2',
|
||||
category: 'Getting Started',
|
||||
screenshots: [],
|
||||
aboutDescription:
|
||||
'A minimal example app that demonstrates the Twenty app framework. Creates a PostCard object and a logic function to generate new postcards. Great starting point for building your own apps.',
|
||||
providers: [],
|
||||
objects: [
|
||||
{
|
||||
universalIdentifier: 'e2c3d4f5-0000-0000-0000-000000000001',
|
||||
nameSingular: 'postCard',
|
||||
namePlural: 'postCards',
|
||||
labelSingular: 'Post Card',
|
||||
labelPlural: 'Post Cards',
|
||||
description: 'A simple postcard object',
|
||||
icon: 'IconMail',
|
||||
fields: [],
|
||||
},
|
||||
],
|
||||
fields: [],
|
||||
logicFunctions: [
|
||||
{
|
||||
name: 'create-new-post-card',
|
||||
description: 'Creates a new postcard record',
|
||||
},
|
||||
],
|
||||
frontComponents: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
|
||||
import { MARKETPLACE_CATALOG_SYNC_CRON_PATTERN } from 'src/engine/core-modules/application/application-marketplace/crons/constants/marketplace-catalog-sync-cron-pattern.constant';
|
||||
import { MarketplaceCatalogSyncCronJob } from 'src/engine/core-modules/application/application-marketplace/crons/marketplace-catalog-sync.cron.job';
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
||||
|
||||
@Command({
|
||||
name: 'cron:marketplace-catalog-sync',
|
||||
description:
|
||||
'Starts a cron job to sync the marketplace catalog into ApplicationRegistration',
|
||||
})
|
||||
export class MarketplaceCatalogSyncCronCommand extends CommandRunner {
|
||||
constructor(
|
||||
@InjectMessageQueue(MessageQueue.cronQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
await this.messageQueueService.add(MarketplaceCatalogSyncCronJob.name, {});
|
||||
|
||||
await this.messageQueueService.addCron<undefined>({
|
||||
jobName: MarketplaceCatalogSyncCronJob.name,
|
||||
data: undefined,
|
||||
options: {
|
||||
repeat: {
|
||||
pattern: MARKETPLACE_CATALOG_SYNC_CRON_PATTERN,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const MARKETPLACE_CATALOG_SYNC_CRON_PATTERN = '0 * * * *';
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
|
||||
import { MARKETPLACE_CATALOG_SYNC_CRON_PATTERN } from 'src/engine/core-modules/application/application-marketplace/crons/constants/marketplace-catalog-sync-cron-pattern.constant';
|
||||
import { MarketplaceCatalogSyncService } from 'src/engine/core-modules/application/application-marketplace/services/marketplace-catalog-sync.service';
|
||||
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
|
||||
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
|
||||
@Injectable()
|
||||
@Processor(MessageQueue.cronQueue)
|
||||
export class MarketplaceCatalogSyncCronJob {
|
||||
private readonly logger = new Logger(MarketplaceCatalogSyncCronJob.name);
|
||||
|
||||
constructor(
|
||||
private readonly marketplaceCatalogSyncService: MarketplaceCatalogSyncService,
|
||||
) {}
|
||||
|
||||
@Process(MarketplaceCatalogSyncCronJob.name)
|
||||
@SentryCronMonitor(
|
||||
MarketplaceCatalogSyncCronJob.name,
|
||||
MARKETPLACE_CATALOG_SYNC_CRON_PATTERN,
|
||||
)
|
||||
async handle(): Promise<void> {
|
||||
this.logger.log('Starting marketplace catalog sync...');
|
||||
|
||||
try {
|
||||
await this.marketplaceCatalogSyncService.syncCatalog();
|
||||
this.logger.log('Marketplace catalog sync completed successfully');
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Marketplace catalog sync failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
@@ -7,7 +7,9 @@ import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
@ObjectType('MarketplaceAppField')
|
||||
export class MarketplaceAppFieldDTO {
|
||||
@@ -274,6 +276,13 @@ export class MarketplaceAppDTO {
|
||||
frontComponents: MarketplaceAppFrontComponentDTO[];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => MarketplaceAppDefaultRoleDTO)
|
||||
@Field(() => MarketplaceAppDefaultRoleDTO, { nullable: true })
|
||||
defaultRole?: MarketplaceAppDefaultRoleDTO;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Field({ nullable: true })
|
||||
sourcePackage?: string;
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { ApplicationInstallModule } from 'src/engine/core-modules/application/application-install/application-install.module';
|
||||
import { MarketplaceCatalogSyncCronCommand } from 'src/engine/core-modules/application/application-marketplace/crons/commands/marketplace-catalog-sync.cron.command';
|
||||
import { MarketplaceCatalogSyncCronJob } from 'src/engine/core-modules/application/application-marketplace/crons/marketplace-catalog-sync.cron.job';
|
||||
import { MarketplaceCatalogSyncService } from 'src/engine/core-modules/application/application-marketplace/services/marketplace-catalog-sync.service';
|
||||
import { MarketplaceQueryService } from 'src/engine/core-modules/application/application-marketplace/services/marketplace-query.service';
|
||||
import { MarketplaceResolver } from 'src/engine/core-modules/application/application-marketplace/resolvers/marketplace.resolver';
|
||||
import { MarketplaceService } from 'src/engine/core-modules/application/application-marketplace/services/marketplace.service';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ApplicationRegistrationEntity]),
|
||||
ApplicationInstallModule,
|
||||
FeatureFlagModule,
|
||||
PermissionsModule,
|
||||
TwentyConfigModule,
|
||||
],
|
||||
providers: [
|
||||
MarketplaceService,
|
||||
MarketplaceCatalogSyncService,
|
||||
MarketplaceQueryService,
|
||||
MarketplaceCatalogSyncCronJob,
|
||||
MarketplaceCatalogSyncCronCommand,
|
||||
MarketplaceResolver,
|
||||
],
|
||||
exports: [
|
||||
MarketplaceCatalogSyncService,
|
||||
MarketplaceQueryService,
|
||||
MarketplaceCatalogSyncCronCommand,
|
||||
],
|
||||
})
|
||||
export class MarketplaceModule {}
|
||||
|
Before Width: | Height: | Size: 743 B After Width: | Height: | Size: 743 B |
+114
@@ -0,0 +1,114 @@
|
||||
import { UseFilters, UseGuards } from '@nestjs/common';
|
||||
import { Args, Mutation, Query } from '@nestjs/graphql';
|
||||
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { ApplicationRegistrationExceptionFilter } from 'src/engine/core-modules/application/application-registration/application-registration-exception-filter';
|
||||
import {
|
||||
ApplicationRegistrationException,
|
||||
ApplicationRegistrationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
|
||||
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
|
||||
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
|
||||
import { AppUpgradeService } from 'src/engine/core-modules/application/application-install/app-upgrade.service';
|
||||
import { MarketplaceAppDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app.dto';
|
||||
import { MarketplaceQueryService } from 'src/engine/core-modules/application/application-marketplace/services/marketplace-query.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import {
|
||||
FeatureFlagGuard,
|
||||
RequireFeatureFlag,
|
||||
} from 'src/engine/guards/feature-flag.guard';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
|
||||
@MetadataResolver()
|
||||
@UseFilters(ApplicationRegistrationExceptionFilter)
|
||||
@UseGuards(
|
||||
UserAuthGuard,
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
NoPermissionGuard,
|
||||
)
|
||||
export class MarketplaceResolver {
|
||||
constructor(
|
||||
private readonly marketplaceQueryService: MarketplaceQueryService,
|
||||
private readonly applicationInstallService: ApplicationInstallService,
|
||||
private readonly appUpgradeService: AppUpgradeService,
|
||||
) {}
|
||||
|
||||
@Query(() => [MarketplaceAppDTO])
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
async findManyMarketplaceApps(): Promise<MarketplaceAppDTO[]> {
|
||||
return this.marketplaceQueryService.findManyMarketplaceApps();
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.MARKETPLACE_APPS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
async installMarketplaceApp(
|
||||
@Args('universalIdentifier') universalIdentifier: string,
|
||||
@Args('version', { type: () => String, nullable: true })
|
||||
version: string | undefined,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<boolean> {
|
||||
const registration =
|
||||
await this.marketplaceQueryService.findRegistrationByUniversalIdentifier(
|
||||
universalIdentifier,
|
||||
);
|
||||
|
||||
if (registration.sourceType !== AppRegistrationSourceType.NPM) {
|
||||
throw new ApplicationRegistrationException(
|
||||
`Only NPM apps can be installed via the marketplace`,
|
||||
ApplicationRegistrationExceptionCode.SOURCE_CHANNEL_MISMATCH,
|
||||
);
|
||||
}
|
||||
|
||||
return this.applicationInstallService.installApplication({
|
||||
appRegistrationId: registration.id,
|
||||
version,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.MARKETPLACE_APPS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
async installNpmApp(
|
||||
@Args('packageName') packageName: string,
|
||||
@Args('version', { type: () => String, nullable: true })
|
||||
version: string | undefined,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<boolean> {
|
||||
const registration =
|
||||
await this.marketplaceQueryService.findOrCreateNpmRegistration({
|
||||
packageName,
|
||||
ownerWorkspaceId: workspace.id,
|
||||
});
|
||||
|
||||
return this.applicationInstallService.installApplication({
|
||||
appRegistrationId: registration.id,
|
||||
version,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.MARKETPLACE_APPS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
async upgradeApplication(
|
||||
@Args('appRegistrationId') appRegistrationId: string,
|
||||
@Args('targetVersion') targetVersion: string,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<boolean> {
|
||||
return this.appUpgradeService.upgradeApplication({
|
||||
appRegistrationId,
|
||||
targetVersion,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
|
||||
import { MARKETPLACE_CATALOG_INDEX } from 'src/engine/core-modules/application/application-marketplace/constants/marketplace-catalog-index.constant';
|
||||
import { MarketplaceService } from 'src/engine/core-modules/application/application-marketplace/services/marketplace.service';
|
||||
import { getAdminWorkspaceId } from 'src/engine/core-modules/application/application-marketplace/utils/get-admin-workspace-id.util';
|
||||
|
||||
@Injectable()
|
||||
export class MarketplaceCatalogSyncService {
|
||||
private readonly logger = new Logger(MarketplaceCatalogSyncService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ApplicationRegistrationEntity)
|
||||
private readonly appRegistrationRepository: Repository<ApplicationRegistrationEntity>,
|
||||
private readonly marketplaceService: MarketplaceService,
|
||||
) {}
|
||||
|
||||
async syncCatalog(): Promise<void> {
|
||||
const dataSource = this.appRegistrationRepository.manager.connection;
|
||||
const adminWorkspaceId = await getAdminWorkspaceId(dataSource);
|
||||
|
||||
if (!isDefined(adminWorkspaceId)) {
|
||||
this.logger.warn(
|
||||
'No admin workspace found. Skipping marketplace catalog sync.',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.syncCuratedApps(adminWorkspaceId);
|
||||
await this.syncNpmApps(adminWorkspaceId);
|
||||
|
||||
this.logger.log('Marketplace catalog sync completed');
|
||||
}
|
||||
|
||||
private async syncCuratedApps(ownerWorkspaceId: string): Promise<void> {
|
||||
for (const entry of MARKETPLACE_CATALOG_INDEX) {
|
||||
try {
|
||||
await this.upsertRegistration({
|
||||
universalIdentifier: entry.universalIdentifier,
|
||||
name: entry.name,
|
||||
description:
|
||||
entry.richDisplayData.aboutDescription ?? entry.description,
|
||||
author: entry.author,
|
||||
sourceType: AppRegistrationSourceType.NPM,
|
||||
sourcePackage: entry.sourcePackage,
|
||||
logoUrl: entry.logoUrl ?? null,
|
||||
websiteUrl: entry.websiteUrl ?? null,
|
||||
termsUrl: entry.termsUrl ?? null,
|
||||
latestAvailableVersion: entry.richDisplayData.version ?? null,
|
||||
isFeatured: entry.isFeatured,
|
||||
marketplaceDisplayData: entry.richDisplayData,
|
||||
ownerWorkspaceId,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to sync curated app "${entry.name}": ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async syncNpmApps(ownerWorkspaceId: string): Promise<void> {
|
||||
const npmApps = await this.marketplaceService.fetchAppsFromNpmRegistry();
|
||||
|
||||
const curatedIdentifiers = new Set(
|
||||
MARKETPLACE_CATALOG_INDEX.map((entry) => entry.universalIdentifier),
|
||||
);
|
||||
|
||||
for (const app of npmApps) {
|
||||
if (curatedIdentifiers.has(app.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.upsertRegistration({
|
||||
universalIdentifier: app.id,
|
||||
name: app.name,
|
||||
description: app.description,
|
||||
author: app.author,
|
||||
sourceType: AppRegistrationSourceType.NPM,
|
||||
sourcePackage: app.sourcePackage ?? app.name,
|
||||
logoUrl: null,
|
||||
websiteUrl: app.websiteUrl ?? null,
|
||||
termsUrl: null,
|
||||
latestAvailableVersion: app.version ?? null,
|
||||
isFeatured: false,
|
||||
marketplaceDisplayData: null,
|
||||
ownerWorkspaceId,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to sync npm app "${app.name}": ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lookup by universalIdentifier only (matches the unique constraint).
|
||||
// ownerWorkspaceId is only set on insert.
|
||||
private async upsertRegistration(
|
||||
params: Pick<
|
||||
ApplicationRegistrationEntity,
|
||||
| 'universalIdentifier'
|
||||
| 'name'
|
||||
| 'description'
|
||||
| 'author'
|
||||
| 'sourceType'
|
||||
| 'sourcePackage'
|
||||
| 'logoUrl'
|
||||
| 'websiteUrl'
|
||||
| 'termsUrl'
|
||||
| 'latestAvailableVersion'
|
||||
| 'isFeatured'
|
||||
| 'marketplaceDisplayData'
|
||||
| 'ownerWorkspaceId'
|
||||
>,
|
||||
): Promise<void> {
|
||||
const existing = await this.appRegistrationRepository.findOne({
|
||||
where: {
|
||||
universalIdentifier: params.universalIdentifier,
|
||||
},
|
||||
});
|
||||
|
||||
if (isDefined(existing)) {
|
||||
await this.appRegistrationRepository.save({
|
||||
...existing,
|
||||
name: params.name,
|
||||
description: params.description,
|
||||
author: params.author,
|
||||
sourceType: params.sourceType,
|
||||
sourcePackage: params.sourcePackage,
|
||||
logoUrl: params.logoUrl,
|
||||
websiteUrl: params.websiteUrl,
|
||||
termsUrl: params.termsUrl,
|
||||
latestAvailableVersion: params.latestAvailableVersion,
|
||||
isFeatured: params.isFeatured,
|
||||
marketplaceDisplayData: params.marketplaceDisplayData,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const registration = this.appRegistrationRepository.create({
|
||||
universalIdentifier: params.universalIdentifier,
|
||||
name: params.name,
|
||||
description: params.description,
|
||||
author: params.author,
|
||||
sourceType: params.sourceType,
|
||||
sourcePackage: params.sourcePackage,
|
||||
logoUrl: params.logoUrl,
|
||||
websiteUrl: params.websiteUrl,
|
||||
termsUrl: params.termsUrl,
|
||||
latestAvailableVersion: params.latestAvailableVersion,
|
||||
isFeatured: params.isFeatured,
|
||||
marketplaceDisplayData: params.marketplaceDisplayData,
|
||||
oAuthClientId: v4(),
|
||||
oAuthRedirectUris: [],
|
||||
oAuthScopes: [],
|
||||
ownerWorkspaceId: params.ownerWorkspaceId,
|
||||
});
|
||||
|
||||
await this.appRegistrationRepository.save(registration);
|
||||
}
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import {
|
||||
ApplicationRegistrationException,
|
||||
ApplicationRegistrationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
|
||||
import { assertValidNpmPackageName } from 'src/engine/core-modules/application/utils/assert-valid-npm-package-name.util';
|
||||
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
|
||||
import { MarketplaceCatalogSyncCronJob } from 'src/engine/core-modules/application/application-marketplace/crons/marketplace-catalog-sync.cron.job';
|
||||
import { MarketplaceAppDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app.dto';
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
||||
|
||||
const MARKETPLACE_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class MarketplaceQueryService {
|
||||
private readonly logger = new Logger(MarketplaceQueryService.name);
|
||||
private cachedApps: MarketplaceAppDTO[] | null = null;
|
||||
private cacheExpiresAt = 0;
|
||||
private hasSyncBeenEnqueued = false;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ApplicationRegistrationEntity)
|
||||
private readonly appRegistrationRepository: Repository<ApplicationRegistrationEntity>,
|
||||
@InjectMessageQueue(MessageQueue.cronQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
) {}
|
||||
|
||||
async findManyMarketplaceApps(): Promise<MarketplaceAppDTO[]> {
|
||||
if (this.cachedApps !== null && Date.now() < this.cacheExpiresAt) {
|
||||
return this.cachedApps;
|
||||
}
|
||||
|
||||
const registrations = await this.appRegistrationRepository.find({
|
||||
where: { sourceType: AppRegistrationSourceType.NPM },
|
||||
});
|
||||
|
||||
if (registrations.length === 0) {
|
||||
if (!this.hasSyncBeenEnqueued) {
|
||||
this.hasSyncBeenEnqueued = true;
|
||||
this.logger.log(
|
||||
'No marketplace registrations found, enqueuing one-time sync job',
|
||||
);
|
||||
await this.messageQueueService.add(
|
||||
MarketplaceCatalogSyncCronJob.name,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
this.cachedApps = registrations.map((registration) =>
|
||||
this.toMarketplaceAppDTO(registration),
|
||||
);
|
||||
this.cacheExpiresAt = Date.now() + MARKETPLACE_CACHE_TTL_MS;
|
||||
|
||||
return this.cachedApps;
|
||||
}
|
||||
|
||||
async findRegistrationByUniversalIdentifier(
|
||||
universalIdentifier: string,
|
||||
): Promise<ApplicationRegistrationEntity> {
|
||||
const registration = await this.appRegistrationRepository.findOne({
|
||||
where: { universalIdentifier },
|
||||
});
|
||||
|
||||
if (!isDefined(registration)) {
|
||||
throw new ApplicationRegistrationException(
|
||||
`No application registration found for identifier "${universalIdentifier}"`,
|
||||
ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return registration;
|
||||
}
|
||||
|
||||
async findOrCreateNpmRegistration(params: {
|
||||
packageName: string;
|
||||
ownerWorkspaceId: string;
|
||||
}): Promise<ApplicationRegistrationEntity> {
|
||||
assertValidNpmPackageName(params.packageName);
|
||||
|
||||
const existing = await this.appRegistrationRepository.findOne({
|
||||
where: { sourcePackage: params.packageName },
|
||||
});
|
||||
|
||||
if (isDefined(existing)) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Creating new registration for npm package "${params.packageName}"`,
|
||||
);
|
||||
|
||||
try {
|
||||
const registration = this.appRegistrationRepository.create({
|
||||
universalIdentifier: v4(),
|
||||
name: params.packageName,
|
||||
sourceType: AppRegistrationSourceType.NPM,
|
||||
sourcePackage: params.packageName,
|
||||
oAuthClientId: v4(),
|
||||
oAuthRedirectUris: [],
|
||||
oAuthScopes: [],
|
||||
ownerWorkspaceId: params.ownerWorkspaceId,
|
||||
});
|
||||
|
||||
return await this.appRegistrationRepository.save(registration);
|
||||
} catch {
|
||||
const concurrentlyCreated = await this.appRegistrationRepository.findOne({
|
||||
where: { sourcePackage: params.packageName },
|
||||
});
|
||||
|
||||
if (isDefined(concurrentlyCreated)) {
|
||||
return concurrentlyCreated;
|
||||
}
|
||||
|
||||
throw new ApplicationRegistrationException(
|
||||
`Failed to create registration for package "${params.packageName}"`,
|
||||
ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
toMarketplaceAppDTO(
|
||||
registration: ApplicationRegistrationEntity,
|
||||
): MarketplaceAppDTO {
|
||||
const displayData = registration.marketplaceDisplayData;
|
||||
|
||||
return {
|
||||
id: registration.universalIdentifier,
|
||||
name: registration.name,
|
||||
description: registration.description ?? '',
|
||||
icon: displayData?.icon ?? 'IconApps',
|
||||
version:
|
||||
displayData?.version ?? registration.latestAvailableVersion ?? '0.0.0',
|
||||
author: registration.author ?? 'Unknown',
|
||||
category: displayData?.category ?? '',
|
||||
logo: displayData?.logo,
|
||||
screenshots: displayData?.screenshots ?? [],
|
||||
aboutDescription:
|
||||
displayData?.aboutDescription ?? registration.description ?? '',
|
||||
providers: displayData?.providers ?? [],
|
||||
websiteUrl: registration.websiteUrl ?? undefined,
|
||||
termsUrl: registration.termsUrl ?? undefined,
|
||||
objects: displayData?.objects ?? [],
|
||||
fields: displayData?.fields ?? [],
|
||||
logicFunctions: displayData?.logicFunctions ?? [],
|
||||
frontComponents: displayData?.frontComponents ?? [],
|
||||
sourcePackage: registration.sourcePackage ?? undefined,
|
||||
defaultRole: displayData?.defaultRole,
|
||||
};
|
||||
}
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import axios from 'axios';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { MarketplaceAppDTO } from 'src/engine/core-modules/application/application-marketplace/dtos/marketplace-app.dto';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
const npmSearchResultSchema = z.object({
|
||||
objects: z.array(
|
||||
z.object({
|
||||
package: z.object({
|
||||
name: z.string(),
|
||||
version: z.string(),
|
||||
description: z.string().optional(),
|
||||
keywords: z.array(z.string()).optional(),
|
||||
author: z.object({ name: z.string().optional() }).optional(),
|
||||
links: z.object({ homepage: z.string().optional() }).optional(),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class MarketplaceService {
|
||||
private readonly logger = new Logger(MarketplaceService.name);
|
||||
|
||||
constructor(private readonly twentyConfigService: TwentyConfigService) {}
|
||||
|
||||
async fetchAppsFromNpmRegistry(): Promise<MarketplaceAppDTO[]> {
|
||||
const registryUrl = this.twentyConfigService.get('APP_REGISTRY_URL');
|
||||
|
||||
try {
|
||||
const { data } = await axios.get(
|
||||
`${registryUrl}/-/v1/search?text=keywords:twenty-app&size=250`,
|
||||
{
|
||||
headers: { 'User-Agent': 'Twenty-Marketplace' },
|
||||
timeout: 10_000,
|
||||
},
|
||||
);
|
||||
|
||||
const parsed = npmSearchResultSchema.safeParse(data);
|
||||
|
||||
if (!parsed.success) {
|
||||
this.logger.warn(
|
||||
`Unexpected npm search response shape: ${parsed.error.message}`,
|
||||
);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return parsed.data.objects
|
||||
.map((result) => {
|
||||
const { name, version, description, author, links } = result.package;
|
||||
const twentyKeyword = (result.package.keywords ?? []).find(
|
||||
(keyword) => keyword.startsWith('twenty-uid:'),
|
||||
);
|
||||
|
||||
if (!isDefined(twentyKeyword)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const universalIdentifier = twentyKeyword.replace('twenty-uid:', '');
|
||||
|
||||
return {
|
||||
id: universalIdentifier,
|
||||
name,
|
||||
description: description ?? '',
|
||||
icon: 'IconApps',
|
||||
version,
|
||||
author: author?.name ?? 'Unknown',
|
||||
category: '',
|
||||
screenshots: [],
|
||||
aboutDescription: description ?? '',
|
||||
providers: [],
|
||||
websiteUrl: links?.homepage,
|
||||
objects: [],
|
||||
fields: [],
|
||||
logicFunctions: [],
|
||||
frontComponents: [],
|
||||
sourcePackage: name,
|
||||
};
|
||||
})
|
||||
.filter(isDefined);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to fetch apps from npm registry: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
// Rich display data stored alongside ApplicationRegistration for marketplace
|
||||
// rendering. This is denormalized from the catalog source so it can be displayed
|
||||
// pre-install without resolving the package.
|
||||
export type MarketplaceDisplayData = {
|
||||
icon?: string;
|
||||
version?: string;
|
||||
category?: string;
|
||||
logo?: string;
|
||||
screenshots?: string[];
|
||||
aboutDescription?: string;
|
||||
providers?: string[];
|
||||
objects?: MarketplaceDisplayObject[];
|
||||
fields?: MarketplaceDisplayField[];
|
||||
logicFunctions?: MarketplaceDisplayLogicFunction[];
|
||||
frontComponents?: MarketplaceDisplayFrontComponent[];
|
||||
defaultRole?: MarketplaceDisplayDefaultRole;
|
||||
};
|
||||
|
||||
type MarketplaceDisplayObject = {
|
||||
universalIdentifier: string;
|
||||
nameSingular: string;
|
||||
namePlural: string;
|
||||
labelSingular: string;
|
||||
labelPlural: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
fields: MarketplaceDisplayField[];
|
||||
};
|
||||
|
||||
type MarketplaceDisplayField = {
|
||||
name: string;
|
||||
type: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
objectUniversalIdentifier: string;
|
||||
universalIdentifier: string;
|
||||
};
|
||||
|
||||
type MarketplaceDisplayLogicFunction = {
|
||||
name: string;
|
||||
description?: string;
|
||||
timeoutSeconds?: number;
|
||||
};
|
||||
|
||||
type MarketplaceDisplayFrontComponent = {
|
||||
name: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
type MarketplaceDisplayDefaultRole = {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
canReadAllObjectRecords: boolean;
|
||||
canUpdateAllObjectRecords: boolean;
|
||||
canSoftDeleteAllObjectRecords: boolean;
|
||||
canDestroyAllObjectRecords: boolean;
|
||||
canUpdateAllSettings: boolean;
|
||||
canAccessAllTools: boolean;
|
||||
objectPermissions: Array<{
|
||||
objectUniversalIdentifier: string;
|
||||
canReadObjectRecords?: boolean;
|
||||
canUpdateObjectRecords?: boolean;
|
||||
canSoftDeleteObjectRecords?: boolean;
|
||||
canDestroyObjectRecords?: boolean;
|
||||
}>;
|
||||
fieldPermissions: Array<{
|
||||
objectUniversalIdentifier: string;
|
||||
fieldUniversalIdentifier: string;
|
||||
canReadFieldValue?: boolean;
|
||||
canUpdateFieldValue?: boolean;
|
||||
}>;
|
||||
permissionFlags: string[];
|
||||
};
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
|
||||
import { type DataSource } from 'typeorm';
|
||||
|
||||
// Every ApplicationRegistration must be owned by a workspace (ownerWorkspaceId
|
||||
// represents ownership / write-access, not visibility scoping — marketplace
|
||||
// registrations are readable by all workspaces). When the catalog sync creates
|
||||
// registrations for marketplace apps that no developer has explicitly claimed,
|
||||
// we assign them to the "admin" workspace: the oldest active workspace whose
|
||||
// owner has admin privileges.
|
||||
//
|
||||
// TODO: This heuristic is fragile — on fresh instances with no admin users the
|
||||
// catalog sync is silently skipped, and if the admin workspace is later deleted
|
||||
// all marketplace registrations become orphaned. Consider introducing a
|
||||
// dedicated "platform" workspace or making ownerWorkspaceId nullable instead.
|
||||
export const getAdminWorkspaceId = async (
|
||||
dataSource: DataSource,
|
||||
): Promise<string | null> => {
|
||||
const result = await dataSource.query<Array<{ workspaceId: string }>>(
|
||||
`SELECT uw."workspaceId"
|
||||
FROM core."userWorkspace" uw
|
||||
JOIN core."user" u ON u.id = uw."userId" AND u."deletedAt" IS NULL
|
||||
JOIN core."workspace" w ON w.id = uw."workspaceId" AND w."deletedAt" IS NULL
|
||||
WHERE (u."canAccessFullAdminPanel" = true OR u."canImpersonate" = true)
|
||||
AND w."activationStatus" = $1
|
||||
AND uw."deletedAt" IS NULL
|
||||
ORDER BY w."createdAt" ASC
|
||||
LIMIT 1`,
|
||||
[WorkspaceActivationStatus.ACTIVE],
|
||||
);
|
||||
|
||||
if (result.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result[0].workspaceId;
|
||||
};
|
||||
+1
-1
@@ -2,7 +2,7 @@ import {
|
||||
ALL_OAUTH_SCOPES,
|
||||
OAUTH_SCOPE_DESCRIPTIONS,
|
||||
OAUTH_SCOPES,
|
||||
} from 'src/engine/core-modules/application-registration/constants/oauth-scopes';
|
||||
} from 'src/engine/core-modules/application/application-registration/constants/oauth-scopes';
|
||||
|
||||
describe('OAuth Scopes', () => {
|
||||
it('should have all scopes defined', () => {
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { Catch, ExceptionFilter } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
ApplicationRegistrationException,
|
||||
ApplicationRegistrationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
|
||||
import {
|
||||
InternalServerError,
|
||||
NotFoundError,
|
||||
UserInputError,
|
||||
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
|
||||
@Catch(ApplicationRegistrationException)
|
||||
export class ApplicationRegistrationExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: ApplicationRegistrationException) {
|
||||
switch (exception.code) {
|
||||
case ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND:
|
||||
case ApplicationRegistrationExceptionCode.VARIABLE_NOT_FOUND:
|
||||
throw new NotFoundError(exception);
|
||||
case ApplicationRegistrationExceptionCode.INVALID_INPUT:
|
||||
case ApplicationRegistrationExceptionCode.INVALID_SCOPE:
|
||||
case ApplicationRegistrationExceptionCode.INVALID_REDIRECT_URI:
|
||||
case ApplicationRegistrationExceptionCode.SOURCE_CHANNEL_MISMATCH:
|
||||
case ApplicationRegistrationExceptionCode.UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED:
|
||||
throw new UserInputError(exception);
|
||||
default:
|
||||
throw new InternalServerError(exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -15,7 +15,7 @@ import {
|
||||
} from 'typeorm';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
|
||||
@Entity({ name: 'applicationRegistrationVariable', schema: 'core' })
|
||||
@ObjectType('ApplicationRegistrationVariable')
|
||||
+6
-6
@@ -5,14 +5,14 @@ import { type ServerVariables } from 'twenty-shared/application';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { In, Not, type Repository } from 'typeorm';
|
||||
|
||||
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application-registration/application-registration-variable.entity';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity';
|
||||
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration/application-registration-variable.entity';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import {
|
||||
ApplicationRegistrationException,
|
||||
ApplicationRegistrationExceptionCode,
|
||||
} from 'src/engine/core-modules/application-registration/application-registration.exception';
|
||||
import { type CreateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application-registration/dtos/create-application-registration-variable.input';
|
||||
import { type UpdateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application-registration/dtos/update-application-registration-variable.input';
|
||||
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
|
||||
import { type CreateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration-variable.input';
|
||||
import { type UpdateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration/dtos/update-application-registration-variable.input';
|
||||
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
|
||||
|
||||
@Injectable()
|
||||
@@ -176,7 +176,7 @@ export class ApplicationRegistrationVariableService {
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const registration = await this.applicationRegistrationRepository.findOne({
|
||||
where: { id: registrationId, workspaceId },
|
||||
where: { id: registrationId, ownerWorkspaceId: workspaceId },
|
||||
});
|
||||
|
||||
if (!registration) {
|
||||
+45
-4
@@ -2,6 +2,7 @@ import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { IDField } from '@ptc-org/nestjs-query-graphql';
|
||||
import {
|
||||
Check,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
@@ -10,13 +11,17 @@ import {
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
OneToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
type Relation,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application-registration/application-registration-variable.entity';
|
||||
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration/application-registration-variable.entity';
|
||||
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { type MarketplaceDisplayData } from 'src/engine/core-modules/application/application-marketplace/types/marketplace-display-data.type';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@@ -39,7 +44,11 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
|
||||
},
|
||||
)
|
||||
@Index('IDX_APPLICATION_REGISTRATION_CREATED_BY_USER_ID', ['createdByUserId'])
|
||||
@Index('IDX_APPLICATION_REGISTRATION_WORKSPACE_ID', ['workspaceId'])
|
||||
@Index('IDX_APPLICATION_REGISTRATION_WORKSPACE_ID', ['ownerWorkspaceId'])
|
||||
@Check(
|
||||
'CHK_NPM_HAS_SOURCE_PACKAGE',
|
||||
`"sourceType" <> 'npm' OR "sourcePackage" IS NOT NULL`,
|
||||
)
|
||||
export class ApplicationRegistrationEntity {
|
||||
@IDField(() => UUIDScalarType)
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
@@ -87,13 +96,38 @@ export class ApplicationRegistrationEntity {
|
||||
@JoinColumn({ name: 'createdByUserId' })
|
||||
createdByUser: Relation<UserEntity> | null;
|
||||
|
||||
@Column({ nullable: false, type: 'uuid' })
|
||||
workspaceId: string;
|
||||
// Represents ownership (who can edit), not visibility scoping.
|
||||
// Marketplace registrations are readable by all workspaces but owned by the
|
||||
// admin workspace when no developer has explicitly claimed them.
|
||||
@Column({ name: 'workspaceId', nullable: false, type: 'uuid' })
|
||||
ownerWorkspaceId: string;
|
||||
|
||||
@ManyToOne(() => WorkspaceEntity, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'workspaceId' })
|
||||
workspace: Relation<WorkspaceEntity>;
|
||||
|
||||
@Field(() => AppRegistrationSourceType)
|
||||
@Column({
|
||||
type: 'text',
|
||||
default: AppRegistrationSourceType.LOCAL,
|
||||
})
|
||||
sourceType: AppRegistrationSourceType;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
sourcePackage: string | null;
|
||||
|
||||
@Column({ nullable: true, type: 'uuid' })
|
||||
tarballFileId: string | null;
|
||||
|
||||
@OneToOne(() => FileEntity, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'tarballFileId' })
|
||||
tarballFile: Relation<FileEntity> | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
latestAvailableVersion: string | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
websiteUrl: string | null;
|
||||
@@ -102,6 +136,13 @@ export class ApplicationRegistrationEntity {
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
termsUrl: string | null;
|
||||
|
||||
@Field(() => Boolean)
|
||||
@Column({ name: 'isFeatured', type: 'boolean', default: false })
|
||||
isFeatured: boolean;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
marketplaceDisplayData: MarketplaceDisplayData | null;
|
||||
|
||||
@OneToMany(
|
||||
() => ApplicationRegistrationVariableEntity,
|
||||
(variable) => variable.applicationRegistration,
|
||||
+6
@@ -9,6 +9,8 @@ export enum ApplicationRegistrationExceptionCode {
|
||||
UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED = 'UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED',
|
||||
INVALID_SCOPE = 'INVALID_SCOPE',
|
||||
INVALID_REDIRECT_URI = 'INVALID_REDIRECT_URI',
|
||||
INVALID_INPUT = 'INVALID_INPUT',
|
||||
SOURCE_CHANNEL_MISMATCH = 'SOURCE_CHANNEL_MISMATCH',
|
||||
VARIABLE_NOT_FOUND = 'VARIABLE_NOT_FOUND',
|
||||
}
|
||||
|
||||
@@ -24,6 +26,10 @@ const getExceptionUserFriendlyMessage = (
|
||||
return msg`One or more requested scopes are invalid.`;
|
||||
case ApplicationRegistrationExceptionCode.INVALID_REDIRECT_URI:
|
||||
return msg`One or more redirect URIs are invalid.`;
|
||||
case ApplicationRegistrationExceptionCode.INVALID_INPUT:
|
||||
return msg`Invalid input for application registration.`;
|
||||
case ApplicationRegistrationExceptionCode.SOURCE_CHANNEL_MISMATCH:
|
||||
return msg`The app source channel does not match the expected type.`;
|
||||
case ApplicationRegistrationExceptionCode.VARIABLE_NOT_FOUND:
|
||||
return msg`Application registration variable not found.`;
|
||||
default:
|
||||
+18
-8
@@ -1,22 +1,27 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application-registration/application-registration-variable.entity';
|
||||
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application-registration/application-registration-variable.service';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity';
|
||||
import { ApplicationRegistrationResolver } from 'src/engine/core-modules/application-registration/application-registration.resolver';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application-registration/application-registration.service';
|
||||
import { OAuthDiscoveryController } from 'src/engine/core-modules/application-registration/controllers/oauth-discovery.controller';
|
||||
import { OAuthTokenController } from 'src/engine/core-modules/application-registration/controllers/oauth-token.controller';
|
||||
import { OAuthService } from 'src/engine/core-modules/application-registration/oauth.service';
|
||||
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration/application-registration-variable.entity';
|
||||
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration/application-registration-variable.service';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { ApplicationRegistrationResolver } from 'src/engine/core-modules/application/application-registration/application-registration.resolver';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
import { OAuthDiscoveryController } from 'src/engine/core-modules/application/application-registration/controllers/oauth-discovery.controller';
|
||||
import { OAuthTokenController } from 'src/engine/core-modules/application/application-registration/controllers/oauth-token.controller';
|
||||
import { OAuthService } from 'src/engine/core-modules/application/application-registration/oauth.service';
|
||||
import { AppTarballUploadService } from 'src/engine/core-modules/application/application-registration/services/app-tarball-upload.service';
|
||||
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { ApplicationInstallModule } from 'src/engine/core-modules/application/application-install/application-install.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
|
||||
import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module';
|
||||
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -28,16 +33,21 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
|
||||
UserWorkspaceEntity,
|
||||
]),
|
||||
SecretEncryptionModule,
|
||||
FeatureFlagModule,
|
||||
PermissionsModule,
|
||||
ThrottlerModule,
|
||||
TokenModule,
|
||||
ApplicationModule,
|
||||
ApplicationInstallModule,
|
||||
FileStorageModule,
|
||||
WorkspaceCacheStorageModule,
|
||||
],
|
||||
controllers: [OAuthTokenController, OAuthDiscoveryController],
|
||||
providers: [
|
||||
ApplicationRegistrationService,
|
||||
ApplicationRegistrationVariableService,
|
||||
ApplicationRegistrationResolver,
|
||||
AppTarballUploadService,
|
||||
OAuthService,
|
||||
],
|
||||
exports: [
|
||||
+87
-13
@@ -1,20 +1,33 @@
|
||||
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
|
||||
import { Args, Mutation, Query } from '@nestjs/graphql';
|
||||
|
||||
import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
|
||||
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application-registration/application-registration-variable.entity';
|
||||
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application-registration/application-registration-variable.service';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application-registration/application-registration.service';
|
||||
import { ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application-registration/dtos/application-registration-stats.dto';
|
||||
import { CreateApplicationRegistrationInput } from 'src/engine/core-modules/application-registration/dtos/create-application-registration.input';
|
||||
import { CreateApplicationRegistrationDTO } from 'src/engine/core-modules/application-registration/dtos/create-application-registration.dto';
|
||||
import { PublicApplicationRegistrationDTO } from 'src/engine/core-modules/application-registration/dtos/public-application-registration.dto';
|
||||
import { CreateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application-registration/dtos/create-application-registration-variable.input';
|
||||
import { RotateClientSecretDTO } from 'src/engine/core-modules/application-registration/dtos/rotate-client-secret.dto';
|
||||
import { UpdateApplicationRegistrationInput } from 'src/engine/core-modules/application-registration/dtos/update-application-registration.input';
|
||||
import { UpdateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application-registration/dtos/update-application-registration-variable.input';
|
||||
import type { FileUpload } from 'graphql-upload/processRequest.mjs';
|
||||
|
||||
import { ApplicationRegistrationExceptionFilter } from 'src/engine/core-modules/application/application-registration/application-registration-exception-filter';
|
||||
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration/application-registration-variable.entity';
|
||||
import { ApplicationRegistrationVariableService } from 'src/engine/core-modules/application/application-registration/application-registration-variable.service';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
import {
|
||||
AppTarballUploadService,
|
||||
MAX_TARBALL_UPLOAD_SIZE_BYTES,
|
||||
} from 'src/engine/core-modules/application/application-registration/services/app-tarball-upload.service';
|
||||
import { ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-stats.dto';
|
||||
import { CreateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration.input';
|
||||
import { CreateApplicationRegistrationDTO } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration.dto';
|
||||
import { PublicApplicationRegistrationDTO } from 'src/engine/core-modules/application/application-registration/dtos/public-application-registration.dto';
|
||||
import { CreateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration-variable.input';
|
||||
import { RotateClientSecretDTO } from 'src/engine/core-modules/application/application-registration/dtos/rotate-client-secret.dto';
|
||||
import { UpdateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/update-application-registration.input';
|
||||
import { UpdateApplicationRegistrationVariableInput } from 'src/engine/core-modules/application/application-registration/dtos/update-application-registration-variable.input';
|
||||
import {
|
||||
ApplicationRegistrationException,
|
||||
ApplicationRegistrationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
|
||||
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
|
||||
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
@@ -23,14 +36,20 @@ import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.ent
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import {
|
||||
FeatureFlagGuard,
|
||||
RequireFeatureFlag,
|
||||
} from 'src/engine/guards/feature-flag.guard';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { streamToBuffer } from 'src/utils/stream-to-buffer';
|
||||
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
@MetadataResolver()
|
||||
@UseFilters(
|
||||
ApplicationRegistrationExceptionFilter,
|
||||
AuthGraphqlApiExceptionFilter,
|
||||
PreventNestToAutoLogGraphqlErrorsFilter,
|
||||
)
|
||||
@@ -38,6 +57,7 @@ export class ApplicationRegistrationResolver {
|
||||
constructor(
|
||||
private readonly applicationRegistrationService: ApplicationRegistrationService,
|
||||
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
|
||||
private readonly appTarballUploadService: AppTarballUploadService,
|
||||
) {}
|
||||
|
||||
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
|
||||
@@ -48,7 +68,8 @@ export class ApplicationRegistrationResolver {
|
||||
return this.applicationRegistrationService.findPublicByClientId(clientId);
|
||||
}
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard, NoPermissionGuard)
|
||||
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard, NoPermissionGuard)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Query(() => ApplicationRegistrationEntity, { nullable: true })
|
||||
async findApplicationRegistrationByUniversalIdentifier(
|
||||
@Args('universalIdentifier') universalIdentifier: string,
|
||||
@@ -60,8 +81,10 @@ export class ApplicationRegistrationResolver {
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Query(() => [ApplicationRegistrationEntity])
|
||||
async findManyApplicationRegistrations(
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
@@ -71,8 +94,10 @@ export class ApplicationRegistrationResolver {
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Query(() => ApplicationRegistrationEntity)
|
||||
async findOneApplicationRegistration(
|
||||
@Args('id') id: string,
|
||||
@@ -83,8 +108,10 @@ export class ApplicationRegistrationResolver {
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Query(() => ApplicationRegistrationStatsDTO)
|
||||
async findApplicationRegistrationStats(
|
||||
@Args('id') id: string,
|
||||
@@ -95,8 +122,10 @@ export class ApplicationRegistrationResolver {
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Mutation(() => CreateApplicationRegistrationDTO)
|
||||
async createApplicationRegistration(
|
||||
@Args('input') input: CreateApplicationRegistrationInput,
|
||||
@@ -112,8 +141,10 @@ export class ApplicationRegistrationResolver {
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Mutation(() => ApplicationRegistrationEntity)
|
||||
async updateApplicationRegistration(
|
||||
@Args('input') input: UpdateApplicationRegistrationInput,
|
||||
@@ -124,8 +155,10 @@ export class ApplicationRegistrationResolver {
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Mutation(() => Boolean)
|
||||
async deleteApplicationRegistration(
|
||||
@Args('id') id: string,
|
||||
@@ -136,8 +169,10 @@ export class ApplicationRegistrationResolver {
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Mutation(() => RotateClientSecretDTO)
|
||||
async rotateApplicationRegistrationClientSecret(
|
||||
@Args('id') id: string,
|
||||
@@ -154,8 +189,10 @@ export class ApplicationRegistrationResolver {
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Query(() => [ApplicationRegistrationVariableEntity])
|
||||
async findApplicationRegistrationVariables(
|
||||
@Args('applicationRegistrationId') applicationRegistrationId: string,
|
||||
@@ -169,8 +206,10 @@ export class ApplicationRegistrationResolver {
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Mutation(() => ApplicationRegistrationVariableEntity)
|
||||
async createApplicationRegistrationVariable(
|
||||
@Args('input') input: CreateApplicationRegistrationVariableInput,
|
||||
@@ -184,8 +223,10 @@ export class ApplicationRegistrationResolver {
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Mutation(() => ApplicationRegistrationVariableEntity)
|
||||
async updateApplicationRegistrationVariable(
|
||||
@Args('input') input: UpdateApplicationRegistrationVariableInput,
|
||||
@@ -199,8 +240,10 @@ export class ApplicationRegistrationResolver {
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Mutation(() => Boolean)
|
||||
async deleteApplicationRegistrationVariable(
|
||||
@Args('id') id: string,
|
||||
@@ -211,4 +254,35 @@ export class ApplicationRegistrationResolver {
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.MARKETPLACE_APPS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Mutation(() => ApplicationRegistrationEntity)
|
||||
async uploadAppTarball(
|
||||
@Args({ name: 'file', type: () => GraphQLUpload })
|
||||
{ createReadStream }: FileUpload,
|
||||
@Args('universalIdentifier', { type: () => String, nullable: true })
|
||||
universalIdentifier: string | undefined,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<ApplicationRegistrationEntity> {
|
||||
const stream = createReadStream();
|
||||
const tarballBuffer = await streamToBuffer(stream);
|
||||
|
||||
if (tarballBuffer.length > MAX_TARBALL_UPLOAD_SIZE_BYTES) {
|
||||
throw new ApplicationRegistrationException(
|
||||
`Tarball exceeds maximum size of ${MAX_TARBALL_UPLOAD_SIZE_BYTES} bytes`,
|
||||
ApplicationRegistrationExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
return this.appTarballUploadService.uploadTarball({
|
||||
tarballBuffer,
|
||||
universalIdentifier,
|
||||
ownerWorkspaceId: workspaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
+27
-24
@@ -8,16 +8,16 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
import { type Repository } from 'typeorm';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import {
|
||||
ApplicationRegistrationException,
|
||||
ApplicationRegistrationExceptionCode,
|
||||
} from 'src/engine/core-modules/application-registration/application-registration.exception';
|
||||
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application-registration/constants/oauth-scopes';
|
||||
import { type ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application-registration/dtos/application-registration-stats.dto';
|
||||
import { type CreateApplicationRegistrationInput } from 'src/engine/core-modules/application-registration/dtos/create-application-registration.input';
|
||||
import { type PublicApplicationRegistrationDTO } from 'src/engine/core-modules/application-registration/dtos/public-application-registration.dto';
|
||||
import { type UpdateApplicationRegistrationInput } from 'src/engine/core-modules/application-registration/dtos/update-application-registration.input';
|
||||
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
|
||||
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application/application-registration/constants/oauth-scopes';
|
||||
import { type ApplicationRegistrationStatsDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-stats.dto';
|
||||
import { type CreateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/create-application-registration.input';
|
||||
import { type PublicApplicationRegistrationDTO } from 'src/engine/core-modules/application/application-registration/dtos/public-application-registration.dto';
|
||||
import { type UpdateApplicationRegistrationInput } from 'src/engine/core-modules/application/application-registration/dtos/update-application-registration.input';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { validateRedirectUri } from 'src/engine/core-modules/auth/utils/validate-redirect-uri.util';
|
||||
|
||||
@@ -33,20 +33,20 @@ export class ApplicationRegistrationService {
|
||||
) {}
|
||||
|
||||
async findMany(
|
||||
workspaceId: string,
|
||||
ownerWorkspaceId: string,
|
||||
): Promise<ApplicationRegistrationEntity[]> {
|
||||
return this.applicationRegistrationRepository.find({
|
||||
where: { workspaceId },
|
||||
where: { ownerWorkspaceId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOneById(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
ownerWorkspaceId: string,
|
||||
): Promise<ApplicationRegistrationEntity> {
|
||||
const registration = await this.applicationRegistrationRepository.findOne({
|
||||
where: { id, workspaceId },
|
||||
where: { id, ownerWorkspaceId },
|
||||
});
|
||||
|
||||
if (!registration) {
|
||||
@@ -93,10 +93,10 @@ export class ApplicationRegistrationService {
|
||||
async isOwnedByWorkspace(id: string, workspaceId: string): Promise<boolean> {
|
||||
const registration = await this.applicationRegistrationRepository.findOne({
|
||||
where: { id },
|
||||
select: ['id', 'workspaceId'],
|
||||
select: ['id', 'ownerWorkspaceId'],
|
||||
});
|
||||
|
||||
return registration?.workspaceId === workspaceId;
|
||||
return registration?.ownerWorkspaceId === workspaceId;
|
||||
}
|
||||
|
||||
// Global lookup — used by app sync to find existing registrations
|
||||
@@ -110,7 +110,7 @@ export class ApplicationRegistrationService {
|
||||
|
||||
async create(
|
||||
input: CreateApplicationRegistrationInput,
|
||||
workspaceId: string,
|
||||
ownerWorkspaceId: string,
|
||||
createdByUserId: string | null,
|
||||
): Promise<{
|
||||
applicationRegistration: ApplicationRegistrationEntity;
|
||||
@@ -152,7 +152,7 @@ export class ApplicationRegistrationService {
|
||||
oAuthRedirectUris: input.oAuthRedirectUris ?? [],
|
||||
oAuthScopes: input.oAuthScopes ?? [],
|
||||
createdByUserId,
|
||||
workspaceId,
|
||||
ownerWorkspaceId,
|
||||
websiteUrl: input.websiteUrl ?? null,
|
||||
termsUrl: input.termsUrl ?? null,
|
||||
});
|
||||
@@ -166,11 +166,11 @@ export class ApplicationRegistrationService {
|
||||
|
||||
async update(
|
||||
input: UpdateApplicationRegistrationInput,
|
||||
workspaceId: string,
|
||||
ownerWorkspaceId: string,
|
||||
): Promise<ApplicationRegistrationEntity> {
|
||||
const { id, update } = input;
|
||||
|
||||
await this.findOneById(id, workspaceId);
|
||||
await this.findOneById(id, ownerWorkspaceId);
|
||||
|
||||
if (isDefined(update.oAuthRedirectUris)) {
|
||||
this.validateRedirectUris(update.oAuthRedirectUris);
|
||||
@@ -198,18 +198,21 @@ export class ApplicationRegistrationService {
|
||||
await this.applicationRegistrationRepository.update(id, updateData);
|
||||
}
|
||||
|
||||
return this.findOneById(id, workspaceId);
|
||||
return this.findOneById(id, ownerWorkspaceId);
|
||||
}
|
||||
|
||||
async delete(id: string, workspaceId: string): Promise<boolean> {
|
||||
await this.findOneById(id, workspaceId);
|
||||
async delete(id: string, ownerWorkspaceId: string): Promise<boolean> {
|
||||
await this.findOneById(id, ownerWorkspaceId);
|
||||
await this.applicationRegistrationRepository.softDelete(id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async rotateClientSecret(id: string, workspaceId: string): Promise<string> {
|
||||
await this.findOneById(id, workspaceId);
|
||||
async rotateClientSecret(
|
||||
id: string,
|
||||
ownerWorkspaceId: string,
|
||||
): Promise<string> {
|
||||
await this.findOneById(id, ownerWorkspaceId);
|
||||
|
||||
const { clientSecret, clientSecretHash } =
|
||||
await this.generateClientSecret();
|
||||
@@ -234,9 +237,9 @@ export class ApplicationRegistrationService {
|
||||
|
||||
async getStats(
|
||||
applicationRegistrationId: string,
|
||||
workspaceId: string,
|
||||
ownerWorkspaceId: string,
|
||||
): Promise<ApplicationRegistrationStatsDTO> {
|
||||
await this.findOneById(applicationRegistrationId, workspaceId);
|
||||
await this.findOneById(applicationRegistrationId, ownerWorkspaceId);
|
||||
|
||||
const versionDistribution: { version: string; count: number }[] =
|
||||
await this.applicationRepository
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
|
||||
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application-registration/constants/oauth-scopes';
|
||||
import { ALL_OAUTH_SCOPES } from 'src/engine/core-modules/application/application-registration/constants/oauth-scopes';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
|
||||
+6
-6
@@ -13,12 +13,12 @@ import {
|
||||
|
||||
import { type Request, type Response } from 'express';
|
||||
|
||||
import { OAuthIntrospectInput } from 'src/engine/core-modules/application-registration/dtos/oauth-introspect.input';
|
||||
import { OAuthRevokeInput } from 'src/engine/core-modules/application-registration/dtos/oauth-revoke.input';
|
||||
import { OAuthTokenInput } from 'src/engine/core-modules/application-registration/dtos/oauth-token.input';
|
||||
import { OAuthService } from 'src/engine/core-modules/application-registration/oauth.service';
|
||||
import { OAuthErrorResponse } from 'src/engine/core-modules/application-registration/types/oauth-error-response.type';
|
||||
import { OAuthTokenResponse } from 'src/engine/core-modules/application-registration/types/oauth-token-response.type';
|
||||
import { OAuthIntrospectInput } from 'src/engine/core-modules/application/application-registration/dtos/oauth-introspect.input';
|
||||
import { OAuthRevokeInput } from 'src/engine/core-modules/application/application-registration/dtos/oauth-revoke.input';
|
||||
import { OAuthTokenInput } from 'src/engine/core-modules/application/application-registration/dtos/oauth-token.input';
|
||||
import { OAuthService } from 'src/engine/core-modules/application/application-registration/oauth.service';
|
||||
import { OAuthErrorResponse } from 'src/engine/core-modules/application/application-registration/types/oauth-error-response.type';
|
||||
import { OAuthTokenResponse } from 'src/engine/core-modules/application/application-registration/types/oauth-token-response.type';
|
||||
import { AuthRestApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-rest-api-exception.filter';
|
||||
import { ThrottlerException } from 'src/engine/core-modules/throttler/throttler.exception';
|
||||
import { ThrottlerService } from 'src/engine/core-modules/throttler/throttler.service';
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
|
||||
@ObjectType('CreateApplicationRegistration')
|
||||
export class CreateApplicationRegistrationDTO {
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
export enum AppRegistrationSourceType {
|
||||
NPM = 'npm',
|
||||
TARBALL = 'tarball',
|
||||
LOCAL = 'local',
|
||||
}
|
||||
|
||||
registerEnumType(AppRegistrationSourceType, {
|
||||
name: 'AppRegistrationSourceType',
|
||||
});
|
||||
+33
-6
@@ -11,12 +11,14 @@ import {
|
||||
AppTokenEntity,
|
||||
AppTokenType,
|
||||
} from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application-registration/application-registration.service';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
|
||||
import { OAuthErrorResponse } from 'src/engine/core-modules/application-registration/types/oauth-error-response.type';
|
||||
import { OAuthTokenResponse } from 'src/engine/core-modules/application-registration/types/oauth-token-response.type';
|
||||
import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { OAuthErrorResponse } from 'src/engine/core-modules/application/application-registration/types/oauth-error-response.type';
|
||||
import { OAuthTokenResponse } from 'src/engine/core-modules/application/application-registration/types/oauth-token-response.type';
|
||||
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
@@ -35,6 +37,7 @@ export class OAuthService {
|
||||
private readonly applicationTokenService: ApplicationTokenService,
|
||||
private readonly applicationRegistrationService: ApplicationRegistrationService,
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly applicationInstallService: ApplicationInstallService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
@@ -558,7 +561,31 @@ export class OAuthService {
|
||||
`Auto-installing application "${applicationRegistration.name}" in workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
// TODO: defaulting to version 0.0.0, build better system
|
||||
if (
|
||||
applicationRegistration.sourceType === AppRegistrationSourceType.NPM ||
|
||||
applicationRegistration.sourceType === AppRegistrationSourceType.TARBALL
|
||||
) {
|
||||
await this.applicationInstallService.installApplication({
|
||||
appRegistrationId: applicationRegistration.id,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const installedApplication = await this.applicationRepository.findOne({
|
||||
where: {
|
||||
applicationRegistrationId: applicationRegistration.id,
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
if (installedApplication) {
|
||||
return installedApplication;
|
||||
}
|
||||
|
||||
this.logger.warn(
|
||||
`Install succeeded but application not found in workspace, falling back to bare creation`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.applicationService.create({
|
||||
universalIdentifier: applicationRegistration.universalIdentifier,
|
||||
name: applicationRegistration.name,
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
|
||||
import {
|
||||
ApplicationRegistrationException,
|
||||
ApplicationRegistrationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application-registration/application-registration.exception';
|
||||
import { extractTarballSecurely } from 'src/engine/core-modules/application/utils/extract-tarball-securely.util';
|
||||
import { resolvePackageContentDir } from 'src/engine/core-modules/application/utils/tarball-utils';
|
||||
import { readJsonFile } from 'src/engine/core-modules/application/utils/read-json-file.util';
|
||||
import { FileStorageDriverFactory } from 'src/engine/core-modules/file-storage/file-storage-driver.factory';
|
||||
|
||||
export const MAX_TARBALL_UPLOAD_SIZE_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
@Injectable()
|
||||
export class AppTarballUploadService {
|
||||
private readonly logger = new Logger(AppTarballUploadService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ApplicationRegistrationEntity)
|
||||
private readonly appRegistrationRepository: Repository<ApplicationRegistrationEntity>,
|
||||
private readonly fileStorageDriverFactory: FileStorageDriverFactory,
|
||||
) {}
|
||||
|
||||
async uploadTarball(params: {
|
||||
tarballBuffer: Buffer;
|
||||
universalIdentifier?: string;
|
||||
ownerWorkspaceId: string;
|
||||
}): Promise<ApplicationRegistrationEntity> {
|
||||
const tempDir = join(tmpdir(), 'twenty-tarball-upload', v4());
|
||||
|
||||
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?: {
|
||||
universalIdentifier?: string;
|
||||
displayName?: string;
|
||||
};
|
||||
}>(contentDir, 'manifest.json');
|
||||
|
||||
if (manifest === null) {
|
||||
throw new ApplicationRegistrationException(
|
||||
'manifest.json not found or invalid in tarball',
|
||||
ApplicationRegistrationExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
const universalIdentifier =
|
||||
params.universalIdentifier ?? manifest.application?.universalIdentifier;
|
||||
|
||||
if (!isDefined(universalIdentifier)) {
|
||||
throw new ApplicationRegistrationException(
|
||||
'universalIdentifier is required (in body or manifest)',
|
||||
ApplicationRegistrationExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
|
||||
let appRegistration = await this.appRegistrationRepository.findOne({
|
||||
where: {
|
||||
universalIdentifier,
|
||||
ownerWorkspaceId: params.ownerWorkspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
if (isDefined(appRegistration)) {
|
||||
if (
|
||||
appRegistration.sourceType !== AppRegistrationSourceType.LOCAL &&
|
||||
appRegistration.sourceType !== AppRegistrationSourceType.TARBALL
|
||||
) {
|
||||
throw new ApplicationRegistrationException(
|
||||
`This app is registered as ${appRegistration.sourceType}. Cannot upload tarball.`,
|
||||
ApplicationRegistrationExceptionCode.SOURCE_CHANNEL_MISMATCH,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
appRegistration = this.appRegistrationRepository.create({
|
||||
universalIdentifier,
|
||||
name: manifest.application?.displayName ?? 'Unknown App',
|
||||
sourceType: AppRegistrationSourceType.TARBALL,
|
||||
oAuthClientId: v4(),
|
||||
oAuthRedirectUris: [],
|
||||
oAuthScopes: [],
|
||||
ownerWorkspaceId: params.ownerWorkspaceId,
|
||||
});
|
||||
|
||||
appRegistration =
|
||||
await this.appRegistrationRepository.save(appRegistration);
|
||||
}
|
||||
|
||||
const storagePath = join('app-tarball', appRegistration.id);
|
||||
|
||||
const driver = this.fileStorageDriverFactory.getCurrentDriver();
|
||||
|
||||
await driver.writeFile({
|
||||
filePath: join(storagePath, 'app.tar.gz'),
|
||||
sourceFile: params.tarballBuffer,
|
||||
mimeType: 'application/gzip',
|
||||
});
|
||||
|
||||
await this.appRegistrationRepository.update(appRegistration.id, {
|
||||
sourceType: AppRegistrationSourceType.TARBALL,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Tarball uploaded for app ${universalIdentifier} (registration ${appRegistration.id})`,
|
||||
);
|
||||
|
||||
return this.appRegistrationRepository.findOneOrFail({
|
||||
where: { id: appRegistration.id },
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -3,13 +3,13 @@ import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { type Repository } from 'typeorm';
|
||||
|
||||
import { ApplicationVariableEntity } from 'src/engine/core-modules/applicationVariable/application-variable.entity';
|
||||
import { ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
|
||||
import {
|
||||
ApplicationVariableEntityException,
|
||||
ApplicationVariableEntityExceptionCode,
|
||||
} from 'src/engine/core-modules/applicationVariable/application-variable.exception';
|
||||
import { ApplicationVariableEntityService } from 'src/engine/core-modules/applicationVariable/application-variable.service';
|
||||
import { SECRET_APPLICATION_VARIABLE_MASK } from 'src/engine/core-modules/applicationVariable/constants/secret-application-variable-mask.constant';
|
||||
} from 'src/engine/core-modules/application/application-variable/application-variable.exception';
|
||||
import { ApplicationVariableEntityService } from 'src/engine/core-modules/application/application-variable/application-variable.service';
|
||||
import { SECRET_APPLICATION_VARIABLE_MASK } from 'src/engine/core-modules/application/application-variable/constants/secret-application-variable-mask.constant';
|
||||
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import { assertUnreachable } from 'twenty-shared/utils';
|
||||
import {
|
||||
ApplicationVariableEntityException,
|
||||
ApplicationVariableEntityExceptionCode,
|
||||
} from 'src/engine/core-modules/applicationVariable/application-variable.exception';
|
||||
} from 'src/engine/core-modules/application/application-variable/application-variable.exception';
|
||||
import { NotFoundError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
|
||||
@Catch(ApplicationVariableEntityException)
|
||||
+4
-4
@@ -3,10 +3,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
||||
|
||||
import { ApplicationVariableEntity } from 'src/engine/core-modules/applicationVariable/application-variable.entity';
|
||||
import { ApplicationVariableEntityResolver } from 'src/engine/core-modules/applicationVariable/application-variable.resolver';
|
||||
import { ApplicationVariableEntityService } from 'src/engine/core-modules/applicationVariable/application-variable.service';
|
||||
import { WorkspaceApplicationVariableMapCacheService } from 'src/engine/core-modules/applicationVariable/services/workspace-application-variable-map-cache.service';
|
||||
import { ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
|
||||
import { ApplicationVariableEntityResolver } from 'src/engine/core-modules/application/application-variable/application-variable.resolver';
|
||||
import { ApplicationVariableEntityService } from 'src/engine/core-modules/application/application-variable/application-variable.service';
|
||||
import { WorkspaceApplicationVariableMapCacheService } from 'src/engine/core-modules/application/application-variable/services/workspace-application-variable-map-cache.service';
|
||||
import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
|
||||
+5
-5
@@ -3,11 +3,11 @@ import { Args, Mutation, Parent, ResolveField } from '@nestjs/graphql';
|
||||
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
|
||||
import { ApplicationVariableEntityExceptionFilter } from 'src/engine/core-modules/applicationVariable/application-variable-exception-filter';
|
||||
import { ApplicationVariableEntity } from 'src/engine/core-modules/applicationVariable/application-variable.entity';
|
||||
import { ApplicationVariableEntityService } from 'src/engine/core-modules/applicationVariable/application-variable.service';
|
||||
import { ApplicationVariableEntityDTO } from 'src/engine/core-modules/applicationVariable/dtos/application-variable.dto';
|
||||
import { UpdateApplicationVariableEntityInput } from 'src/engine/core-modules/applicationVariable/dtos/update-application-variable.input';
|
||||
import { ApplicationVariableEntityExceptionFilter } from 'src/engine/core-modules/application/application-variable/application-variable-exception-filter';
|
||||
import { ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
|
||||
import { ApplicationVariableEntityService } from 'src/engine/core-modules/application/application-variable/application-variable.service';
|
||||
import { ApplicationVariableEntityDTO } from 'src/engine/core-modules/application/application-variable/dtos/application-variable.dto';
|
||||
import { UpdateApplicationVariableEntityInput } from 'src/engine/core-modules/application/application-variable/dtos/update-application-variable.input';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
+3
-3
@@ -5,12 +5,12 @@ import { ApplicationVariables } from 'twenty-shared/application';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { In, Not, Repository } from 'typeorm';
|
||||
|
||||
import { ApplicationVariableEntity } from 'src/engine/core-modules/applicationVariable/application-variable.entity';
|
||||
import { ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
|
||||
import {
|
||||
ApplicationVariableEntityException,
|
||||
ApplicationVariableEntityExceptionCode,
|
||||
} from 'src/engine/core-modules/applicationVariable/application-variable.exception';
|
||||
import { SECRET_APPLICATION_VARIABLE_MASK } from 'src/engine/core-modules/applicationVariable/constants/secret-application-variable-mask.constant';
|
||||
} from 'src/engine/core-modules/application/application-variable/application-variable.exception';
|
||||
import { SECRET_APPLICATION_VARIABLE_MASK } from 'src/engine/core-modules/application/application-variable/constants/secret-application-variable-mask.constant';
|
||||
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
|
||||
+3
-3
@@ -6,9 +6,9 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service';
|
||||
|
||||
import { ApplicationVariableEntity } from 'src/engine/core-modules/applicationVariable/application-variable.entity';
|
||||
import { type ApplicationVariableCacheMaps } from 'src/engine/core-modules/applicationVariable/types/application-variable-cache-maps.type';
|
||||
import { fromApplicationVariableEntityToFlatApplicationVariable } from 'src/engine/core-modules/applicationVariable/utils/from-application-variable-entity-to-flat-application-variable.util';
|
||||
import { ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
|
||||
import { type ApplicationVariableCacheMaps } from 'src/engine/core-modules/application/application-variable/types/application-variable-cache-maps.type';
|
||||
import { fromApplicationVariableEntityToFlatApplicationVariable } from 'src/engine/core-modules/application/application-variable/utils/from-application-variable-entity-to-flat-application-variable.util';
|
||||
import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator';
|
||||
|
||||
@Injectable()
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { type FlatApplicationVariable } from 'src/engine/core-modules/applicationVariable/types/flat-application-variable.type';
|
||||
import { type FlatApplicationVariable } from 'src/engine/core-modules/application/application-variable/types/flat-application-variable.type';
|
||||
|
||||
export type ApplicationVariableCacheMaps = {
|
||||
byId: Partial<Record<string, FlatApplicationVariable>>;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { type ApplicationVariableEntity } from 'src/engine/core-modules/applicationVariable/application-variable.entity';
|
||||
import { type ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
|
||||
import { type FlatEntityFrom } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-from.type';
|
||||
|
||||
export type FlatApplicationVariable = FlatEntityFrom<ApplicationVariableEntity>;
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { type ApplicationVariableEntity } from 'src/engine/core-modules/applicationVariable/application-variable.entity';
|
||||
import { type FlatApplicationVariable } from 'src/engine/core-modules/applicationVariable/types/flat-application-variable.type';
|
||||
import { type ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
|
||||
import { type FlatApplicationVariable } from 'src/engine/core-modules/application/application-variable/types/flat-application-variable.type';
|
||||
|
||||
export const fromApplicationVariableEntityToFlatApplicationVariable = (
|
||||
entity: ApplicationVariableEntity,
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ApplicationInstallModule } from 'src/engine/core-modules/application/application-install/application-install.module';
|
||||
import { AppVersionCheckCronCommand } from 'src/engine/core-modules/application/application-version-check/crons/commands/app-version-check.cron.command';
|
||||
import { AppVersionCheckCronJob } from 'src/engine/core-modules/application/application-version-check/crons/app-version-check.cron.job';
|
||||
|
||||
@Module({
|
||||
imports: [ApplicationInstallModule],
|
||||
providers: [AppVersionCheckCronJob, AppVersionCheckCronCommand],
|
||||
exports: [AppVersionCheckCronCommand],
|
||||
})
|
||||
export class AppVersionCheckModule {}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { SentryCronMonitor } from 'src/engine/core-modules/cron/sentry-cron-monitor.decorator';
|
||||
import { APP_VERSION_CHECK_CRON_PATTERN } from 'src/engine/core-modules/application/application-version-check/crons/constants/app-version-check-cron-pattern.constant';
|
||||
import { AppUpgradeService } from 'src/engine/core-modules/application/application-install/app-upgrade.service';
|
||||
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
|
||||
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
|
||||
@Injectable()
|
||||
@Processor(MessageQueue.cronQueue)
|
||||
export class AppVersionCheckCronJob {
|
||||
private readonly logger = new Logger(AppVersionCheckCronJob.name);
|
||||
|
||||
constructor(private readonly appUpgradeService: AppUpgradeService) {}
|
||||
|
||||
@Process(AppVersionCheckCronJob.name)
|
||||
@SentryCronMonitor(
|
||||
AppVersionCheckCronJob.name,
|
||||
APP_VERSION_CHECK_CRON_PATTERN,
|
||||
)
|
||||
async handle(): Promise<void> {
|
||||
this.logger.log('Starting app version check...');
|
||||
|
||||
try {
|
||||
await this.appUpgradeService.checkAllForUpdates();
|
||||
this.logger.log('App version check completed successfully');
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`App version check failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
|
||||
import { APP_VERSION_CHECK_CRON_PATTERN } from 'src/engine/core-modules/application/application-version-check/crons/constants/app-version-check-cron-pattern.constant';
|
||||
import { AppVersionCheckCronJob } from 'src/engine/core-modules/application/application-version-check/crons/app-version-check.cron.job';
|
||||
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
|
||||
|
||||
@Command({
|
||||
name: 'cron:app-version-check',
|
||||
description:
|
||||
'Starts a cron job to check for app version updates on npm registries',
|
||||
})
|
||||
export class AppVersionCheckCronCommand extends CommandRunner {
|
||||
constructor(
|
||||
@InjectMessageQueue(MessageQueue.cronQueue)
|
||||
private readonly messageQueueService: MessageQueueService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
await this.messageQueueService.addCron<undefined>({
|
||||
jobName: AppVersionCheckCronJob.name,
|
||||
data: undefined,
|
||||
options: {
|
||||
repeat: {
|
||||
pattern: APP_VERSION_CHECK_CRON_PATTERN,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
// Every 6 hours
|
||||
export const APP_VERSION_CHECK_CRON_PATTERN = '0 */6 * * *';
|
||||
@@ -15,9 +15,10 @@ import {
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application-registration/application-registration.entity';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
import { ApplicationVariableEntity } from 'src/engine/core-modules/applicationVariable/application-variable.entity';
|
||||
import { ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
|
||||
import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
@@ -52,8 +53,8 @@ export class ApplicationEntity extends WorkspaceRelatedEntity {
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
version: string | null;
|
||||
|
||||
@Column({ type: 'text', default: 'local' })
|
||||
sourceType: 'local';
|
||||
@Column({ type: 'text', default: AppRegistrationSourceType.LOCAL })
|
||||
sourceType: AppRegistrationSourceType;
|
||||
|
||||
@Column({ nullable: false, type: 'text' })
|
||||
sourcePath: string;
|
||||
|
||||
@@ -13,6 +13,10 @@ export enum ApplicationExceptionCode {
|
||||
APPLICATION_NOT_FOUND = 'APPLICATION_NOT_FOUND',
|
||||
FORBIDDEN = 'FORBIDDEN',
|
||||
INVALID_INPUT = 'INVALID_INPUT',
|
||||
SOURCE_CHANNEL_MISMATCH = 'SOURCE_CHANNEL_MISMATCH',
|
||||
PACKAGE_RESOLUTION_FAILED = 'PACKAGE_RESOLUTION_FAILED',
|
||||
TARBALL_EXTRACTION_FAILED = 'TARBALL_EXTRACTION_FAILED',
|
||||
UPGRADE_FAILED = 'UPGRADE_FAILED',
|
||||
}
|
||||
|
||||
const getApplicationExceptionUserFriendlyMessage = (
|
||||
@@ -35,6 +39,14 @@ const getApplicationExceptionUserFriendlyMessage = (
|
||||
return msg`You do not have permission to perform this action.`;
|
||||
case ApplicationExceptionCode.INVALID_INPUT:
|
||||
return msg`Invalid input provided.`;
|
||||
case ApplicationExceptionCode.SOURCE_CHANNEL_MISMATCH:
|
||||
return msg`Source channel mismatch.`;
|
||||
case ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED:
|
||||
return msg`Unable to retrieve the application package.`;
|
||||
case ApplicationExceptionCode.TARBALL_EXTRACTION_FAILED:
|
||||
return msg`Failed to extract tarball.`;
|
||||
case ApplicationExceptionCode.UPGRADE_FAILED:
|
||||
return msg`Application upgrade failed.`;
|
||||
default:
|
||||
assertUnreachable(code);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
|
||||
import { MarketplaceService } from 'src/engine/core-modules/application/services/marketplace.service';
|
||||
import { WorkspaceFlatApplicationMapCacheService } from 'src/engine/core-modules/application/services/workspace-flat-application-map-cache.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { WorkspaceFlatApplicationMapCacheService } from 'src/engine/core-modules/application/workspace-flat-application-map-cache.service';
|
||||
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
|
||||
@@ -15,16 +15,9 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
|
||||
TypeOrmModule.forFeature([ApplicationEntity, AgentEntity, WorkspaceEntity]),
|
||||
WorkspaceManyOrAllFlatEntityMapsCacheModule,
|
||||
WorkspaceCacheModule,
|
||||
TwentyConfigModule,
|
||||
],
|
||||
exports: [
|
||||
ApplicationService,
|
||||
WorkspaceFlatApplicationMapCacheService,
|
||||
MarketplaceService,
|
||||
],
|
||||
providers: [
|
||||
ApplicationService,
|
||||
WorkspaceFlatApplicationMapCacheService,
|
||||
MarketplaceService,
|
||||
],
|
||||
exports: [ApplicationService, WorkspaceFlatApplicationMapCacheService],
|
||||
providers: [ApplicationService, WorkspaceFlatApplicationMapCacheService],
|
||||
})
|
||||
export class ApplicationModule {}
|
||||
|
||||
+33
-16
@@ -126,6 +126,7 @@ export class ApplicationService {
|
||||
'applicationVariables',
|
||||
'packageJsonFile',
|
||||
'yarnLockFile',
|
||||
'applicationRegistration',
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -160,6 +161,7 @@ export class ApplicationService {
|
||||
'applicationVariables',
|
||||
'packageJsonFile',
|
||||
'yarnLockFile',
|
||||
'applicationRegistration',
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -236,10 +238,21 @@ export class ApplicationService {
|
||||
workspace,
|
||||
});
|
||||
|
||||
return {
|
||||
application: twentyStandardFlatApplication as ApplicationEntity,
|
||||
workspace,
|
||||
};
|
||||
const application = await this.applicationRepository.findOne({
|
||||
where: {
|
||||
id: twentyStandardFlatApplication.id,
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isDefined(application)) {
|
||||
throw new ApplicationException(
|
||||
`Twenty standard application not found for workspace ${workspace.id}`,
|
||||
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return { application, workspace };
|
||||
}
|
||||
|
||||
async createTwentyStandardApplication(
|
||||
@@ -395,10 +408,7 @@ export class ApplicationService {
|
||||
data: Partial<ApplicationEntity> & { workspaceId: string },
|
||||
queryRunner?: QueryRunner,
|
||||
): Promise<ApplicationEntity> {
|
||||
const application = this.applicationRepository.create({
|
||||
...data,
|
||||
sourceType: 'local',
|
||||
});
|
||||
const application = this.applicationRepository.create(data);
|
||||
|
||||
if (queryRunner) {
|
||||
return queryRunner.manager.save(ApplicationEntity, application);
|
||||
@@ -415,19 +425,23 @@ export class ApplicationService {
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
data: Parameters<typeof this.applicationRepository.update>[1],
|
||||
data: Parameters<typeof this.applicationRepository.update>[1] & {
|
||||
workspaceId: string;
|
||||
},
|
||||
): Promise<ApplicationEntity> {
|
||||
await this.applicationRepository.update({ id }, data);
|
||||
|
||||
await this.workspaceCacheService.invalidateAndRecompute(
|
||||
data.workspaceId as string,
|
||||
['flatApplicationMaps'],
|
||||
);
|
||||
await this.workspaceCacheService.invalidateAndRecompute(data.workspaceId, [
|
||||
'flatApplicationMaps',
|
||||
]);
|
||||
|
||||
const updatedApplication = await this.findById(id);
|
||||
|
||||
if (!updatedApplication) {
|
||||
throw new Error(`Failed to update application with id ${id}`);
|
||||
if (!isDefined(updatedApplication)) {
|
||||
throw new ApplicationException(
|
||||
`Application with id ${id} not found after update`,
|
||||
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return updatedApplication;
|
||||
@@ -440,7 +454,10 @@ export class ApplicationService {
|
||||
});
|
||||
|
||||
if (!isDefined(application)) {
|
||||
throw new Error(`Application does not exist`);
|
||||
throw new ApplicationException(
|
||||
`Application with universalIdentifier ${universalIdentifier} not found`,
|
||||
ApplicationExceptionCode.APPLICATION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
await this.fileStorageService.deleteApplicationFiles({
|
||||
+2
@@ -1,5 +1,7 @@
|
||||
enableInlineHunks: true
|
||||
|
||||
enableScripts: false
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.9.2.cjs
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { AppRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/app-registration-source-type.enum';
|
||||
|
||||
@ObjectType('ApplicationRegistrationSummary')
|
||||
export class ApplicationRegistrationSummaryDTO {
|
||||
@Field(() => UUIDScalarType)
|
||||
id: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Field({ nullable: true })
|
||||
latestAvailableVersion?: string;
|
||||
|
||||
@Field(() => AppRegistrationSourceType)
|
||||
sourceType: AppRegistrationSourceType;
|
||||
}
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
import GraphQLJSON from 'graphql-type-json';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { ApplicationVariableEntityDTO } from 'src/engine/core-modules/applicationVariable/dtos/application-variable.dto';
|
||||
import { ApplicationRegistrationSummaryDTO } from 'src/engine/core-modules/application/dtos/application-registration-summary.dto';
|
||||
import { ApplicationVariableEntityDTO } from 'src/engine/core-modules/application/application-variable/dtos/application-variable.dto';
|
||||
import { AgentDTO } from 'src/engine/metadata-modules/ai/ai-agent/dtos/agent.dto';
|
||||
import { LogicFunctionDTO } from 'src/engine/metadata-modules/logic-function/dtos/logic-function.dto';
|
||||
import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
|
||||
@@ -29,12 +30,12 @@ export class ApplicationDTO {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Field()
|
||||
@Field({ nullable: true })
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Field()
|
||||
@Field({ nullable: true })
|
||||
version?: string;
|
||||
|
||||
@IsString()
|
||||
@@ -64,6 +65,11 @@ export class ApplicationDTO {
|
||||
@Field(() => GraphQLJSON)
|
||||
availablePackages: Record<string, string>;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
@Field(() => UUIDScalarType, { nullable: true })
|
||||
applicationRegistrationId?: string;
|
||||
|
||||
@Field(() => Boolean)
|
||||
@IsBoolean()
|
||||
canBeUninstalled: boolean;
|
||||
@@ -93,4 +99,8 @@ export class ApplicationDTO {
|
||||
|
||||
@Field(() => [ApplicationVariableEntityDTO])
|
||||
applicationVariables?: ApplicationVariableEntityDTO[];
|
||||
|
||||
@IsOptional()
|
||||
@Field(() => ApplicationRegistrationSummaryDTO, { nullable: true })
|
||||
applicationRegistration?: ApplicationRegistrationSummaryDTO;
|
||||
}
|
||||
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { Mutation, Query } from '@nestjs/graphql';
|
||||
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
|
||||
import { MarketplaceAppDTO } from 'src/engine/core-modules/application/dtos/marketplace-app.dto';
|
||||
import { MarketplaceService } from 'src/engine/core-modules/application/services/marketplace.service';
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
|
||||
@MetadataResolver()
|
||||
@UseGuards(UserAuthGuard, WorkspaceAuthGuard, NoPermissionGuard)
|
||||
export class MarketplaceResolver {
|
||||
constructor(private readonly marketplaceService: MarketplaceService) {}
|
||||
|
||||
@Query(() => [MarketplaceAppDTO])
|
||||
async findManyMarketplaceApps(): Promise<MarketplaceAppDTO[]> {
|
||||
return this.marketplaceService.findAllMarketplaceApps();
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.MARKETPLACE_APPS))
|
||||
async installMarketplaceApp(): Promise<boolean> {
|
||||
// TODO
|
||||
return true;
|
||||
}
|
||||
}
|
||||
-347
@@ -1,347 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { lowerCase, upperFirst } from 'lodash';
|
||||
import { type Manifest } from 'twenty-shared/application';
|
||||
import { PackageJson } from 'type-fest';
|
||||
|
||||
import {
|
||||
MarketplaceAppDTO,
|
||||
MarketplaceAppDefaultRoleDTO,
|
||||
MarketplaceAppFieldDTO,
|
||||
MarketplaceAppFrontComponentDTO,
|
||||
MarketplaceAppLogicFunctionDTO,
|
||||
MarketplaceAppObjectDTO,
|
||||
} from 'src/engine/core-modules/application/dtos/marketplace-app.dto';
|
||||
import { MOCKED_MARKETPLACE_APP } from 'src/engine/core-modules/application/services/mocked-marketplace-app.constant';
|
||||
|
||||
type GitHubContent = {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'dir';
|
||||
download_url?: string;
|
||||
};
|
||||
|
||||
const GITHUB_RAW_BASE_URL =
|
||||
'https://raw.githubusercontent.com/twentyhq/twenty/main/packages/twenty-apps';
|
||||
const GITHUB_API_BASE_URL =
|
||||
'https://api.github.com/repos/twentyhq/twenty/contents/packages/twenty-apps';
|
||||
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class MarketplaceService {
|
||||
private readonly logger = new Logger(MarketplaceService.name);
|
||||
|
||||
private cachedApps: MarketplaceAppDTO[] | null = null;
|
||||
private cacheTimestamp: number | null = null;
|
||||
|
||||
async findAllMarketplaceApps(): Promise<MarketplaceAppDTO[]> {
|
||||
if (this.isCacheValid()) {
|
||||
return this.cachedApps as MarketplaceAppDTO[];
|
||||
}
|
||||
|
||||
const apps = await this.fetchAllMarketplaceApps();
|
||||
|
||||
this.cachedApps = apps;
|
||||
this.cacheTimestamp = Date.now();
|
||||
|
||||
return apps;
|
||||
}
|
||||
|
||||
private isCacheValid(): boolean {
|
||||
if (this.cachedApps === null || this.cacheTimestamp === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Date.now() - this.cacheTimestamp < CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
private async fetchAllMarketplaceApps(): Promise<MarketplaceAppDTO[]> {
|
||||
const apps: MarketplaceAppDTO[] = [];
|
||||
|
||||
apps.push(MOCKED_MARKETPLACE_APP); // To remove once we have apps on marketplace
|
||||
|
||||
try {
|
||||
const appDirs = await this.getAppDirectoriesFromGitHub();
|
||||
|
||||
for (const appDir of appDirs) {
|
||||
try {
|
||||
const manifest = await this.loadAppManifestFromGithub(appDir);
|
||||
|
||||
if (manifest) {
|
||||
apps.push(manifest);
|
||||
} else {
|
||||
// to remove after manifest are committed to the repo
|
||||
const appName = appDir.replace(/^community\//, '');
|
||||
const appLabel = upperFirst(lowerCase(appName));
|
||||
|
||||
apps.push({
|
||||
id: appName,
|
||||
name: appLabel,
|
||||
description: '',
|
||||
icon: '',
|
||||
version: '',
|
||||
author: 'Anonymous',
|
||||
category: '',
|
||||
logo: undefined,
|
||||
screenshots: [],
|
||||
aboutDescription: '',
|
||||
providers: [],
|
||||
websiteUrl: '',
|
||||
termsUrl: '',
|
||||
objects: [],
|
||||
fields: [],
|
||||
logicFunctions: [],
|
||||
frontComponents: [],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to load manifest from ${appDir}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to fetch marketplace apps from GitHub: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return apps;
|
||||
}
|
||||
|
||||
private async getAppDirectoriesFromGitHub(): Promise<string[]> {
|
||||
const directories: string[] = [];
|
||||
|
||||
const rootContents = await this.fetchGitHubDirectory('');
|
||||
|
||||
for (const entry of rootContents) {
|
||||
if (entry.name === 'community') {
|
||||
const communityContents = await this.fetchGitHubDirectory('community');
|
||||
|
||||
for (const communityEntry of communityContents) {
|
||||
if (communityEntry.type !== 'dir') continue;
|
||||
if (communityEntry.name.startsWith('.')) continue;
|
||||
|
||||
directories.push(`community/${communityEntry.name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return directories;
|
||||
}
|
||||
|
||||
private async fetchGitHubDirectory(path: string): Promise<GitHubContent[]> {
|
||||
const url = path ? `${GITHUB_API_BASE_URL}/${path}` : GITHUB_API_BASE_URL;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'Twenty-Marketplace',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API error: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
private async fetchGitHubFile(path: string): Promise<string | null> {
|
||||
const url = `${GITHUB_RAW_BASE_URL}/${path}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Twenty-Marketplace',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
return null;
|
||||
}
|
||||
throw new Error(`GitHub raw file error: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.text();
|
||||
}
|
||||
|
||||
private async loadAppManifestFromGithub(
|
||||
appPath: string,
|
||||
): Promise<MarketplaceAppDTO | null> {
|
||||
const manifestContent = await this.fetchGitHubFile(
|
||||
`${appPath}/.twenty/output/manifest.json`,
|
||||
);
|
||||
|
||||
if (!manifestContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const packageJsonContent = await this.fetchGitHubFile(
|
||||
`${appPath}/.twenty/output/package.json`,
|
||||
);
|
||||
|
||||
if (!packageJsonContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(manifestContent) as Manifest;
|
||||
const packageJson = JSON.parse(packageJsonContent) as PackageJson;
|
||||
|
||||
const { application } = manifest;
|
||||
|
||||
if (!application.author || !application.category) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const objects: MarketplaceAppObjectDTO[] = (manifest.objects ?? []).map(
|
||||
(manifestObject) => ({
|
||||
universalIdentifier: manifestObject.universalIdentifier,
|
||||
nameSingular: manifestObject.nameSingular,
|
||||
namePlural: manifestObject.namePlural,
|
||||
labelSingular: manifestObject.labelSingular,
|
||||
labelPlural: manifestObject.labelPlural,
|
||||
description: manifestObject.description,
|
||||
icon: manifestObject.icon,
|
||||
fields: (manifestObject.fields ?? []).map((field) => ({
|
||||
name: field.name ?? '',
|
||||
type: field.type,
|
||||
label: field.label ?? '',
|
||||
description: field.description,
|
||||
icon: field.icon,
|
||||
universalIdentifier: field.universalIdentifier,
|
||||
objectUniversalIdentifier: manifestObject.universalIdentifier,
|
||||
})),
|
||||
}),
|
||||
);
|
||||
|
||||
const fields: MarketplaceAppFieldDTO[] = (manifest.fields ?? []).map(
|
||||
(manifestField) => {
|
||||
return {
|
||||
name: manifestField.name,
|
||||
type: manifestField.type,
|
||||
label: manifestField.label,
|
||||
description: manifestField.description,
|
||||
icon: manifestField.icon,
|
||||
objectUniversalIdentifier: manifestField.objectUniversalIdentifier,
|
||||
universalIdentifier: manifestField.universalIdentifier,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const logicFunctions: MarketplaceAppLogicFunctionDTO[] = (
|
||||
manifest.logicFunctions ?? []
|
||||
).map((manifestLogicFunction) => ({
|
||||
name: manifestLogicFunction.name ?? '',
|
||||
description: manifestLogicFunction.description,
|
||||
timeoutSeconds: manifestLogicFunction.timeoutSeconds,
|
||||
}));
|
||||
|
||||
const frontComponents: MarketplaceAppFrontComponentDTO[] = (
|
||||
manifest.frontComponents ?? []
|
||||
).map((manifestFrontComponent) => ({
|
||||
name: manifestFrontComponent.name ?? '',
|
||||
description: manifestFrontComponent.description,
|
||||
}));
|
||||
|
||||
const defaultRole = this.resolveDefaultRole(
|
||||
manifest,
|
||||
application.defaultRoleUniversalIdentifier,
|
||||
);
|
||||
|
||||
return {
|
||||
id: application.universalIdentifier,
|
||||
name: application.displayName,
|
||||
description: application.description ?? '',
|
||||
icon: application.icon ?? 'IconApps',
|
||||
version: packageJson.version ?? '0.1.0',
|
||||
author: application.author,
|
||||
category: application.category,
|
||||
logo: this.resolveAssetUrl(appPath, application.logoUrl),
|
||||
screenshots: this.resolveAssetUrls(appPath, application.screenshots),
|
||||
aboutDescription: application.aboutDescription ?? '',
|
||||
providers: application.providers ?? [],
|
||||
websiteUrl: application.websiteUrl,
|
||||
termsUrl: application.termsUrl,
|
||||
objects,
|
||||
fields,
|
||||
logicFunctions,
|
||||
frontComponents,
|
||||
defaultRole,
|
||||
};
|
||||
}
|
||||
|
||||
private resolveDefaultRole(
|
||||
manifest: Manifest,
|
||||
defaultRoleUniversalIdentifier: string,
|
||||
): MarketplaceAppDefaultRoleDTO | undefined {
|
||||
const roleManifest = manifest.roles?.find(
|
||||
(role) => role.universalIdentifier === defaultRoleUniversalIdentifier,
|
||||
);
|
||||
|
||||
if (!roleManifest) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
id: roleManifest.universalIdentifier,
|
||||
label: roleManifest.label,
|
||||
description: roleManifest.description,
|
||||
canReadAllObjectRecords: roleManifest.canReadAllObjectRecords ?? false,
|
||||
canUpdateAllObjectRecords:
|
||||
roleManifest.canUpdateAllObjectRecords ?? false,
|
||||
canSoftDeleteAllObjectRecords:
|
||||
roleManifest.canSoftDeleteAllObjectRecords ?? false,
|
||||
canDestroyAllObjectRecords:
|
||||
roleManifest.canDestroyAllObjectRecords ?? false,
|
||||
canUpdateAllSettings: roleManifest.canUpdateAllSettings ?? false,
|
||||
canAccessAllTools: roleManifest.canAccessAllTools ?? false,
|
||||
objectPermissions: (roleManifest.objectPermissions ?? []).map(
|
||||
(permission) => ({
|
||||
objectUniversalIdentifier: permission.objectUniversalIdentifier,
|
||||
canReadObjectRecords: permission.canReadObjectRecords,
|
||||
canUpdateObjectRecords: permission.canUpdateObjectRecords,
|
||||
canSoftDeleteObjectRecords: permission.canSoftDeleteObjectRecords,
|
||||
canDestroyObjectRecords: permission.canDestroyObjectRecords,
|
||||
}),
|
||||
),
|
||||
fieldPermissions: (roleManifest.fieldPermissions ?? []).map(
|
||||
(permission) => ({
|
||||
objectUniversalIdentifier: permission.objectUniversalIdentifier,
|
||||
fieldUniversalIdentifier: permission.fieldUniversalIdentifier,
|
||||
canReadFieldValue: permission.canReadFieldValue,
|
||||
canUpdateFieldValue: permission.canUpdateFieldValue,
|
||||
}),
|
||||
),
|
||||
permissionFlags: (roleManifest.permissionFlags ?? []).map((flag) =>
|
||||
flag.toString(),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private resolveAssetUrl(
|
||||
appPath: string,
|
||||
relativePath: string | undefined,
|
||||
): string | undefined {
|
||||
if (!relativePath) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `${GITHUB_RAW_BASE_URL}/${appPath}/${relativePath}`;
|
||||
}
|
||||
|
||||
private resolveAssetUrls(
|
||||
appPath: string,
|
||||
relativePaths: string[] | undefined,
|
||||
): string[] {
|
||||
if (!relativePaths) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return relativePaths
|
||||
.map((path) => this.resolveAssetUrl(appPath, path))
|
||||
.filter((url): url is string => url !== undefined);
|
||||
}
|
||||
}
|
||||
-204
@@ -1,204 +0,0 @@
|
||||
import { type MarketplaceAppDTO } from 'src/engine/core-modules/application/dtos/marketplace-app.dto';
|
||||
|
||||
// Readable mock identifiers for the Data Enrichment app
|
||||
const MOCK_APP_ID = 'a1b2c3d4-0000-0000-0000-000000000001';
|
||||
const MOCK_ROLE_ID = 'a1b2c3d4-0000-0000-0000-000000000010';
|
||||
const MOCK_ENRICHMENT_JOB_UNIVERSAL_ID = 'a1b2c3d4-0000-0000-0000-000000000100';
|
||||
|
||||
// Standard object universalIdentifiers from STANDARD_OBJECTS
|
||||
const COMPANY_UNIVERSAL_ID = '20202020-b374-4779-a561-80086cb2e17f';
|
||||
const PERSON_UNIVERSAL_ID = '20202020-e674-48e5-a542-72570eee7213';
|
||||
|
||||
// Standard field universalIdentifiers
|
||||
const COMPANY_NAME_FIELD_UNIVERSAL_ID = '20202020-4d99-4e2e-a84c-4a27837b1ece';
|
||||
|
||||
// Enrichment job field universalIdentifiers
|
||||
const MOCK_ENRICHMENT_JOB_STATUS_FIELD_UNIVERSAL_ID =
|
||||
'a1b2c3d4-0000-0000-0000-000000000101';
|
||||
const MOCK_ENRICHMENT_JOB_PROVIDER_FIELD_UNIVERSAL_ID =
|
||||
'a1b2c3d4-0000-0000-0000-000000000102';
|
||||
const MOCK_ENRICHMENT_JOB_ENRICHED_AT_FIELD_UNIVERSAL_ID =
|
||||
'a1b2c3d4-0000-0000-0000-000000000103';
|
||||
const MOCK_ENRICHMENT_JOB_RECORD_ID_FIELD_UNIVERSAL_ID =
|
||||
'a1b2c3d4-0000-0000-0000-000000000104';
|
||||
|
||||
// App-provided field universalIdentifiers on standard objects
|
||||
const COMPANY_INDUSTRY_FIELD_UNIVERSAL_ID =
|
||||
'a1b2c3d4-0000-0000-0000-000000000201';
|
||||
const COMPANY_EMPLOYEE_COUNT_FIELD_UNIVERSAL_ID =
|
||||
'a1b2c3d4-0000-0000-0000-000000000202';
|
||||
const PERSON_LINKEDIN_URL_FIELD_UNIVERSAL_ID =
|
||||
'a1b2c3d4-0000-0000-0000-000000000203';
|
||||
const PERSON_JOB_TITLE_FIELD_UNIVERSAL_ID =
|
||||
'a1b2c3d4-0000-0000-0000-000000000204';
|
||||
|
||||
const MOCK_LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="#1a2744"><ellipse cx="38" cy="20" rx="28" ry="10"/><rect x="10" y="20" width="56" height="50"/><ellipse cx="38" cy="70" rx="28" ry="10"/><ellipse cx="38" cy="35" rx="28" ry="10" fill="none" stroke="#fff" stroke-width="3"/><ellipse cx="38" cy="52" rx="28" ry="10" fill="none" stroke="#fff" stroke-width="3"/><circle cx="72" cy="62" r="22" fill="#1a2744"/><circle cx="72" cy="62" r="18" fill="#fff"/><path d="M72 50 L72 74 M62 58 L72 48 L82 58" stroke="#1a2744" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>`;
|
||||
|
||||
export const MOCKED_MARKETPLACE_APP: MarketplaceAppDTO = {
|
||||
id: MOCK_APP_ID,
|
||||
name: 'Data Enrichment',
|
||||
description: 'Enrich your data easily. Choose your provider.',
|
||||
icon: 'IconSparkles',
|
||||
version: '1.0.0',
|
||||
author: 'Cosmos Labs',
|
||||
category: 'Data',
|
||||
logo: `data:image/svg+xml,${encodeURIComponent(MOCK_LOGO_SVG)}`,
|
||||
screenshots: [
|
||||
'https://placehold.co/800x400/f5f5f5/666?text=Screenshot+1',
|
||||
'https://placehold.co/800x400/f5f5f5/666?text=Screenshot+2',
|
||||
'https://placehold.co/800x400/f5f5f5/666?text=Screenshot+3',
|
||||
],
|
||||
aboutDescription:
|
||||
'Enhance your workspace with automated data intelligence. This app monitors your new records and automatically populates missing details such as job titles, company size, social profiles, and industry insights.',
|
||||
providers: ['Clearbit', 'Apollo', 'Hunter.io'],
|
||||
websiteUrl: 'https://google.com',
|
||||
termsUrl: 'https://google.com',
|
||||
objects: [
|
||||
{
|
||||
universalIdentifier: MOCK_ENRICHMENT_JOB_UNIVERSAL_ID,
|
||||
nameSingular: 'enrichmentJob',
|
||||
namePlural: 'enrichmentJobs',
|
||||
labelSingular: 'Enrichment Job',
|
||||
labelPlural: 'Enrichment Jobs',
|
||||
description: 'Tracks data enrichment requests and their status',
|
||||
icon: 'IconSparkles',
|
||||
fields: [
|
||||
{
|
||||
name: 'status',
|
||||
type: 'SELECT',
|
||||
label: 'Status',
|
||||
description: 'Current status of the enrichment job',
|
||||
icon: 'IconProgressCheck',
|
||||
universalIdentifier: MOCK_ENRICHMENT_JOB_STATUS_FIELD_UNIVERSAL_ID,
|
||||
objectUniversalIdentifier: MOCK_ENRICHMENT_JOB_UNIVERSAL_ID,
|
||||
},
|
||||
{
|
||||
name: 'provider',
|
||||
type: 'TEXT',
|
||||
label: 'Provider',
|
||||
description: 'Enrichment provider used',
|
||||
icon: 'IconCloud',
|
||||
universalIdentifier: MOCK_ENRICHMENT_JOB_PROVIDER_FIELD_UNIVERSAL_ID,
|
||||
objectUniversalIdentifier: MOCK_ENRICHMENT_JOB_UNIVERSAL_ID,
|
||||
},
|
||||
{
|
||||
name: 'enrichedAt',
|
||||
type: 'DATE_TIME',
|
||||
label: 'Enriched At',
|
||||
description: 'When the enrichment was completed',
|
||||
icon: 'IconCalendar',
|
||||
universalIdentifier:
|
||||
MOCK_ENRICHMENT_JOB_ENRICHED_AT_FIELD_UNIVERSAL_ID,
|
||||
objectUniversalIdentifier: MOCK_ENRICHMENT_JOB_UNIVERSAL_ID,
|
||||
},
|
||||
{
|
||||
name: 'recordId',
|
||||
type: 'TEXT',
|
||||
label: 'Record ID',
|
||||
description: 'ID of the enriched record',
|
||||
icon: 'IconKey',
|
||||
universalIdentifier: MOCK_ENRICHMENT_JOB_RECORD_ID_FIELD_UNIVERSAL_ID,
|
||||
objectUniversalIdentifier: MOCK_ENRICHMENT_JOB_UNIVERSAL_ID,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
name: 'industry',
|
||||
type: 'TEXT',
|
||||
label: 'Industry',
|
||||
description: 'Company industry from enrichment',
|
||||
icon: 'IconBuildingFactory2',
|
||||
objectUniversalIdentifier: COMPANY_UNIVERSAL_ID,
|
||||
universalIdentifier: COMPANY_INDUSTRY_FIELD_UNIVERSAL_ID,
|
||||
},
|
||||
{
|
||||
name: 'employeeCount',
|
||||
type: 'NUMBER',
|
||||
label: 'Employee Count',
|
||||
description: 'Number of employees from enrichment',
|
||||
icon: 'IconUsers',
|
||||
objectUniversalIdentifier: COMPANY_UNIVERSAL_ID,
|
||||
universalIdentifier: COMPANY_EMPLOYEE_COUNT_FIELD_UNIVERSAL_ID,
|
||||
},
|
||||
{
|
||||
name: 'linkedInUrl',
|
||||
type: 'LINKS',
|
||||
label: 'LinkedIn URL',
|
||||
description: 'LinkedIn profile URL from enrichment',
|
||||
icon: 'IconBrandLinkedin',
|
||||
objectUniversalIdentifier: PERSON_UNIVERSAL_ID,
|
||||
universalIdentifier: PERSON_LINKEDIN_URL_FIELD_UNIVERSAL_ID,
|
||||
},
|
||||
{
|
||||
name: 'jobTitle',
|
||||
type: 'TEXT',
|
||||
label: 'Job Title',
|
||||
description: 'Job title from enrichment',
|
||||
icon: 'IconBriefcase',
|
||||
objectUniversalIdentifier: PERSON_UNIVERSAL_ID,
|
||||
universalIdentifier: PERSON_JOB_TITLE_FIELD_UNIVERSAL_ID,
|
||||
},
|
||||
],
|
||||
logicFunctions: [
|
||||
{
|
||||
name: 'enrich-on-create',
|
||||
description: 'Automatically enriches new records when they are created',
|
||||
timeoutSeconds: 30,
|
||||
},
|
||||
],
|
||||
frontComponents: [],
|
||||
defaultRole: {
|
||||
id: MOCK_ROLE_ID,
|
||||
label: 'Data Enrichment default role',
|
||||
description: 'Default permissions for the Data Enrichment app',
|
||||
canReadAllObjectRecords: true,
|
||||
canUpdateAllObjectRecords: true,
|
||||
canSoftDeleteAllObjectRecords: true,
|
||||
canDestroyAllObjectRecords: true,
|
||||
canUpdateAllSettings: false,
|
||||
canAccessAllTools: false,
|
||||
objectPermissions: [
|
||||
{
|
||||
// Company: revoke soft-delete
|
||||
objectUniversalIdentifier: COMPANY_UNIVERSAL_ID,
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
canDestroyObjectRecords: false,
|
||||
},
|
||||
{
|
||||
// Person: no overrides (matches defaults)
|
||||
objectUniversalIdentifier: PERSON_UNIVERSAL_ID,
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: true,
|
||||
canDestroyObjectRecords: false,
|
||||
},
|
||||
{
|
||||
// Enrichment Job (app-custom object): revoke soft-delete
|
||||
objectUniversalIdentifier: MOCK_ENRICHMENT_JOB_UNIVERSAL_ID,
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
canDestroyObjectRecords: false,
|
||||
},
|
||||
],
|
||||
fieldPermissions: [
|
||||
{
|
||||
objectUniversalIdentifier: COMPANY_UNIVERSAL_ID,
|
||||
fieldUniversalIdentifier: COMPANY_NAME_FIELD_UNIVERSAL_ID,
|
||||
canReadFieldValue: true,
|
||||
canUpdateFieldValue: false,
|
||||
},
|
||||
{
|
||||
objectUniversalIdentifier: PERSON_UNIVERSAL_ID,
|
||||
fieldUniversalIdentifier: PERSON_JOB_TITLE_FIELD_UNIVERSAL_ID,
|
||||
canReadFieldValue: true,
|
||||
canUpdateFieldValue: false,
|
||||
},
|
||||
],
|
||||
permissionFlags: ['DATA_MODEL', 'API_KEYS_AND_WEBHOOKS'],
|
||||
},
|
||||
};
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
ApplicationException,
|
||||
ApplicationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application.exception';
|
||||
|
||||
// NPM package names: optional @scope/ prefix, then name segment
|
||||
// Rejects path traversal (..), control characters, and non-npm-valid names
|
||||
const NPM_PACKAGE_NAME_REGEX =
|
||||
/^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
|
||||
|
||||
export const assertValidNpmPackageName = (name: string): void => {
|
||||
if (!NPM_PACKAGE_NAME_REGEX.test(name) || name.includes('..')) {
|
||||
throw new ApplicationException(
|
||||
'Invalid npm package name',
|
||||
ApplicationExceptionCode.INVALID_INPUT,
|
||||
);
|
||||
}
|
||||
};
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { resolve, sep } from 'path';
|
||||
|
||||
import * as tar from 'tar';
|
||||
|
||||
import {
|
||||
ApplicationException,
|
||||
ApplicationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application.exception';
|
||||
|
||||
export const MAX_EXTRACTED_SIZE_BYTES = 500 * 1024 * 1024;
|
||||
|
||||
export const extractTarballSecurely = async (
|
||||
tarballPath: string,
|
||||
targetDir: string,
|
||||
): Promise<void> => {
|
||||
let totalExtractedSize = 0;
|
||||
const resolvedTarget = resolve(targetDir) + sep;
|
||||
|
||||
await tar.extract({
|
||||
file: tarballPath,
|
||||
cwd: targetDir,
|
||||
filter: (entryPath, entry) => {
|
||||
const resolvedEntry = resolve(targetDir, entryPath);
|
||||
|
||||
if (!resolvedEntry.startsWith(resolvedTarget)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ('type' in entry) {
|
||||
const entryType = entry.type;
|
||||
|
||||
if (entryType === 'SymbolicLink' || entryType === 'Link') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
totalExtractedSize += entry.size ?? 0;
|
||||
|
||||
if (totalExtractedSize > MAX_EXTRACTED_SIZE_BYTES) {
|
||||
throw new ApplicationException(
|
||||
`Extracted size exceeds ${MAX_EXTRACTED_SIZE_BYTES} bytes`,
|
||||
ApplicationExceptionCode.TARBALL_EXTRACTION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
});
|
||||
};
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
import {
|
||||
ApplicationException,
|
||||
ApplicationExceptionCode,
|
||||
} from 'src/engine/core-modules/application/application.exception';
|
||||
|
||||
export const readJsonFile = async <T>(
|
||||
dir: string,
|
||||
filename: string,
|
||||
): Promise<T | null> => {
|
||||
const filePath = join(dir, filename);
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
|
||||
return JSON.parse(content) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const readJsonFileOrThrow = async <T>(
|
||||
dir: string,
|
||||
filename: string,
|
||||
): Promise<T> => {
|
||||
const result = await readJsonFile<T>(dir, filename);
|
||||
|
||||
if (result === null) {
|
||||
throw new ApplicationException(
|
||||
`${filename} not found or invalid in resolved package`,
|
||||
ApplicationExceptionCode.PACKAGE_RESOLUTION_FAILED,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
// npm pack wraps contents in a package/ subdirectory
|
||||
export const resolvePackageContentDir = async (
|
||||
extractDir: string,
|
||||
): Promise<string> => {
|
||||
const packageSubdir = join(extractDir, 'package');
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(packageSubdir);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
return packageSubdir;
|
||||
}
|
||||
} catch {
|
||||
// no package/ subdirectory — contents are at root
|
||||
}
|
||||
|
||||
return extractDir;
|
||||
};
|
||||
@@ -4,9 +4,9 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
|
||||
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
|
||||
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
|
||||
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
|
||||
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { AppTokenService } from 'src/engine/core-modules/app-token/services/app-token.service';
|
||||
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application-registration/application-registration.module';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
|
||||
|
||||
@@ -29,7 +29,7 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceInvitationService } from 'src/engine/core-modules/workspace-invitation/services/workspace-invitation.service';
|
||||
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application-registration/application-registration.service';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@@ -13,7 +13,7 @@ import { AppPath } from 'twenty-shared/types';
|
||||
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
|
||||
import { IsNull, Repository } from 'typeorm';
|
||||
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application-registration/application-registration.service';
|
||||
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
|
||||
import {
|
||||
AppTokenEntity,
|
||||
AppTokenType,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { v4 } from 'uuid';
|
||||
|
||||
import { USER_SIGNUP_EVENT_NAME } from 'src/engine/api/graphql/workspace-query-runner/constants/user-signup-event-name.constants';
|
||||
import { type AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import {
|
||||
AuthException,
|
||||
AuthExceptionCode,
|
||||
|
||||
@@ -7,9 +7,11 @@ import { ActorModule } from 'src/engine/core-modules/actor/actor.module';
|
||||
import { AdminPanelModule } from 'src/engine/core-modules/admin-panel/admin-panel.module';
|
||||
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
|
||||
import { AppTokenModule } from 'src/engine/core-modules/app-token/app-token.module';
|
||||
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application-registration/application-registration.module';
|
||||
import { ApplicationSyncModule } from 'src/engine/core-modules/application/application-sync.module';
|
||||
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
|
||||
import { ApplicationDevelopmentModule } from 'src/engine/core-modules/application/application-development/application-development.module';
|
||||
import { ApplicationInstallModule } from 'src/engine/core-modules/application/application-install/application-install.module';
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { MarketplaceModule } from 'src/engine/core-modules/application/application-marketplace/marketplace.module';
|
||||
import { EnvironmentModule } from 'src/engine/core-modules/environment/environment.module';
|
||||
import { ApprovedAccessDomainModule } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.module';
|
||||
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
||||
@@ -91,7 +93,9 @@ import { FileModule } from './file/file.module';
|
||||
OpenApiModule,
|
||||
ApplicationRegistrationModule,
|
||||
ApplicationModule,
|
||||
ApplicationSyncModule,
|
||||
ApplicationInstallModule,
|
||||
ApplicationDevelopmentModule,
|
||||
MarketplaceModule,
|
||||
AppTokenModule,
|
||||
TimelineMessagingModule,
|
||||
TimelineCalendarEventModule,
|
||||
|
||||
@@ -17,7 +17,7 @@ export class FileStorageModule {
|
||||
TypeOrmModule.forFeature([FileEntity, ApplicationEntity]),
|
||||
],
|
||||
providers: [FileStorageDriverFactory, FileStorageService],
|
||||
exports: [FileStorageService],
|
||||
exports: [FileStorageDriverFactory, FileStorageService],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
|
||||
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
import { Like, type QueryRunner, Repository } from 'typeorm';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
|
||||
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { FileWithSignedUrlDTO } from 'src/engine/core-modules/file/dtos/file-with-sign-url.dto';
|
||||
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
|
||||
|
||||
+3
@@ -54,6 +54,9 @@ export const fileFolderConfigs: Record<FileFolder, FileFolderConfig> = {
|
||||
[FileFolder.Workflow]: {
|
||||
ignoreExpirationToken: false,
|
||||
},
|
||||
[FileFolder.AppTarball]: {
|
||||
ignoreExpirationToken: false,
|
||||
},
|
||||
};
|
||||
|
||||
export type AllowedFolders = KebabCase<keyof typeof FileFolder>;
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ import {
|
||||
} from 'src/engine/core-modules/logic-function/logic-function-drivers/interfaces/logic-function-driver.interface';
|
||||
|
||||
import { FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
|
||||
import type { FlatApplicationVariable } from 'src/engine/core-modules/applicationVariable/types/flat-application-variable.type';
|
||||
import type { FlatApplicationVariable } from 'src/engine/core-modules/application/application-variable/types/flat-application-variable.type';
|
||||
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
|
||||
import { LOGIC_FUNCTION_EXECUTED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/logic-function/logic-function-executed';
|
||||
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { type FlatApplicationVariable } from 'src/engine/core-modules/applicationVariable/types/flat-application-variable.type';
|
||||
import { type FlatApplicationVariable } from 'src/engine/core-modules/application/application-variable/types/flat-application-variable.type';
|
||||
import { type SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
|
||||
import { buildEnvVar } from 'src/engine/core-modules/logic-function/logic-function-executor/utils/build-env-var';
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { type FlatApplicationVariable } from 'src/engine/core-modules/applicationVariable/types/flat-application-variable.type';
|
||||
import { type FlatApplicationVariable } from 'src/engine/core-modules/application/application-variable/types/flat-application-variable.type';
|
||||
import { type SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
|
||||
|
||||
export const buildEnvVar = (
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ import {
|
||||
type OutputFile,
|
||||
} from 'src/engine/core-modules/code-interpreter/drivers/interfaces/code-interpreter-driver.interface';
|
||||
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import {
|
||||
type AccessTokenJwtPayload,
|
||||
JwtTokenTypeEnum,
|
||||
|
||||
@@ -1634,6 +1634,26 @@ export class ConfigVariables {
|
||||
@CastToPositiveNumber()
|
||||
@IsOptional()
|
||||
PG_DATABASE_REPLICA_TIMEOUT_MS: number = 10000;
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.SERVER_CONFIG,
|
||||
description:
|
||||
'Default npm registry URL for resolving app packages (e.g. https://registry.npmjs.org)',
|
||||
type: ConfigVariableType.STRING,
|
||||
})
|
||||
@IsUrl({ require_tld: false })
|
||||
@IsOptional()
|
||||
APP_REGISTRY_URL: string = 'https://registry.npmjs.org';
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.SERVER_CONFIG,
|
||||
isSensitive: true,
|
||||
description:
|
||||
'Auth token for the default npm registry (for private packages)',
|
||||
type: ConfigVariableType.STRING,
|
||||
})
|
||||
@IsOptional()
|
||||
APP_REGISTRY_TOKEN: string;
|
||||
}
|
||||
|
||||
export const validate = (config: Record<string, unknown>): ConfigVariables => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user