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:
Félix Malfait
2026-03-05 10:34:08 +01:00
committed by GitHub
parent bfa50f566e
commit 0e89c96170
205 changed files with 4741 additions and 1014 deletions
@@ -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 {}
@@ -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,
);
}
}
}
}
@@ -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);
}
@@ -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,
);
}
}
}
@@ -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,
);
}
}
}
@@ -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 {}
@@ -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({
@@ -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 },
);
}
}
@@ -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';
@@ -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;
}
}
@@ -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: [],
},
},
];
@@ -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,
},
},
});
}
}
@@ -0,0 +1 @@
export const MARKETPLACE_CATALOG_SYNC_CRON_PATTERN = '0 * * * *';
@@ -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;
}
}
}
@@ -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;
}
@@ -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 {}
@@ -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

@@ -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": ""
}
@@ -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,
});
}
}
@@ -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);
}
}
@@ -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,
};
}
}
@@ -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 [];
}
}
}
@@ -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[];
};
@@ -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;
};
@@ -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');
});
});
@@ -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);
}
}
}
@@ -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;
}
@@ -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,
);
}
}
}
@@ -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;
}
@@ -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),
});
}
}
@@ -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 {}
@@ -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,
});
}
}
@@ -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,
);
}
}
}
@@ -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",
};
@@ -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'],
};
}
}
@@ -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');
}
}
@@ -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[];
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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[];
}
@@ -0,0 +1,7 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType('RotateClientSecret')
export class RotateClientSecretDTO {
@Field()
clientSecret: string;
}
@@ -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;
}
@@ -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;
}
@@ -0,0 +1,11 @@
import { registerEnumType } from '@nestjs/graphql';
export enum AppRegistrationSourceType {
NPM = 'npm',
TARBALL = 'tarball',
LOCAL = 'local',
}
registerEnumType(AppRegistrationSourceType, {
name: 'AppRegistrationSourceType',
});
@@ -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 };
}
}
@@ -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 });
}
}
}
@@ -0,0 +1,4 @@
export type OAuthErrorResponse = {
error: string;
error_description: string;
};
@@ -0,0 +1,7 @@
export type OAuthTokenResponse = {
access_token: string;
token_type: string;
expires_in: number;
refresh_token?: string;
scope?: string;
};
@@ -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,
});
});
});
});
@@ -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);
}
}
}
@@ -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;
}
@@ -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),
});
}
}
@@ -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 {}
@@ -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;
}
}
@@ -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',
]);
}
}
@@ -0,0 +1 @@
export const SECRET_APPLICATION_VARIABLE_MASK = '********';
@@ -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;
}
@@ -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;
}
@@ -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;
}
}
@@ -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[]>>;
};
@@ -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>;
@@ -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(),
});
@@ -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 {}
@@ -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;
}
}
}
@@ -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,
},
},
});
}
}
@@ -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 {}
@@ -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({
@@ -1,5 +1,7 @@
enableInlineHunks: true
enableScripts: false
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.9.2.cjs
@@ -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;
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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'],
},
};
@@ -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,
);
}
};
@@ -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;
},
});
};
@@ -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;
};