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 {}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="#1a2744">
|
||||
<!-- Database cylinder -->
|
||||
<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"/>
|
||||
<!-- Database stripes -->
|
||||
<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"/>
|
||||
<!-- Upload circle -->
|
||||
<circle cx="72" cy="62" r="22" fill="#1a2744"/>
|
||||
<circle cx="72" cy="62" r="18" fill="#fff"/>
|
||||
<!-- Upload arrow -->
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 743 B |
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"application": {
|
||||
"universalIdentifier": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"defaultRoleUniversalIdentifier": "00000000-0000-0000-0000-000000000000",
|
||||
"displayName": "Data Enrichment",
|
||||
"description": "Enrich your data easily. Choose your provider.",
|
||||
"icon": "IconSparkles",
|
||||
"author": "Cosmos Labs",
|
||||
"category": "Data",
|
||||
"logoUrl": "assets/logo.png",
|
||||
"screenshots": [
|
||||
"assets/screenshot-1.png",
|
||||
"assets/screenshot-2.png",
|
||||
"assets/screenshot-3.png"
|
||||
],
|
||||
"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"
|
||||
},
|
||||
"entities": {
|
||||
"objects": [],
|
||||
"fields": [],
|
||||
"logicFunctions": [],
|
||||
"frontComponents": [],
|
||||
"roles": []
|
||||
},
|
||||
"publicAssets": [],
|
||||
"sources": {},
|
||||
"packageJson": {
|
||||
"name": "@twenty-apps/data-enrichment",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"yarnLock": ""
|
||||
}
|
||||
+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;
|
||||
};
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
ALL_OAUTH_SCOPES,
|
||||
OAUTH_SCOPE_DESCRIPTIONS,
|
||||
OAUTH_SCOPES,
|
||||
} from 'src/engine/core-modules/application/application-registration/constants/oauth-scopes';
|
||||
|
||||
describe('OAuth Scopes', () => {
|
||||
it('should have all scopes defined', () => {
|
||||
expect(ALL_OAUTH_SCOPES).toContain('api');
|
||||
expect(ALL_OAUTH_SCOPES).toContain('profile');
|
||||
expect(ALL_OAUTH_SCOPES).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should have descriptions for all scopes', () => {
|
||||
for (const scope of ALL_OAUTH_SCOPES) {
|
||||
expect(OAUTH_SCOPE_DESCRIPTIONS[scope]).toBeDefined();
|
||||
expect(OAUTH_SCOPE_DESCRIPTIONS[scope].length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have consistent keys and values', () => {
|
||||
expect(OAUTH_SCOPES.API).toBe('api');
|
||||
expect(OAUTH_SCOPES.PROFILE).toBe('profile');
|
||||
});
|
||||
});
|
||||
+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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { IDField } from '@ptc-org/nestjs-query-graphql';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
type Relation,
|
||||
Unique,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
|
||||
@Entity({ name: 'applicationRegistrationVariable', schema: 'core' })
|
||||
@ObjectType('ApplicationRegistrationVariable')
|
||||
@Unique('IDX_APP_REG_VAR_KEY_APP_REGISTRATION_ID_UNIQUE', [
|
||||
'key',
|
||||
'applicationRegistrationId',
|
||||
])
|
||||
@Index('IDX_APP_REG_VAR_APP_REGISTRATION_ID', ['applicationRegistrationId'])
|
||||
export class ApplicationRegistrationVariableEntity {
|
||||
@IDField(() => UUIDScalarType)
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Field()
|
||||
@Column({ nullable: false, type: 'text' })
|
||||
key: string;
|
||||
|
||||
@Column({ nullable: false, type: 'text', default: '' })
|
||||
encryptedValue: string;
|
||||
|
||||
@Field()
|
||||
@Column({ nullable: false, type: 'text', default: '' })
|
||||
description: string;
|
||||
|
||||
@Field()
|
||||
@Column({ nullable: false, type: 'boolean', default: true })
|
||||
isSecret: boolean;
|
||||
|
||||
@Field()
|
||||
@Column({ nullable: false, type: 'boolean', default: false })
|
||||
isRequired: boolean;
|
||||
|
||||
@Field()
|
||||
get isFilled(): boolean {
|
||||
return this.encryptedValue !== '';
|
||||
}
|
||||
|
||||
@Column({ nullable: false, type: 'uuid' })
|
||||
applicationRegistrationId: string;
|
||||
|
||||
@ManyToOne(
|
||||
() => ApplicationRegistrationEntity,
|
||||
(applicationRegistration) => applicationRegistration.variables,
|
||||
{ onDelete: 'CASCADE', nullable: false },
|
||||
)
|
||||
@JoinColumn({ name: 'applicationRegistrationId' })
|
||||
applicationRegistration: Relation<ApplicationRegistrationEntity>;
|
||||
|
||||
@Field()
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@Field()
|
||||
@UpdateDateColumn({ type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
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/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/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()
|
||||
export class ApplicationRegistrationVariableService {
|
||||
constructor(
|
||||
@InjectRepository(ApplicationRegistrationVariableEntity)
|
||||
private readonly variableRepository: Repository<ApplicationRegistrationVariableEntity>,
|
||||
@InjectRepository(ApplicationRegistrationEntity)
|
||||
private readonly applicationRegistrationRepository: Repository<ApplicationRegistrationEntity>,
|
||||
private readonly encryptionService: SecretEncryptionService,
|
||||
) {}
|
||||
|
||||
async findVariables(
|
||||
applicationRegistrationId: string,
|
||||
workspaceId: string,
|
||||
): Promise<ApplicationRegistrationVariableEntity[]> {
|
||||
await this.assertRegistrationOwnedByWorkspace(
|
||||
applicationRegistrationId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
return this.variableRepository.find({
|
||||
where: { applicationRegistrationId },
|
||||
order: { key: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async createVariable(
|
||||
input: CreateApplicationRegistrationVariableInput,
|
||||
workspaceId: string,
|
||||
): Promise<ApplicationRegistrationVariableEntity> {
|
||||
await this.assertRegistrationOwnedByWorkspace(
|
||||
input.applicationRegistrationId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const encryptedValue = this.encryptionService.encrypt(input.value);
|
||||
|
||||
const variable = this.variableRepository.create({
|
||||
applicationRegistrationId: input.applicationRegistrationId,
|
||||
key: input.key,
|
||||
encryptedValue,
|
||||
description: input.description ?? '',
|
||||
isSecret: input.isSecret ?? true,
|
||||
});
|
||||
|
||||
return this.variableRepository.save(variable);
|
||||
}
|
||||
|
||||
async updateVariable(
|
||||
input: UpdateApplicationRegistrationVariableInput,
|
||||
workspaceId: string,
|
||||
): Promise<ApplicationRegistrationVariableEntity> {
|
||||
const { id, update } = input;
|
||||
|
||||
const variable = await this.variableRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!variable) {
|
||||
throw new ApplicationRegistrationException(
|
||||
`Variable with id ${id} not found`,
|
||||
ApplicationRegistrationExceptionCode.VARIABLE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
await this.assertRegistrationOwnedByWorkspace(
|
||||
variable.applicationRegistrationId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
|
||||
if (isDefined(update.value)) {
|
||||
updateData.encryptedValue = this.encryptionService.encrypt(update.value);
|
||||
}
|
||||
|
||||
if (isDefined(update.description)) {
|
||||
updateData.description = update.description;
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await this.variableRepository.update(id, updateData);
|
||||
}
|
||||
|
||||
return this.variableRepository.findOneOrFail({ where: { id } });
|
||||
}
|
||||
|
||||
async deleteVariable(id: string, workspaceId: string): Promise<boolean> {
|
||||
const variable = await this.variableRepository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!variable) {
|
||||
throw new ApplicationRegistrationException(
|
||||
`Variable with id ${id} not found`,
|
||||
ApplicationRegistrationExceptionCode.VARIABLE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
await this.assertRegistrationOwnedByWorkspace(
|
||||
variable.applicationRegistrationId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
await this.variableRepository.delete(id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Syncs variable schemas from manifest: creates missing, updates metadata, removes stale
|
||||
async syncVariableSchemas(
|
||||
applicationRegistrationId: string,
|
||||
serverVariables: ServerVariables,
|
||||
): Promise<void> {
|
||||
const declaredKeys = Object.keys(serverVariables);
|
||||
|
||||
const existingVariables = await this.variableRepository.find({
|
||||
where: { applicationRegistrationId },
|
||||
});
|
||||
|
||||
const existingByKey = new Map(
|
||||
existingVariables.map((variable) => [variable.key, variable]),
|
||||
);
|
||||
|
||||
for (const [key, schema] of Object.entries(serverVariables)) {
|
||||
const existing = existingByKey.get(key);
|
||||
|
||||
if (existing) {
|
||||
await this.variableRepository.update(existing.id, {
|
||||
description: schema.description ?? '',
|
||||
isSecret: schema.isSecret ?? true,
|
||||
isRequired: schema.isRequired ?? false,
|
||||
});
|
||||
} else {
|
||||
await this.variableRepository.save(
|
||||
this.variableRepository.create({
|
||||
applicationRegistrationId,
|
||||
key,
|
||||
encryptedValue: '',
|
||||
description: schema.description ?? '',
|
||||
isSecret: schema.isSecret ?? true,
|
||||
isRequired: schema.isRequired ?? false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (declaredKeys.length > 0) {
|
||||
await this.variableRepository.delete({
|
||||
applicationRegistrationId,
|
||||
key: Not(In(declaredKeys)),
|
||||
});
|
||||
} else {
|
||||
await this.variableRepository.delete({ applicationRegistrationId });
|
||||
}
|
||||
}
|
||||
|
||||
private async assertRegistrationOwnedByWorkspace(
|
||||
registrationId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
const registration = await this.applicationRegistrationRepository.findOne({
|
||||
where: { id: registrationId, ownerWorkspaceId: workspaceId },
|
||||
});
|
||||
|
||||
if (!registration) {
|
||||
throw new ApplicationRegistrationException(
|
||||
`Application registration with id ${registrationId} not found`,
|
||||
ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { IDField } from '@ptc-org/nestjs-query-graphql';
|
||||
import {
|
||||
Check,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
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/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';
|
||||
|
||||
@Entity({ name: 'applicationRegistration', schema: 'core' })
|
||||
@ObjectType('ApplicationRegistration')
|
||||
@Index(
|
||||
'IDX_APPLICATION_REGISTRATION_UNIVERSAL_IDENTIFIER_UNIQUE',
|
||||
['universalIdentifier'],
|
||||
{
|
||||
unique: true,
|
||||
where: '"deletedAt" IS NULL',
|
||||
},
|
||||
)
|
||||
@Index(
|
||||
'IDX_APPLICATION_REGISTRATION_OAUTH_CLIENT_ID_UNIQUE',
|
||||
['oAuthClientId'],
|
||||
{
|
||||
unique: true,
|
||||
where: '"deletedAt" IS NULL',
|
||||
},
|
||||
)
|
||||
@Index('IDX_APPLICATION_REGISTRATION_CREATED_BY_USER_ID', ['createdByUserId'])
|
||||
@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')
|
||||
id: string;
|
||||
|
||||
@Field()
|
||||
@Column({ nullable: false, type: 'uuid' })
|
||||
universalIdentifier: string;
|
||||
|
||||
@Field()
|
||||
@Column({ nullable: false, type: 'text' })
|
||||
name: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
description: string | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
logoUrl: string | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
author: string | null;
|
||||
|
||||
@Field()
|
||||
@Column({ nullable: false, type: 'text' })
|
||||
oAuthClientId: string;
|
||||
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
oAuthClientSecretHash: string | null;
|
||||
|
||||
@Field(() => [String])
|
||||
@Column({ type: 'text', array: true, default: '{}' })
|
||||
oAuthRedirectUris: string[];
|
||||
|
||||
@Field(() => [String])
|
||||
@Column({ type: 'text', array: true, default: '{}' })
|
||||
oAuthScopes: string[];
|
||||
|
||||
@Column({ nullable: true, type: 'uuid' })
|
||||
createdByUserId: string | null;
|
||||
|
||||
@ManyToOne(() => UserEntity, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'createdByUserId' })
|
||||
createdByUser: Relation<UserEntity> | null;
|
||||
|
||||
// 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;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
@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,
|
||||
{ onDelete: 'CASCADE' },
|
||||
)
|
||||
variables: Relation<ApplicationRegistrationVariableEntity[]>;
|
||||
|
||||
@Field()
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@Field()
|
||||
@UpdateDateColumn({ type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@DeleteDateColumn({ type: 'timestamptz' })
|
||||
deletedAt: Date | null;
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export enum ApplicationRegistrationExceptionCode {
|
||||
APPLICATION_REGISTRATION_NOT_FOUND = 'APPLICATION_REGISTRATION_NOT_FOUND',
|
||||
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',
|
||||
}
|
||||
|
||||
const getExceptionUserFriendlyMessage = (
|
||||
code: ApplicationRegistrationExceptionCode,
|
||||
) => {
|
||||
switch (code) {
|
||||
case ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND:
|
||||
return msg`Application registration not found.`;
|
||||
case ApplicationRegistrationExceptionCode.UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED:
|
||||
return msg`This universal identifier is already claimed by another registration.`;
|
||||
case ApplicationRegistrationExceptionCode.INVALID_SCOPE:
|
||||
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:
|
||||
assertUnreachable(code);
|
||||
}
|
||||
};
|
||||
|
||||
export class ApplicationRegistrationException extends CustomException<ApplicationRegistrationExceptionCode> {
|
||||
constructor(
|
||||
message: string,
|
||||
code: ApplicationRegistrationExceptionCode,
|
||||
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
|
||||
) {
|
||||
super(message, code, {
|
||||
userFriendlyMessage:
|
||||
userFriendlyMessage ?? getExceptionUserFriendlyMessage(code),
|
||||
});
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
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: [
|
||||
TypeOrmModule.forFeature([
|
||||
ApplicationRegistrationEntity,
|
||||
ApplicationRegistrationVariableEntity,
|
||||
ApplicationEntity,
|
||||
AppTokenEntity,
|
||||
UserWorkspaceEntity,
|
||||
]),
|
||||
SecretEncryptionModule,
|
||||
FeatureFlagModule,
|
||||
PermissionsModule,
|
||||
ThrottlerModule,
|
||||
TokenModule,
|
||||
ApplicationModule,
|
||||
ApplicationInstallModule,
|
||||
FileStorageModule,
|
||||
WorkspaceCacheStorageModule,
|
||||
],
|
||||
controllers: [OAuthTokenController, OAuthDiscoveryController],
|
||||
providers: [
|
||||
ApplicationRegistrationService,
|
||||
ApplicationRegistrationVariableService,
|
||||
ApplicationRegistrationResolver,
|
||||
AppTarballUploadService,
|
||||
OAuthService,
|
||||
],
|
||||
exports: [
|
||||
ApplicationRegistrationService,
|
||||
ApplicationRegistrationVariableService,
|
||||
],
|
||||
})
|
||||
export class ApplicationRegistrationModule {}
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
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 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';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
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,
|
||||
)
|
||||
export class ApplicationRegistrationResolver {
|
||||
constructor(
|
||||
private readonly applicationRegistrationService: ApplicationRegistrationService,
|
||||
private readonly applicationRegistrationVariableService: ApplicationRegistrationVariableService,
|
||||
private readonly appTarballUploadService: AppTarballUploadService,
|
||||
) {}
|
||||
|
||||
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
|
||||
@Query(() => PublicApplicationRegistrationDTO, { nullable: true })
|
||||
async findApplicationRegistrationByClientId(
|
||||
@Args('clientId') clientId: string,
|
||||
): Promise<PublicApplicationRegistrationDTO | null> {
|
||||
return this.applicationRegistrationService.findPublicByClientId(clientId);
|
||||
}
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard, NoPermissionGuard)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Query(() => ApplicationRegistrationEntity, { nullable: true })
|
||||
async findApplicationRegistrationByUniversalIdentifier(
|
||||
@Args('universalIdentifier') universalIdentifier: string,
|
||||
): Promise<ApplicationRegistrationEntity | null> {
|
||||
return this.applicationRegistrationService.findOneByUniversalIdentifier(
|
||||
universalIdentifier,
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Query(() => [ApplicationRegistrationEntity])
|
||||
async findManyApplicationRegistrations(
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<ApplicationRegistrationEntity[]> {
|
||||
return this.applicationRegistrationService.findMany(workspaceId);
|
||||
}
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Query(() => ApplicationRegistrationEntity)
|
||||
async findOneApplicationRegistration(
|
||||
@Args('id') id: string,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<ApplicationRegistrationEntity> {
|
||||
return this.applicationRegistrationService.findOneById(id, workspaceId);
|
||||
}
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Query(() => ApplicationRegistrationStatsDTO)
|
||||
async findApplicationRegistrationStats(
|
||||
@Args('id') id: string,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<ApplicationRegistrationStatsDTO> {
|
||||
return this.applicationRegistrationService.getStats(id, workspaceId);
|
||||
}
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Mutation(() => CreateApplicationRegistrationDTO)
|
||||
async createApplicationRegistration(
|
||||
@Args('input') input: CreateApplicationRegistrationInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
@AuthUser({ allowUndefined: true }) user: UserEntity | undefined,
|
||||
): Promise<CreateApplicationRegistrationDTO> {
|
||||
return this.applicationRegistrationService.create(
|
||||
input,
|
||||
workspaceId,
|
||||
user?.id ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Mutation(() => ApplicationRegistrationEntity)
|
||||
async updateApplicationRegistration(
|
||||
@Args('input') input: UpdateApplicationRegistrationInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<ApplicationRegistrationEntity> {
|
||||
return this.applicationRegistrationService.update(input, workspaceId);
|
||||
}
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Mutation(() => Boolean)
|
||||
async deleteApplicationRegistration(
|
||||
@Args('id') id: string,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<boolean> {
|
||||
return this.applicationRegistrationService.delete(id, workspaceId);
|
||||
}
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Mutation(() => RotateClientSecretDTO)
|
||||
async rotateApplicationRegistrationClientSecret(
|
||||
@Args('id') id: string,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<RotateClientSecretDTO> {
|
||||
const clientSecret =
|
||||
await this.applicationRegistrationService.rotateClientSecret(
|
||||
id,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
return { clientSecret };
|
||||
}
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Query(() => [ApplicationRegistrationVariableEntity])
|
||||
async findApplicationRegistrationVariables(
|
||||
@Args('applicationRegistrationId') applicationRegistrationId: string,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<ApplicationRegistrationVariableEntity[]> {
|
||||
return this.applicationRegistrationVariableService.findVariables(
|
||||
applicationRegistrationId,
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Mutation(() => ApplicationRegistrationVariableEntity)
|
||||
async createApplicationRegistrationVariable(
|
||||
@Args('input') input: CreateApplicationRegistrationVariableInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<ApplicationRegistrationVariableEntity> {
|
||||
return this.applicationRegistrationVariableService.createVariable(
|
||||
input,
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Mutation(() => ApplicationRegistrationVariableEntity)
|
||||
async updateApplicationRegistrationVariable(
|
||||
@Args('input') input: UpdateApplicationRegistrationVariableInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<ApplicationRegistrationVariableEntity> {
|
||||
return this.applicationRegistrationVariableService.updateVariable(
|
||||
input,
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
FeatureFlagGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.API_KEYS_AND_WEBHOOKS),
|
||||
)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_APPLICATION_ENABLED)
|
||||
@Mutation(() => Boolean)
|
||||
async deleteApplicationRegistrationVariable(
|
||||
@Args('id') id: string,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
): Promise<boolean> {
|
||||
return this.applicationRegistrationVariableService.deleteVariable(
|
||||
id,
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+311
@@ -0,0 +1,311 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type 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 { 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';
|
||||
|
||||
const BCRYPT_SALT_ROUNDS = 10;
|
||||
|
||||
@Injectable()
|
||||
export class ApplicationRegistrationService {
|
||||
constructor(
|
||||
@InjectRepository(ApplicationRegistrationEntity)
|
||||
private readonly applicationRegistrationRepository: Repository<ApplicationRegistrationEntity>,
|
||||
@InjectRepository(ApplicationEntity)
|
||||
private readonly applicationRepository: Repository<ApplicationEntity>,
|
||||
) {}
|
||||
|
||||
async findMany(
|
||||
ownerWorkspaceId: string,
|
||||
): Promise<ApplicationRegistrationEntity[]> {
|
||||
return this.applicationRegistrationRepository.find({
|
||||
where: { ownerWorkspaceId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOneById(
|
||||
id: string,
|
||||
ownerWorkspaceId: string,
|
||||
): Promise<ApplicationRegistrationEntity> {
|
||||
const registration = await this.applicationRegistrationRepository.findOne({
|
||||
where: { id, ownerWorkspaceId },
|
||||
});
|
||||
|
||||
if (!registration) {
|
||||
throw new ApplicationRegistrationException(
|
||||
`Application registration with id ${id} not found`,
|
||||
ApplicationRegistrationExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
return registration;
|
||||
}
|
||||
|
||||
// Global lookup — used by OAuth flow (no workspace scoping)
|
||||
async findOneByClientId(
|
||||
clientId: string,
|
||||
): Promise<ApplicationRegistrationEntity | null> {
|
||||
return this.applicationRegistrationRepository.findOne({
|
||||
where: { oAuthClientId: clientId },
|
||||
});
|
||||
}
|
||||
|
||||
// Global lookup — used by OAuth authorize page (no workspace scoping)
|
||||
async findPublicByClientId(
|
||||
clientId: string,
|
||||
): Promise<PublicApplicationRegistrationDTO | null> {
|
||||
const registration = await this.applicationRegistrationRepository.findOne({
|
||||
where: { oAuthClientId: clientId },
|
||||
select: ['id', 'name', 'logoUrl', 'websiteUrl', 'oAuthScopes'],
|
||||
});
|
||||
|
||||
if (!registration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: registration.id,
|
||||
name: registration.name,
|
||||
logoUrl: registration.logoUrl,
|
||||
websiteUrl: registration.websiteUrl,
|
||||
oAuthScopes: registration.oAuthScopes,
|
||||
};
|
||||
}
|
||||
|
||||
async isOwnedByWorkspace(id: string, workspaceId: string): Promise<boolean> {
|
||||
const registration = await this.applicationRegistrationRepository.findOne({
|
||||
where: { id },
|
||||
select: ['id', 'ownerWorkspaceId'],
|
||||
});
|
||||
|
||||
return registration?.ownerWorkspaceId === workspaceId;
|
||||
}
|
||||
|
||||
// Global lookup — used by app sync to find existing registrations
|
||||
async findOneByUniversalIdentifier(
|
||||
universalIdentifier: string,
|
||||
): Promise<ApplicationRegistrationEntity | null> {
|
||||
return this.applicationRegistrationRepository.findOne({
|
||||
where: { universalIdentifier },
|
||||
});
|
||||
}
|
||||
|
||||
async create(
|
||||
input: CreateApplicationRegistrationInput,
|
||||
ownerWorkspaceId: string,
|
||||
createdByUserId: string | null,
|
||||
): Promise<{
|
||||
applicationRegistration: ApplicationRegistrationEntity;
|
||||
clientSecret: string;
|
||||
}> {
|
||||
const universalIdentifier = input.universalIdentifier ?? v4();
|
||||
|
||||
const existingByUid =
|
||||
await this.findOneByUniversalIdentifier(universalIdentifier);
|
||||
|
||||
if (existingByUid) {
|
||||
throw new ApplicationRegistrationException(
|
||||
`Universal identifier ${universalIdentifier} is already claimed`,
|
||||
ApplicationRegistrationExceptionCode.UNIVERSAL_IDENTIFIER_ALREADY_CLAIMED,
|
||||
);
|
||||
}
|
||||
|
||||
if (isDefined(input.oAuthRedirectUris)) {
|
||||
this.validateRedirectUris(input.oAuthRedirectUris);
|
||||
}
|
||||
|
||||
if (isDefined(input.oAuthScopes)) {
|
||||
this.validateScopes(input.oAuthScopes);
|
||||
}
|
||||
|
||||
const clientId = v4();
|
||||
const { clientSecret, clientSecretHash } =
|
||||
await this.generateClientSecret();
|
||||
|
||||
const applicationRegistration =
|
||||
this.applicationRegistrationRepository.create({
|
||||
universalIdentifier,
|
||||
name: input.name,
|
||||
description: input.description ?? null,
|
||||
logoUrl: input.logoUrl ?? null,
|
||||
author: input.author ?? null,
|
||||
oAuthClientId: clientId,
|
||||
oAuthClientSecretHash: clientSecretHash,
|
||||
oAuthRedirectUris: input.oAuthRedirectUris ?? [],
|
||||
oAuthScopes: input.oAuthScopes ?? [],
|
||||
createdByUserId,
|
||||
ownerWorkspaceId,
|
||||
websiteUrl: input.websiteUrl ?? null,
|
||||
termsUrl: input.termsUrl ?? null,
|
||||
});
|
||||
|
||||
const saved = await this.applicationRegistrationRepository.save(
|
||||
applicationRegistration,
|
||||
);
|
||||
|
||||
return { applicationRegistration: saved, clientSecret };
|
||||
}
|
||||
|
||||
async update(
|
||||
input: UpdateApplicationRegistrationInput,
|
||||
ownerWorkspaceId: string,
|
||||
): Promise<ApplicationRegistrationEntity> {
|
||||
const { id, update } = input;
|
||||
|
||||
await this.findOneById(id, ownerWorkspaceId);
|
||||
|
||||
if (isDefined(update.oAuthRedirectUris)) {
|
||||
this.validateRedirectUris(update.oAuthRedirectUris);
|
||||
}
|
||||
|
||||
if (isDefined(update.oAuthScopes)) {
|
||||
this.validateScopes(update.oAuthScopes);
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
|
||||
if (isDefined(update.name)) updateData.name = update.name;
|
||||
if (isDefined(update.description))
|
||||
updateData.description = update.description;
|
||||
if (isDefined(update.logoUrl)) updateData.logoUrl = update.logoUrl;
|
||||
if (isDefined(update.author)) updateData.author = update.author;
|
||||
if (isDefined(update.oAuthRedirectUris))
|
||||
updateData.oAuthRedirectUris = update.oAuthRedirectUris;
|
||||
if (isDefined(update.oAuthScopes))
|
||||
updateData.oAuthScopes = update.oAuthScopes;
|
||||
if (isDefined(update.websiteUrl)) updateData.websiteUrl = update.websiteUrl;
|
||||
if (isDefined(update.termsUrl)) updateData.termsUrl = update.termsUrl;
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await this.applicationRegistrationRepository.update(id, updateData);
|
||||
}
|
||||
|
||||
return this.findOneById(id, ownerWorkspaceId);
|
||||
}
|
||||
|
||||
async delete(id: string, ownerWorkspaceId: string): Promise<boolean> {
|
||||
await this.findOneById(id, ownerWorkspaceId);
|
||||
await this.applicationRegistrationRepository.softDelete(id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async rotateClientSecret(
|
||||
id: string,
|
||||
ownerWorkspaceId: string,
|
||||
): Promise<string> {
|
||||
await this.findOneById(id, ownerWorkspaceId);
|
||||
|
||||
const { clientSecret, clientSecretHash } =
|
||||
await this.generateClientSecret();
|
||||
|
||||
await this.applicationRegistrationRepository.update(id, {
|
||||
oAuthClientSecretHash: clientSecretHash,
|
||||
});
|
||||
|
||||
return clientSecret;
|
||||
}
|
||||
|
||||
async verifyClientSecret(
|
||||
registration: ApplicationRegistrationEntity,
|
||||
clientSecret: string,
|
||||
): Promise<boolean> {
|
||||
if (!registration.oAuthClientSecretHash) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return bcrypt.compare(clientSecret, registration.oAuthClientSecretHash);
|
||||
}
|
||||
|
||||
async getStats(
|
||||
applicationRegistrationId: string,
|
||||
ownerWorkspaceId: string,
|
||||
): Promise<ApplicationRegistrationStatsDTO> {
|
||||
await this.findOneById(applicationRegistrationId, ownerWorkspaceId);
|
||||
|
||||
const versionDistribution: { version: string; count: number }[] =
|
||||
await this.applicationRepository
|
||||
.createQueryBuilder('application')
|
||||
.select("COALESCE(application.version, 'unknown')", 'version')
|
||||
.addSelect('COUNT(*)::int', 'count')
|
||||
.where(
|
||||
'application."applicationRegistrationId" = :applicationRegistrationId',
|
||||
{ applicationRegistrationId },
|
||||
)
|
||||
.andWhere('application."deletedAt" IS NULL')
|
||||
.groupBy('version')
|
||||
.orderBy('count', 'DESC')
|
||||
.getRawMany();
|
||||
|
||||
const activeInstalls = versionDistribution.reduce(
|
||||
(sum, entry) => sum + entry.count,
|
||||
0,
|
||||
);
|
||||
|
||||
const mostInstalledVersion = versionDistribution[0]?.version ?? null;
|
||||
|
||||
return {
|
||||
activeInstalls,
|
||||
mostInstalledVersion,
|
||||
versionDistribution,
|
||||
};
|
||||
}
|
||||
|
||||
private async generateClientSecret(): Promise<{
|
||||
clientSecret: string;
|
||||
clientSecretHash: string;
|
||||
}> {
|
||||
const clientSecret = crypto.randomBytes(32).toString('hex');
|
||||
const clientSecretHash = await bcrypt.hash(
|
||||
clientSecret,
|
||||
BCRYPT_SALT_ROUNDS,
|
||||
);
|
||||
|
||||
return { clientSecret, clientSecretHash };
|
||||
}
|
||||
|
||||
private validateRedirectUris(uris: string[]): void {
|
||||
for (const uri of uris) {
|
||||
const result = validateRedirectUri(uri);
|
||||
|
||||
if (!result.valid) {
|
||||
throw new ApplicationRegistrationException(
|
||||
result.reason,
|
||||
ApplicationRegistrationExceptionCode.INVALID_REDIRECT_URI,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private validateScopes(scopes: string[]): void {
|
||||
const validScopes: readonly string[] = ALL_OAUTH_SCOPES;
|
||||
const invalidScopes = scopes.filter(
|
||||
(scope) => !validScopes.includes(scope),
|
||||
);
|
||||
|
||||
if (invalidScopes.length > 0) {
|
||||
throw new ApplicationRegistrationException(
|
||||
`Invalid scopes: ${invalidScopes.join(', ')}`,
|
||||
ApplicationRegistrationExceptionCode.INVALID_SCOPE,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
// Scopes are a thin consent boundary shown to the user during OAuth authorization.
|
||||
// Actual permissions are enforced by the role assigned to the application at the
|
||||
// workspace level (object, field, and row-level permissions).
|
||||
export const OAUTH_SCOPES = {
|
||||
API: 'api',
|
||||
PROFILE: 'profile',
|
||||
} as const;
|
||||
|
||||
export type OAuthScope = (typeof OAUTH_SCOPES)[keyof typeof OAUTH_SCOPES];
|
||||
|
||||
export const ALL_OAUTH_SCOPES: OAuthScope[] = Object.values(OAUTH_SCOPES);
|
||||
|
||||
export const OAUTH_SCOPE_DESCRIPTIONS: Record<OAuthScope, string> = {
|
||||
[OAUTH_SCOPES.API]: 'Access workspace data according to the assigned role',
|
||||
[OAUTH_SCOPES.PROFILE]: "Read the authenticated user's profile",
|
||||
};
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
|
||||
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';
|
||||
|
||||
@Controller('.well-known')
|
||||
export class OAuthDiscoveryController {
|
||||
constructor(private readonly twentyConfigService: TwentyConfigService) {}
|
||||
|
||||
@Get('oauth-authorization-server')
|
||||
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
|
||||
getAuthorizationServerMetadata() {
|
||||
const serverUrl = this.twentyConfigService.get('SERVER_URL');
|
||||
|
||||
return {
|
||||
issuer: serverUrl,
|
||||
authorization_endpoint: `${serverUrl}/authorize`,
|
||||
token_endpoint: `${serverUrl}/oauth/token`,
|
||||
revocation_endpoint: `${serverUrl}/oauth/revoke`,
|
||||
introspection_endpoint: `${serverUrl}/oauth/introspect`,
|
||||
scopes_supported: ALL_OAUTH_SCOPES,
|
||||
response_types_supported: ['code'],
|
||||
grant_types_supported: [
|
||||
'authorization_code',
|
||||
'client_credentials',
|
||||
'refresh_token',
|
||||
],
|
||||
code_challenge_methods_supported: ['S256'],
|
||||
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
|
||||
revocation_endpoint_auth_methods_supported: ['client_secret_post'],
|
||||
introspection_endpoint_auth_methods_supported: ['client_secret_post'],
|
||||
};
|
||||
}
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseFilters,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { type Request, type Response } from 'express';
|
||||
|
||||
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';
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
|
||||
|
||||
const OAUTH_RATE_LIMIT_MAX = 60;
|
||||
const OAUTH_RATE_LIMIT_WINDOW_MS = 60_000;
|
||||
|
||||
@Controller('oauth')
|
||||
@UseFilters(AuthRestApiExceptionFilter)
|
||||
export class OAuthTokenController {
|
||||
constructor(
|
||||
private readonly oauthService: OAuthService,
|
||||
private readonly throttlerService: ThrottlerService,
|
||||
) {}
|
||||
|
||||
@Post('token')
|
||||
@HttpCode(200)
|
||||
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
|
||||
@UsePipes(new ValidationPipe())
|
||||
async token(
|
||||
@Body() body: OAuthTokenInput,
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
if (await this.applyRateLimit(req, res)) return;
|
||||
|
||||
let result: OAuthTokenResponse | OAuthErrorResponse;
|
||||
|
||||
switch (body.grant_type) {
|
||||
case 'authorization_code':
|
||||
result = await this.oauthService.exchangeAuthorizationCode({
|
||||
authorizationCode: body.code ?? '',
|
||||
clientId: body.client_id ?? '',
|
||||
clientSecret: body.client_secret,
|
||||
codeVerifier: body.code_verifier,
|
||||
redirectUri: body.redirect_uri ?? '',
|
||||
});
|
||||
break;
|
||||
|
||||
case 'client_credentials':
|
||||
result = await this.oauthService.clientCredentialsGrant({
|
||||
clientId: body.client_id ?? '',
|
||||
clientSecret: body.client_secret ?? '',
|
||||
});
|
||||
break;
|
||||
|
||||
case 'refresh_token':
|
||||
result = await this.oauthService.refreshTokenGrant({
|
||||
refreshToken: body.refresh_token ?? '',
|
||||
clientId: body.client_id ?? '',
|
||||
clientSecret: body.client_secret,
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
result = {
|
||||
error: 'unsupported_grant_type',
|
||||
error_description:
|
||||
'The provided grant_type is not supported. Supported values: authorization_code, client_credentials, refresh_token',
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
this.setSecurityHeaders(res);
|
||||
|
||||
if ('error' in result) {
|
||||
const statusCode = result.error === 'invalid_client' ? 401 : 400;
|
||||
|
||||
res.status(statusCode);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('revoke')
|
||||
@HttpCode(200)
|
||||
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
|
||||
@UsePipes(new ValidationPipe())
|
||||
async revoke(
|
||||
@Body() body: OAuthRevokeInput,
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
if (await this.applyRateLimit(req, res)) return;
|
||||
this.setSecurityHeaders(res);
|
||||
|
||||
await this.oauthService.revokeToken({
|
||||
token: body.token,
|
||||
clientId: body.client_id,
|
||||
clientSecret: body.client_secret,
|
||||
});
|
||||
|
||||
// RFC 7009 §2.2: always return 200, even for invalid tokens
|
||||
return {};
|
||||
}
|
||||
|
||||
@Post('introspect')
|
||||
@HttpCode(200)
|
||||
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
|
||||
@UsePipes(new ValidationPipe())
|
||||
async introspect(
|
||||
@Body() body: OAuthIntrospectInput,
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
if (await this.applyRateLimit(req, res)) return;
|
||||
this.setSecurityHeaders(res);
|
||||
|
||||
if (!body.client_id) {
|
||||
res.status(401);
|
||||
|
||||
return {
|
||||
error: 'invalid_client',
|
||||
error_description: 'client_id is required',
|
||||
};
|
||||
}
|
||||
|
||||
return this.oauthService.introspectToken({
|
||||
token: body.token,
|
||||
clientId: body.client_id,
|
||||
clientSecret: body.client_secret,
|
||||
});
|
||||
}
|
||||
|
||||
private async applyRateLimit(req: Request, res: Response): Promise<boolean> {
|
||||
const rateLimitKey = `oauth:${req.ip}`;
|
||||
|
||||
try {
|
||||
await this.throttlerService.tokenBucketThrottleOrThrow(
|
||||
rateLimitKey,
|
||||
1,
|
||||
OAUTH_RATE_LIMIT_MAX,
|
||||
OAUTH_RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
if (error instanceof ThrottlerException) {
|
||||
res.status(429).json({
|
||||
error: 'rate_limit_exceeded',
|
||||
error_description: 'Too many requests, please try again later',
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private setSecurityHeaders(res: Response): void {
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.set('Pragma', 'no-cache');
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { Field, Int, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('VersionDistributionEntry')
|
||||
export class VersionDistributionEntryDTO {
|
||||
@Field(() => String)
|
||||
version: string;
|
||||
|
||||
@Field(() => Int)
|
||||
count: number;
|
||||
}
|
||||
|
||||
@ObjectType('ApplicationRegistrationStats')
|
||||
export class ApplicationRegistrationStatsDTO {
|
||||
@Field(() => Int)
|
||||
activeInstalls: number;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
mostInstalledVersion: string | null;
|
||||
|
||||
@Field(() => [VersionDistributionEntryDTO])
|
||||
versionDistribution: VersionDistributionEntryDTO[];
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import {
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
@InputType()
|
||||
export class CreateApplicationRegistrationVariableInput {
|
||||
@Field()
|
||||
@IsUUID()
|
||||
applicationRegistrationId: string;
|
||||
|
||||
@Field()
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
key: string;
|
||||
|
||||
@Field()
|
||||
@IsString()
|
||||
@MaxLength(10000)
|
||||
value: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isSecret?: boolean;
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity';
|
||||
|
||||
@ObjectType('CreateApplicationRegistration')
|
||||
export class CreateApplicationRegistrationDTO {
|
||||
@Field(() => ApplicationRegistrationEntity)
|
||||
applicationRegistration: ApplicationRegistrationEntity;
|
||||
|
||||
@Field()
|
||||
clientSecret: string;
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
@InputType()
|
||||
export class CreateApplicationRegistrationInput {
|
||||
@Field()
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
name: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@MaxLength(2048)
|
||||
@IsOptional()
|
||||
logoUrl?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
@IsOptional()
|
||||
author?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
universalIdentifier?: string;
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
@IsArray()
|
||||
@ArrayMaxSize(20)
|
||||
@IsString({ each: true })
|
||||
@MaxLength(2048, { each: true })
|
||||
@IsOptional()
|
||||
oAuthRedirectUris?: string[];
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@IsString({ each: true })
|
||||
@MaxLength(256, { each: true })
|
||||
@IsOptional()
|
||||
oAuthScopes?: string[];
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@MaxLength(2048)
|
||||
@IsOptional()
|
||||
websiteUrl?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@MaxLength(2048)
|
||||
@IsOptional()
|
||||
termsUrl?: string;
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class OAuthIntrospectInput {
|
||||
@IsString()
|
||||
@MaxLength(4096)
|
||||
token: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
token_type_hint?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
client_id?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
client_secret?: string;
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class OAuthRevokeInput {
|
||||
@IsString()
|
||||
@MaxLength(4096)
|
||||
token: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
token_type_hint?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
client_id?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
client_secret?: string;
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class OAuthTokenInput {
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
grant_type: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
code?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2048)
|
||||
redirect_uri?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
client_id?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
client_secret?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(512)
|
||||
code_verifier?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2048)
|
||||
refresh_token?: string;
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@ObjectType('PublicApplicationRegistration')
|
||||
export class PublicApplicationRegistrationDTO {
|
||||
@Field(() => UUIDScalarType)
|
||||
id: string;
|
||||
|
||||
@Field()
|
||||
name: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
logoUrl: string | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
websiteUrl: string | null;
|
||||
|
||||
@Field(() => [String])
|
||||
oAuthScopes: string[];
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType('RotateClientSecret')
|
||||
export class RotateClientSecretDTO {
|
||||
@Field()
|
||||
clientSecret: string;
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
@InputType()
|
||||
export class UpdateApplicationRegistrationVariablePayload {
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@MaxLength(10000)
|
||||
@IsOptional()
|
||||
value?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class UpdateApplicationRegistrationVariableInput {
|
||||
@IsNotEmpty()
|
||||
@Field()
|
||||
@IsUUID()
|
||||
id: string;
|
||||
|
||||
@Type(() => UpdateApplicationRegistrationVariablePayload)
|
||||
@ValidateNested()
|
||||
@Field(() => UpdateApplicationRegistrationVariablePayload)
|
||||
update: UpdateApplicationRegistrationVariablePayload;
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
@InputType()
|
||||
export class UpdateApplicationRegistrationPayload {
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
@IsOptional()
|
||||
description?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@MaxLength(2048)
|
||||
@IsOptional()
|
||||
logoUrl?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
@IsOptional()
|
||||
author?: string;
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
@IsArray()
|
||||
@ArrayMaxSize(20)
|
||||
@IsString({ each: true })
|
||||
@MaxLength(2048, { each: true })
|
||||
@IsOptional()
|
||||
oAuthRedirectUris?: string[];
|
||||
|
||||
@Field(() => [String], { nullable: true })
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@IsString({ each: true })
|
||||
@MaxLength(256, { each: true })
|
||||
@IsOptional()
|
||||
oAuthScopes?: string[];
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@MaxLength(2048)
|
||||
@IsOptional()
|
||||
websiteUrl?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@IsString()
|
||||
@MaxLength(2048)
|
||||
@IsOptional()
|
||||
termsUrl?: string;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class UpdateApplicationRegistrationInput {
|
||||
@IsNotEmpty()
|
||||
@Field()
|
||||
@IsUUID()
|
||||
id: string;
|
||||
|
||||
@Type(() => UpdateApplicationRegistrationPayload)
|
||||
@ValidateNested()
|
||||
@Field(() => UpdateApplicationRegistrationPayload)
|
||||
update: UpdateApplicationRegistrationPayload;
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
export enum AppRegistrationSourceType {
|
||||
NPM = 'npm',
|
||||
TARBALL = 'tarball',
|
||||
LOCAL = 'local',
|
||||
}
|
||||
|
||||
registerEnumType(AppRegistrationSourceType, {
|
||||
name: 'AppRegistrationSourceType',
|
||||
});
|
||||
+615
@@ -0,0 +1,615 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
import ms from 'ms';
|
||||
import { Repository } from 'typeorm';
|
||||
import { base64UrlEncode } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
AppTokenEntity,
|
||||
AppTokenType,
|
||||
} from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
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 { 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';
|
||||
|
||||
@Injectable()
|
||||
export class OAuthService {
|
||||
private readonly logger = new Logger(OAuthService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AppTokenEntity)
|
||||
private readonly appTokenRepository: Repository<AppTokenEntity>,
|
||||
@InjectRepository(ApplicationEntity)
|
||||
private readonly applicationRepository: Repository<ApplicationEntity>,
|
||||
@InjectRepository(UserWorkspaceEntity)
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
private readonly applicationTokenService: ApplicationTokenService,
|
||||
private readonly applicationRegistrationService: ApplicationRegistrationService,
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly applicationInstallService: ApplicationInstallService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
async exchangeAuthorizationCode(params: {
|
||||
authorizationCode: string;
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
codeVerifier?: string;
|
||||
redirectUri: string;
|
||||
}): Promise<OAuthTokenResponse | OAuthErrorResponse> {
|
||||
const {
|
||||
authorizationCode,
|
||||
clientId,
|
||||
clientSecret,
|
||||
codeVerifier,
|
||||
redirectUri,
|
||||
} = params;
|
||||
|
||||
if (!authorizationCode) {
|
||||
return this.errorResponse(
|
||||
'invalid_request',
|
||||
'Authorization code is required',
|
||||
);
|
||||
}
|
||||
|
||||
const clientValidation = await this.validateClient(clientId);
|
||||
|
||||
if ('error' in clientValidation) {
|
||||
return clientValidation;
|
||||
}
|
||||
|
||||
const applicationRegistration = clientValidation;
|
||||
|
||||
if (clientSecret) {
|
||||
const secretError = await this.validateClientSecret(
|
||||
applicationRegistration,
|
||||
clientSecret,
|
||||
);
|
||||
|
||||
if (secretError) {
|
||||
return secretError;
|
||||
}
|
||||
}
|
||||
|
||||
const hashedAuthorizationCode = crypto
|
||||
.createHash('sha256')
|
||||
.update(authorizationCode)
|
||||
.digest('hex');
|
||||
|
||||
const authCodeToken = await this.appTokenRepository.findOne({
|
||||
where: {
|
||||
value: hashedAuthorizationCode,
|
||||
type: AppTokenType.AuthorizationCode,
|
||||
},
|
||||
});
|
||||
|
||||
if (!authCodeToken) {
|
||||
return this.errorResponse(
|
||||
'invalid_grant',
|
||||
'Authorization code not found',
|
||||
);
|
||||
}
|
||||
|
||||
// RFC 6749 §4.1.2: if a previously used code is presented, this indicates
|
||||
// a potential compromise — log a security warning
|
||||
if (authCodeToken.revokedAt) {
|
||||
this.logger.warn(
|
||||
`Authorization code replay detected for client ${clientId}. ` +
|
||||
`Code was already used at ${authCodeToken.revokedAt.toISOString()}.`,
|
||||
);
|
||||
|
||||
return this.errorResponse(
|
||||
'invalid_grant',
|
||||
'Authorization code has already been used',
|
||||
);
|
||||
}
|
||||
|
||||
if (authCodeToken.expiresAt.getTime() < Date.now()) {
|
||||
return this.errorResponse('invalid_grant', 'Authorization code expired');
|
||||
}
|
||||
|
||||
// RFC 6749 §4.1.3: auth code must have been issued to this client
|
||||
const storedClientId = authCodeToken.context?.clientId;
|
||||
|
||||
if (!storedClientId || storedClientId !== clientId) {
|
||||
return this.errorResponse(
|
||||
'invalid_grant',
|
||||
'Authorization code was not issued to this client',
|
||||
);
|
||||
}
|
||||
|
||||
// RFC 6749 §4.1.3: redirect_uri must match the one used in the authorization request
|
||||
const storedRedirectUri = authCodeToken.context?.redirectUri;
|
||||
|
||||
if (storedRedirectUri) {
|
||||
if (!redirectUri) {
|
||||
return this.errorResponse(
|
||||
'invalid_request',
|
||||
'redirect_uri is required',
|
||||
);
|
||||
}
|
||||
|
||||
if (redirectUri !== storedRedirectUri) {
|
||||
return this.errorResponse(
|
||||
'invalid_grant',
|
||||
'redirect_uri does not match the one used in the authorization request',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// PKCE: if code_challenge was stored, code_verifier is required
|
||||
const storedCodeChallenge = authCodeToken.context?.codeChallenge;
|
||||
|
||||
if (storedCodeChallenge) {
|
||||
if (!codeVerifier) {
|
||||
return this.errorResponse(
|
||||
'invalid_request',
|
||||
'code_verifier is required (PKCE was used in authorization)',
|
||||
);
|
||||
}
|
||||
|
||||
const computedChallenge = base64UrlEncode(
|
||||
crypto.createHash('sha256').update(codeVerifier).digest(),
|
||||
);
|
||||
|
||||
if (computedChallenge !== storedCodeChallenge) {
|
||||
return this.errorResponse(
|
||||
'invalid_grant',
|
||||
'Code verifier does not match the code challenge',
|
||||
);
|
||||
}
|
||||
} else if (codeVerifier) {
|
||||
return this.errorResponse(
|
||||
'invalid_request',
|
||||
'code_verifier provided but no code_challenge was used in authorization',
|
||||
);
|
||||
}
|
||||
|
||||
if (!clientSecret && !storedCodeChallenge) {
|
||||
return this.errorResponse(
|
||||
'invalid_request',
|
||||
'Either client_secret or code_verifier (PKCE) is required',
|
||||
);
|
||||
}
|
||||
|
||||
await this.appTokenRepository.update(authCodeToken.id, {
|
||||
revokedAt: new Date(),
|
||||
});
|
||||
|
||||
if (!authCodeToken.userId || !authCodeToken.workspaceId) {
|
||||
return this.errorResponse(
|
||||
'server_error',
|
||||
'Authorization code is missing user or workspace context',
|
||||
);
|
||||
}
|
||||
|
||||
const application = await this.findOrInstallApplication(
|
||||
applicationRegistration,
|
||||
authCodeToken.workspaceId,
|
||||
);
|
||||
|
||||
const userWorkspace = await this.userWorkspaceRepository.findOne({
|
||||
where: {
|
||||
userId: authCodeToken.userId,
|
||||
workspaceId: authCodeToken.workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userWorkspace) {
|
||||
return this.errorResponse(
|
||||
'invalid_grant',
|
||||
'User no longer has access to this workspace',
|
||||
);
|
||||
}
|
||||
|
||||
const { applicationAccessToken, applicationRefreshToken } =
|
||||
await this.applicationTokenService.generateApplicationTokenPair({
|
||||
workspaceId: authCodeToken.workspaceId,
|
||||
applicationId: application.id,
|
||||
userId: authCodeToken.userId,
|
||||
userWorkspaceId: userWorkspace.id,
|
||||
});
|
||||
|
||||
const grantedScope =
|
||||
authCodeToken.context?.scope ??
|
||||
applicationRegistration.oAuthScopes.join(' ');
|
||||
|
||||
this.logger.log(
|
||||
`Authorization code exchanged: client=${clientId} workspace=${authCodeToken.workspaceId} user=${authCodeToken.userId}`,
|
||||
);
|
||||
|
||||
return {
|
||||
access_token: applicationAccessToken.token,
|
||||
token_type: 'Bearer',
|
||||
expires_in: this.getAccessTokenExpiresInSeconds(),
|
||||
refresh_token: applicationRefreshToken.token,
|
||||
scope: grantedScope,
|
||||
};
|
||||
}
|
||||
|
||||
async clientCredentialsGrant(params: {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}): Promise<OAuthTokenResponse | OAuthErrorResponse> {
|
||||
const { clientId, clientSecret } = params;
|
||||
|
||||
const clientValidation = await this.validateClient(clientId);
|
||||
|
||||
if ('error' in clientValidation) {
|
||||
return clientValidation;
|
||||
}
|
||||
|
||||
const applicationRegistration = clientValidation;
|
||||
|
||||
const secretError = await this.validateClientSecret(
|
||||
applicationRegistration,
|
||||
clientSecret,
|
||||
);
|
||||
|
||||
if (secretError) {
|
||||
return secretError;
|
||||
}
|
||||
|
||||
const applications = await this.applicationRepository.find({
|
||||
where: { applicationRegistrationId: applicationRegistration.id },
|
||||
});
|
||||
|
||||
if (applications.length === 0) {
|
||||
return this.errorResponse(
|
||||
'server_error',
|
||||
'No workspace installation found for this client. Install the app in a workspace first.',
|
||||
);
|
||||
}
|
||||
|
||||
if (applications.length > 1) {
|
||||
return this.errorResponse(
|
||||
'invalid_request',
|
||||
'Multiple workspace installations found. Client credentials grant requires exactly one installation.',
|
||||
);
|
||||
}
|
||||
|
||||
const application = applications[0];
|
||||
|
||||
const applicationAccessToken =
|
||||
await this.applicationTokenService.generateApplicationAccessToken({
|
||||
workspaceId: application.workspaceId,
|
||||
applicationId: application.id,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Client credentials token issued: client=${clientId} workspace=${application.workspaceId}`,
|
||||
);
|
||||
|
||||
return {
|
||||
access_token: applicationAccessToken.token,
|
||||
token_type: 'Bearer',
|
||||
expires_in: this.getAccessTokenExpiresInSeconds(),
|
||||
scope: applicationRegistration.oAuthScopes.join(' '),
|
||||
};
|
||||
}
|
||||
|
||||
async refreshTokenGrant(params: {
|
||||
refreshToken: string;
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
}): Promise<OAuthTokenResponse | OAuthErrorResponse> {
|
||||
const { refreshToken, clientId, clientSecret } = params;
|
||||
|
||||
const clientValidation = await this.validateClient(clientId);
|
||||
|
||||
if ('error' in clientValidation) {
|
||||
return clientValidation;
|
||||
}
|
||||
|
||||
const applicationRegistration = clientValidation;
|
||||
|
||||
// Confidential clients (those with a secret) must authenticate
|
||||
if (applicationRegistration.oAuthClientSecretHash && !clientSecret) {
|
||||
return this.errorResponse(
|
||||
'invalid_client',
|
||||
'Client authentication required for confidential clients',
|
||||
);
|
||||
}
|
||||
|
||||
if (clientSecret) {
|
||||
const secretError = await this.validateClientSecret(
|
||||
applicationRegistration,
|
||||
clientSecret,
|
||||
);
|
||||
|
||||
if (secretError) {
|
||||
return secretError;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const payload =
|
||||
this.applicationTokenService.validateApplicationRefreshToken(
|
||||
refreshToken,
|
||||
);
|
||||
|
||||
// Verify the refresh token belongs to this client
|
||||
const application = await this.applicationRepository.findOne({
|
||||
where: { id: payload.applicationId },
|
||||
});
|
||||
|
||||
if (
|
||||
!application ||
|
||||
application.applicationRegistrationId !== applicationRegistration.id
|
||||
) {
|
||||
return this.errorResponse(
|
||||
'invalid_grant',
|
||||
'Refresh token was not issued to this client',
|
||||
);
|
||||
}
|
||||
|
||||
const { applicationAccessToken, applicationRefreshToken } =
|
||||
await this.applicationTokenService.renewApplicationTokens(payload);
|
||||
|
||||
this.logger.log(
|
||||
`Refresh token exchanged: client=${clientId} application=${payload.applicationId}`,
|
||||
);
|
||||
|
||||
return {
|
||||
access_token: applicationAccessToken.token,
|
||||
token_type: 'Bearer',
|
||||
expires_in: this.getAccessTokenExpiresInSeconds(),
|
||||
refresh_token: applicationRefreshToken.token,
|
||||
scope: applicationRegistration.oAuthScopes.join(' '),
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.warn(`Refresh token grant failed: client=${clientId}`, error);
|
||||
|
||||
return this.errorResponse(
|
||||
'invalid_grant',
|
||||
'Invalid or expired refresh token',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// RFC 7009: Token revocation
|
||||
// Returns true if token was successfully processed (even if already invalid)
|
||||
async revokeToken(params: {
|
||||
token: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
}): Promise<{ success: boolean }> {
|
||||
const { token, clientId, clientSecret } = params;
|
||||
|
||||
if (clientId) {
|
||||
const clientValidation = await this.validateClient(clientId);
|
||||
|
||||
if ('error' in clientValidation) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
if (clientSecret) {
|
||||
const secretError = await this.validateClientSecret(
|
||||
clientValidation,
|
||||
clientSecret,
|
||||
);
|
||||
|
||||
if (secretError) {
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Since our tokens are stateless JWTs, we can't truly revoke them.
|
||||
// We validate the token to log that revocation was requested.
|
||||
try {
|
||||
const payload =
|
||||
this.applicationTokenService.validateApplicationRefreshToken(token);
|
||||
|
||||
this.logger.log(
|
||||
`Token revocation requested for application ${payload.applicationId}`,
|
||||
);
|
||||
} catch {
|
||||
// Per RFC 7009 §2.2: the server responds with HTTP 200 for both
|
||||
// valid and invalid tokens
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// RFC 7662: Token introspection
|
||||
async introspectToken(params: {
|
||||
token: string;
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
const { token, clientId, clientSecret } = params;
|
||||
|
||||
const clientValidation = await this.validateClient(clientId);
|
||||
|
||||
if ('error' in clientValidation) {
|
||||
return { active: false };
|
||||
}
|
||||
|
||||
if (clientSecret) {
|
||||
const secretError = await this.validateClientSecret(
|
||||
clientValidation,
|
||||
clientSecret,
|
||||
);
|
||||
|
||||
if (secretError) {
|
||||
return { active: false };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
this.applicationTokenService.validateApplicationRefreshToken(token);
|
||||
|
||||
const decoded = this.applicationTokenService.decodeToken(token);
|
||||
|
||||
if (!decoded) {
|
||||
return { active: false };
|
||||
}
|
||||
|
||||
// Verify the token belongs to this client
|
||||
const application = await this.applicationRepository.findOne({
|
||||
where: { id: decoded.applicationId },
|
||||
});
|
||||
|
||||
if (
|
||||
!application ||
|
||||
application.applicationRegistrationId !== clientValidation.id
|
||||
) {
|
||||
return { active: false };
|
||||
}
|
||||
|
||||
return {
|
||||
active: true,
|
||||
sub: decoded.sub,
|
||||
client_id: clientId,
|
||||
token_type: 'Bearer',
|
||||
scope: clientValidation.oAuthScopes.join(' '),
|
||||
aud: decoded.workspaceId,
|
||||
iss: this.twentyConfigService.get('SERVER_URL'),
|
||||
exp: decoded.exp,
|
||||
iat: decoded.iat,
|
||||
};
|
||||
} catch {
|
||||
// Try as access token (with signature verification)
|
||||
try {
|
||||
const payload =
|
||||
this.applicationTokenService.validateApplicationAccessToken(token);
|
||||
|
||||
const application = await this.applicationRepository.findOne({
|
||||
where: { id: payload.applicationId },
|
||||
});
|
||||
|
||||
if (
|
||||
!application ||
|
||||
application.applicationRegistrationId !== clientValidation.id
|
||||
) {
|
||||
return { active: false };
|
||||
}
|
||||
|
||||
return {
|
||||
active: true,
|
||||
sub: payload.sub,
|
||||
client_id: clientId,
|
||||
token_type: 'Bearer',
|
||||
scope: clientValidation.oAuthScopes.join(' '),
|
||||
aud: payload.workspaceId,
|
||||
iss: this.twentyConfigService.get('SERVER_URL'),
|
||||
};
|
||||
} catch {
|
||||
return { active: false };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async validateClient(
|
||||
clientId: string,
|
||||
): Promise<ApplicationRegistrationEntity | OAuthErrorResponse> {
|
||||
const applicationRegistration =
|
||||
await this.applicationRegistrationService.findOneByClientId(clientId);
|
||||
|
||||
if (!applicationRegistration) {
|
||||
return this.errorResponse('invalid_client', 'Client not found');
|
||||
}
|
||||
|
||||
return applicationRegistration;
|
||||
}
|
||||
|
||||
private async validateClientSecret(
|
||||
applicationRegistration: ApplicationRegistrationEntity,
|
||||
clientSecret: string,
|
||||
): Promise<OAuthErrorResponse | null> {
|
||||
const isValid =
|
||||
await this.applicationRegistrationService.verifyClientSecret(
|
||||
applicationRegistration,
|
||||
clientSecret,
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
return this.errorResponse('invalid_client', 'Invalid client secret');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async findOrInstallApplication(
|
||||
applicationRegistration: ApplicationRegistrationEntity,
|
||||
workspaceId: string,
|
||||
): Promise<ApplicationEntity> {
|
||||
const existingApplication = await this.applicationRepository.findOne({
|
||||
where: {
|
||||
applicationRegistrationId: applicationRegistration.id,
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingApplication) {
|
||||
return existingApplication;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Auto-installing application "${applicationRegistration.name}" in workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
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,
|
||||
description: applicationRegistration.description,
|
||||
version: '0.0.0',
|
||||
sourcePath: 'oauth-install',
|
||||
applicationRegistrationId: applicationRegistration.id,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
// OAuth RFC 6749 requires expires_in as seconds
|
||||
private getAccessTokenExpiresInSeconds(): number {
|
||||
const duration = this.twentyConfigService.get(
|
||||
'APPLICATION_ACCESS_TOKEN_EXPIRES_IN',
|
||||
);
|
||||
|
||||
return Math.floor(ms(duration) / 1000);
|
||||
}
|
||||
|
||||
private errorResponse(
|
||||
error: string,
|
||||
errorDescription: string,
|
||||
): OAuthErrorResponse {
|
||||
return { error, error_description: errorDescription };
|
||||
}
|
||||
}
|
||||
+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
@@ -0,0 +1,4 @@
|
||||
export type OAuthErrorResponse = {
|
||||
error: string;
|
||||
error_description: string;
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export type OAuthTokenResponse = {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
refresh_token?: string;
|
||||
scope?: string;
|
||||
};
|
||||
+329
@@ -0,0 +1,329 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { type Repository } from 'typeorm';
|
||||
|
||||
import { ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
|
||||
import {
|
||||
ApplicationVariableEntityException,
|
||||
ApplicationVariableEntityExceptionCode,
|
||||
} 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';
|
||||
|
||||
describe('ApplicationVariableEntityService', () => {
|
||||
let service: ApplicationVariableEntityService;
|
||||
let repository: jest.Mocked<Repository<ApplicationVariableEntity>>;
|
||||
let secretEncryptionService: jest.Mocked<SecretEncryptionService>;
|
||||
let workspaceCacheService: jest.Mocked<WorkspaceCacheService>;
|
||||
|
||||
const mockWorkspaceId = 'workspace-123';
|
||||
const mockApplicationId = 'app-456';
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ApplicationVariableEntityService,
|
||||
{
|
||||
provide: getRepositoryToken(ApplicationVariableEntity),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
update: jest.fn(),
|
||||
save: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SecretEncryptionService,
|
||||
useValue: {
|
||||
encrypt: jest.fn((value: string) => `encrypted_${value}`),
|
||||
decrypt: jest.fn((value: string) =>
|
||||
value.replace('encrypted_', ''),
|
||||
),
|
||||
decryptAndMask: jest.fn(
|
||||
({
|
||||
value: _value,
|
||||
mask: _mask,
|
||||
}: {
|
||||
value: string;
|
||||
mask: string;
|
||||
}) => '********',
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: WorkspaceCacheService,
|
||||
useValue: {
|
||||
invalidateAndRecompute: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ApplicationVariableEntityService>(
|
||||
ApplicationVariableEntityService,
|
||||
);
|
||||
repository = module.get(getRepositoryToken(ApplicationVariableEntity));
|
||||
secretEncryptionService = module.get(SecretEncryptionService);
|
||||
workspaceCacheService = module.get(WorkspaceCacheService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should encrypt value when variable is secret', async () => {
|
||||
const existingVariable = {
|
||||
id: '1',
|
||||
key: 'API_KEY',
|
||||
value: 'old-encrypted-value',
|
||||
isSecret: true,
|
||||
applicationId: mockApplicationId,
|
||||
} as ApplicationVariableEntity;
|
||||
|
||||
repository.findOne.mockResolvedValue(existingVariable);
|
||||
repository.update.mockResolvedValue({ affected: 1 } as any);
|
||||
|
||||
await service.update({
|
||||
key: 'API_KEY',
|
||||
plainTextValue: 'new-secret-value',
|
||||
applicationId: mockApplicationId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
});
|
||||
|
||||
expect(secretEncryptionService.encrypt).toHaveBeenCalledWith(
|
||||
'new-secret-value',
|
||||
);
|
||||
expect(repository.update).toHaveBeenCalledWith(
|
||||
{ key: 'API_KEY', applicationId: mockApplicationId },
|
||||
{ value: 'encrypted_new-secret-value' },
|
||||
);
|
||||
expect(workspaceCacheService.invalidateAndRecompute).toHaveBeenCalledWith(
|
||||
mockWorkspaceId,
|
||||
['applicationVariableMaps'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should not encrypt value when variable is not secret', async () => {
|
||||
const existingVariable = {
|
||||
id: '1',
|
||||
key: 'PUBLIC_URL',
|
||||
value: 'https://old-url.com',
|
||||
isSecret: false,
|
||||
applicationId: mockApplicationId,
|
||||
} as ApplicationVariableEntity;
|
||||
|
||||
repository.findOne.mockResolvedValue(existingVariable);
|
||||
repository.update.mockResolvedValue({ affected: 1 } as any);
|
||||
|
||||
await service.update({
|
||||
key: 'PUBLIC_URL',
|
||||
plainTextValue: 'https://new-url.com',
|
||||
applicationId: mockApplicationId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
});
|
||||
|
||||
expect(secretEncryptionService.encrypt).not.toHaveBeenCalled();
|
||||
expect(repository.update).toHaveBeenCalledWith(
|
||||
{ key: 'PUBLIC_URL', applicationId: mockApplicationId },
|
||||
{ value: 'https://new-url.com' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw exception when variable not found', async () => {
|
||||
repository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.update({
|
||||
key: 'NON_EXISTENT',
|
||||
plainTextValue: 'some-value',
|
||||
applicationId: mockApplicationId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
}),
|
||||
).rejects.toThrow(ApplicationVariableEntityException);
|
||||
|
||||
await expect(
|
||||
service.update({
|
||||
key: 'NON_EXISTENT',
|
||||
plainTextValue: 'some-value',
|
||||
applicationId: mockApplicationId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: ApplicationVariableEntityExceptionCode.APPLICATION_VARIABLE_NOT_FOUND,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertManyApplicationVariableEntities', () => {
|
||||
it('should encrypt secret values when creating new variables', async () => {
|
||||
repository.findOne.mockResolvedValue(null);
|
||||
repository.save.mockResolvedValue({} as any);
|
||||
repository.delete.mockResolvedValue({ affected: 0 } as any);
|
||||
|
||||
await service.upsertManyApplicationVariableEntities({
|
||||
applicationVariables: {
|
||||
SECRET_KEY: {
|
||||
universalIdentifier: 'secret-key-123',
|
||||
value: 'my-secret',
|
||||
description: 'A secret key',
|
||||
isSecret: true,
|
||||
},
|
||||
},
|
||||
applicationId: mockApplicationId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
});
|
||||
|
||||
expect(secretEncryptionService.encrypt).toHaveBeenCalledWith('my-secret');
|
||||
expect(repository.save).toHaveBeenCalledWith({
|
||||
key: 'SECRET_KEY',
|
||||
value: 'encrypted_my-secret',
|
||||
description: 'A secret key',
|
||||
isSecret: true,
|
||||
applicationId: mockApplicationId,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not encrypt non-secret values when creating new variables', async () => {
|
||||
repository.findOne.mockResolvedValue(null);
|
||||
repository.save.mockResolvedValue({} as any);
|
||||
repository.delete.mockResolvedValue({ affected: 0 } as any);
|
||||
|
||||
await service.upsertManyApplicationVariableEntities({
|
||||
applicationVariables: {
|
||||
PUBLIC_URL: {
|
||||
universalIdentifier: 'public-url-123',
|
||||
value: 'https://example.com',
|
||||
description: 'Public URL',
|
||||
isSecret: false,
|
||||
},
|
||||
},
|
||||
applicationId: mockApplicationId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
});
|
||||
|
||||
expect(secretEncryptionService.encrypt).not.toHaveBeenCalled();
|
||||
expect(repository.save).toHaveBeenCalledWith({
|
||||
key: 'PUBLIC_URL',
|
||||
value: 'https://example.com',
|
||||
description: 'Public URL',
|
||||
isSecret: false,
|
||||
applicationId: mockApplicationId,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle undefined isSecret as false', async () => {
|
||||
repository.findOne.mockResolvedValue(null);
|
||||
repository.save.mockResolvedValue({} as any);
|
||||
repository.delete.mockResolvedValue({ affected: 0 } as any);
|
||||
|
||||
await service.upsertManyApplicationVariableEntities({
|
||||
applicationVariables: {
|
||||
SOME_VAR: {
|
||||
universalIdentifier: 'some-var-123',
|
||||
value: 'some-value',
|
||||
description: 'Some variable',
|
||||
},
|
||||
},
|
||||
applicationId: mockApplicationId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
});
|
||||
|
||||
expect(secretEncryptionService.encrypt).not.toHaveBeenCalled();
|
||||
expect(repository.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
isSecret: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update existing variables without changing values', async () => {
|
||||
const existingVariable = {
|
||||
id: '1',
|
||||
key: 'EXISTING_VAR',
|
||||
value: 'existing-encrypted-value',
|
||||
isSecret: true,
|
||||
applicationId: mockApplicationId,
|
||||
} as ApplicationVariableEntity;
|
||||
|
||||
repository.findOne.mockResolvedValue(existingVariable);
|
||||
repository.update.mockResolvedValue({ affected: 1 } as any);
|
||||
repository.delete.mockResolvedValue({ affected: 0 } as any);
|
||||
|
||||
await service.upsertManyApplicationVariableEntities({
|
||||
applicationVariables: {
|
||||
EXISTING_VAR: {
|
||||
universalIdentifier: 'existing-var-123',
|
||||
value: 'new-value',
|
||||
description: 'Updated description',
|
||||
isSecret: true,
|
||||
},
|
||||
},
|
||||
applicationId: mockApplicationId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
});
|
||||
|
||||
expect(repository.update).toHaveBeenCalledWith(
|
||||
{ key: 'EXISTING_VAR', applicationId: mockApplicationId },
|
||||
{
|
||||
description: 'Updated description',
|
||||
isSecret: true,
|
||||
value: 'encrypted_new-value',
|
||||
},
|
||||
);
|
||||
expect(repository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle undefined applicationVariables', async () => {
|
||||
await service.upsertManyApplicationVariableEntities({
|
||||
applicationVariables: undefined,
|
||||
applicationId: mockApplicationId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
});
|
||||
|
||||
expect(repository.findOne).not.toHaveBeenCalled();
|
||||
expect(repository.save).not.toHaveBeenCalled();
|
||||
expect(repository.update).not.toHaveBeenCalled();
|
||||
expect(
|
||||
workspaceCacheService.invalidateAndRecompute,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDisplayValue', () => {
|
||||
it('should return plain value for non-secret variables', () => {
|
||||
const variable = {
|
||||
id: '1',
|
||||
key: 'PUBLIC_URL',
|
||||
value: 'https://example.com',
|
||||
isSecret: false,
|
||||
applicationId: mockApplicationId,
|
||||
} as ApplicationVariableEntity;
|
||||
|
||||
const result = service.getDisplayValue(variable);
|
||||
|
||||
expect(result).toBe('https://example.com');
|
||||
expect(secretEncryptionService.decryptAndMask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call decryptAndMask for secret variables', () => {
|
||||
const variable = {
|
||||
id: '1',
|
||||
key: 'SECRET_KEY',
|
||||
value: 'encrypted_value',
|
||||
isSecret: true,
|
||||
applicationId: mockApplicationId,
|
||||
} as ApplicationVariableEntity;
|
||||
|
||||
service.getDisplayValue(variable);
|
||||
|
||||
expect(secretEncryptionService.decryptAndMask).toHaveBeenCalledWith({
|
||||
value: 'encrypted_value',
|
||||
mask: SECRET_APPLICATION_VARIABLE_MASK,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { Catch, ExceptionFilter } from '@nestjs/common';
|
||||
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
ApplicationVariableEntityException,
|
||||
ApplicationVariableEntityExceptionCode,
|
||||
} 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)
|
||||
export class ApplicationVariableEntityExceptionFilter
|
||||
implements ExceptionFilter
|
||||
{
|
||||
catch(exception: ApplicationVariableEntityException) {
|
||||
switch (exception.code) {
|
||||
case ApplicationVariableEntityExceptionCode.APPLICATION_VARIABLE_NOT_FOUND:
|
||||
throw new NotFoundError(exception);
|
||||
default:
|
||||
assertUnreachable(exception.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import { ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { IDField } from '@ptc-org/nestjs-query-graphql';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
|
||||
import { EntityRelation } from 'src/engine/workspace-manager/workspace-migration/types/entity-relation.interface';
|
||||
|
||||
@Entity({
|
||||
name: 'applicationVariable',
|
||||
schema: 'core',
|
||||
})
|
||||
@ObjectType('ApplicationVariable')
|
||||
@Unique('IDX_APPLICATION_VARIABLE_KEY_APPLICATION_ID_UNIQUE', [
|
||||
'key',
|
||||
'applicationId',
|
||||
])
|
||||
export class ApplicationVariableEntity {
|
||||
@IDField(() => UUIDScalarType)
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ nullable: false, type: 'text' })
|
||||
key: string;
|
||||
|
||||
@Column({ nullable: false, type: 'text', default: '' })
|
||||
value: string;
|
||||
|
||||
@Column({ nullable: false, type: 'text', default: '' })
|
||||
description: string;
|
||||
|
||||
@Column({ nullable: false, type: 'boolean', default: false })
|
||||
isSecret: boolean;
|
||||
|
||||
@Column({ nullable: true, type: 'uuid' })
|
||||
applicationId?: string;
|
||||
|
||||
@ManyToOne(
|
||||
() => ApplicationEntity,
|
||||
(application) => application.applicationVariables,
|
||||
{
|
||||
onDelete: 'CASCADE',
|
||||
nullable: true,
|
||||
},
|
||||
)
|
||||
@JoinColumn({ name: 'applicationId' })
|
||||
application: EntityRelation<ApplicationEntity> | null;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export enum ApplicationVariableEntityExceptionCode {
|
||||
APPLICATION_VARIABLE_NOT_FOUND = 'APPLICATION_VARIABLE_NOT_FOUND',
|
||||
}
|
||||
|
||||
const getApplicationVariableEntityExceptionUserFriendlyMessage = (
|
||||
code: ApplicationVariableEntityExceptionCode,
|
||||
) => {
|
||||
switch (code) {
|
||||
case ApplicationVariableEntityExceptionCode.APPLICATION_VARIABLE_NOT_FOUND:
|
||||
return msg`Application variable not found.`;
|
||||
default:
|
||||
assertUnreachable(code);
|
||||
}
|
||||
};
|
||||
|
||||
export class ApplicationVariableEntityException extends CustomException<ApplicationVariableEntityExceptionCode> {
|
||||
constructor(
|
||||
message: string,
|
||||
code: ApplicationVariableEntityExceptionCode,
|
||||
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
|
||||
) {
|
||||
super(message, code, {
|
||||
userFriendlyMessage:
|
||||
userFriendlyMessage ??
|
||||
getApplicationVariableEntityExceptionUserFriendlyMessage(code),
|
||||
});
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
|
||||
|
||||
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';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
NestjsQueryTypeOrmModule.forFeature([ApplicationVariableEntity]),
|
||||
TypeOrmModule.forFeature([ApplicationVariableEntity]),
|
||||
PermissionsModule,
|
||||
WorkspaceCacheModule,
|
||||
SecretEncryptionModule,
|
||||
],
|
||||
providers: [
|
||||
ApplicationVariableEntityService,
|
||||
ApplicationVariableEntityResolver,
|
||||
WorkspaceApplicationVariableMapCacheService,
|
||||
],
|
||||
exports: [
|
||||
ApplicationVariableEntityService,
|
||||
WorkspaceApplicationVariableMapCacheService,
|
||||
],
|
||||
})
|
||||
export class ApplicationVariableEntityModule {}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { UseFilters, UseGuards } from '@nestjs/common';
|
||||
import { Args, Mutation, Parent, ResolveField } from '@nestjs/graphql';
|
||||
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
|
||||
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';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.APPLICATIONS),
|
||||
)
|
||||
@MetadataResolver(() => ApplicationVariableEntityDTO)
|
||||
@UseFilters(ApplicationVariableEntityExceptionFilter)
|
||||
export class ApplicationVariableEntityResolver {
|
||||
constructor(
|
||||
private readonly applicationVariableService: ApplicationVariableEntityService,
|
||||
) {}
|
||||
|
||||
@ResolveField(() => String)
|
||||
value(@Parent() applicationVariable: ApplicationVariableEntity): string {
|
||||
return this.applicationVariableService.getDisplayValue(applicationVariable);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async updateOneApplicationVariable(
|
||||
@Args() { key, value, applicationId }: UpdateApplicationVariableEntityInput,
|
||||
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
|
||||
) {
|
||||
await this.applicationVariableService.update({
|
||||
key,
|
||||
plainTextValue: value,
|
||||
applicationId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
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/application/application-variable/application-variable.entity';
|
||||
import {
|
||||
ApplicationVariableEntityException,
|
||||
ApplicationVariableEntityExceptionCode,
|
||||
} 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';
|
||||
|
||||
@Injectable()
|
||||
export class ApplicationVariableEntityService {
|
||||
constructor(
|
||||
@InjectRepository(ApplicationVariableEntity)
|
||||
private readonly applicationVariableRepository: Repository<ApplicationVariableEntity>,
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly secretEncryptionService: SecretEncryptionService,
|
||||
) {}
|
||||
|
||||
private encryptSecretValue(value: string, isSecret: boolean): string {
|
||||
if (!isSecret) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return this.secretEncryptionService.encrypt(value);
|
||||
}
|
||||
|
||||
getDisplayValue(applicationVariable: ApplicationVariableEntity): string {
|
||||
if (!applicationVariable.isSecret) {
|
||||
return applicationVariable.value;
|
||||
}
|
||||
|
||||
return this.secretEncryptionService.decryptAndMask({
|
||||
value: applicationVariable.value,
|
||||
mask: SECRET_APPLICATION_VARIABLE_MASK,
|
||||
});
|
||||
}
|
||||
|
||||
async update({
|
||||
key,
|
||||
plainTextValue,
|
||||
applicationId,
|
||||
workspaceId,
|
||||
}: Pick<ApplicationVariableEntity, 'key'> & {
|
||||
applicationId: string;
|
||||
workspaceId: string;
|
||||
plainTextValue: string;
|
||||
}) {
|
||||
const existingVariable = await this.applicationVariableRepository.findOne({
|
||||
where: { key, applicationId },
|
||||
});
|
||||
|
||||
if (!isDefined(existingVariable)) {
|
||||
throw new ApplicationVariableEntityException(
|
||||
`Application variable with key ${key} not found`,
|
||||
ApplicationVariableEntityExceptionCode.APPLICATION_VARIABLE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const encryptedValue = this.encryptSecretValue(
|
||||
plainTextValue,
|
||||
existingVariable.isSecret,
|
||||
);
|
||||
|
||||
await this.applicationVariableRepository.update(
|
||||
{ key, applicationId },
|
||||
{
|
||||
value: encryptedValue,
|
||||
},
|
||||
);
|
||||
|
||||
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
|
||||
'applicationVariableMaps',
|
||||
]);
|
||||
}
|
||||
|
||||
async upsertManyApplicationVariableEntities({
|
||||
applicationVariables,
|
||||
applicationId,
|
||||
workspaceId,
|
||||
}: {
|
||||
applicationVariables?: ApplicationVariables;
|
||||
applicationId: string;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
if (!isDefined(applicationVariables)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, { value, description, isSecret }] of Object.entries(
|
||||
applicationVariables,
|
||||
)) {
|
||||
const isSecretValue = isSecret ?? false;
|
||||
const encryptedValue = this.encryptSecretValue(
|
||||
value ?? '',
|
||||
isSecretValue,
|
||||
);
|
||||
|
||||
if (
|
||||
await this.applicationVariableRepository.findOne({
|
||||
where: {
|
||||
key,
|
||||
applicationId,
|
||||
},
|
||||
})
|
||||
) {
|
||||
await this.applicationVariableRepository.update(
|
||||
{
|
||||
key,
|
||||
applicationId,
|
||||
},
|
||||
{
|
||||
value: encryptedValue,
|
||||
description: description ?? '',
|
||||
isSecret: isSecretValue,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await this.applicationVariableRepository.save({
|
||||
key,
|
||||
value: encryptedValue,
|
||||
description: description ?? '',
|
||||
isSecret: isSecretValue,
|
||||
applicationId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.applicationVariableRepository.delete({
|
||||
applicationId,
|
||||
key: Not(In(Object.keys(applicationVariables))),
|
||||
});
|
||||
|
||||
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
|
||||
'applicationVariableMaps',
|
||||
]);
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const SECRET_APPLICATION_VARIABLE_MASK = '********';
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { IsBoolean, IsString } from 'class-validator';
|
||||
import { IDField } from '@ptc-org/nestjs-query-graphql';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@ObjectType('ApplicationVariable')
|
||||
export class ApplicationVariableEntityDTO {
|
||||
@IDField(() => UUIDScalarType)
|
||||
id: string;
|
||||
|
||||
@IsString()
|
||||
@Field()
|
||||
key: string;
|
||||
|
||||
@IsString()
|
||||
@Field()
|
||||
value: string;
|
||||
|
||||
@IsString()
|
||||
@Field()
|
||||
description: string;
|
||||
|
||||
@IsBoolean()
|
||||
@Field()
|
||||
isSecret: boolean;
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { ArgsType, Field } from '@nestjs/graphql';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@ArgsType()
|
||||
export class UpdateApplicationVariableEntityInput {
|
||||
@Field(() => String, { nullable: false })
|
||||
key: string;
|
||||
|
||||
@Field(() => String, { nullable: false })
|
||||
value: string;
|
||||
|
||||
@Field(() => UUIDScalarType, { nullable: false })
|
||||
applicationId: string;
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
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/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()
|
||||
@WorkspaceCache('applicationVariableMaps')
|
||||
export class WorkspaceApplicationVariableMapCacheService extends WorkspaceCacheProvider<ApplicationVariableCacheMaps> {
|
||||
constructor(
|
||||
@InjectRepository(ApplicationVariableEntity)
|
||||
private readonly applicationVariableRepository: Repository<ApplicationVariableEntity>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async computeForCache(
|
||||
workspaceId: string,
|
||||
): Promise<ApplicationVariableCacheMaps> {
|
||||
const applicationVariableEntities = await this.applicationVariableRepository
|
||||
.createQueryBuilder('applicationVariable')
|
||||
.innerJoin('applicationVariable.application', 'application')
|
||||
.where('application.workspaceId = :workspaceId', { workspaceId })
|
||||
.getMany();
|
||||
|
||||
const applicationVariableMaps: ApplicationVariableCacheMaps = {
|
||||
byId: {},
|
||||
byApplicationId: {},
|
||||
};
|
||||
|
||||
for (const entity of applicationVariableEntities) {
|
||||
const flatApplicationVariable =
|
||||
fromApplicationVariableEntityToFlatApplicationVariable(entity);
|
||||
|
||||
applicationVariableMaps.byId[flatApplicationVariable.id] =
|
||||
flatApplicationVariable;
|
||||
|
||||
if (!isDefined(flatApplicationVariable.applicationId)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!isDefined(
|
||||
applicationVariableMaps.byApplicationId[
|
||||
flatApplicationVariable.applicationId
|
||||
],
|
||||
)
|
||||
) {
|
||||
applicationVariableMaps.byApplicationId[
|
||||
flatApplicationVariable.applicationId
|
||||
] = [flatApplicationVariable];
|
||||
continue;
|
||||
}
|
||||
|
||||
applicationVariableMaps.byApplicationId[
|
||||
flatApplicationVariable.applicationId
|
||||
]?.push(flatApplicationVariable);
|
||||
}
|
||||
|
||||
return applicationVariableMaps;
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { type FlatApplicationVariable } from 'src/engine/core-modules/application/application-variable/types/flat-application-variable.type';
|
||||
|
||||
export type ApplicationVariableCacheMaps = {
|
||||
byId: Partial<Record<string, FlatApplicationVariable>>;
|
||||
byApplicationId: Partial<Record<string, FlatApplicationVariable[]>>;
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
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>;
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
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,
|
||||
): FlatApplicationVariable => ({
|
||||
id: entity.id,
|
||||
key: entity.key,
|
||||
value: entity.value,
|
||||
description: entity.description,
|
||||
isSecret: entity.isSecret,
|
||||
applicationId: entity.applicationId,
|
||||
createdAt: entity.createdAt.toISOString(),
|
||||
updatedAt: entity.updatedAt.toISOString(),
|
||||
});
|
||||
+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;
|
||||
};
|
||||
Reference in New Issue
Block a user