From fcd68aa6ddd25eee25a41c62f377bb0acc4892a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Wed, 22 Apr 2026 15:21:04 +0200 Subject: [PATCH] feat(app): infrastructure for pre-installed apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the plumbing for server admins to declare a list of npm app packages that are auto-installed on every new workspace and backfillable onto existing workspaces. The follow-up PR migrates Exa onto this path. What this PR introduces: - **Server-level variable injection into logic function execution.** LogicFunctionExecutorService now resolves env vars as: (registration-level ApplicationRegistrationVariable) overridden by (workspace-level ApplicationVariable). The manifest already declares serverVariables; this closes the loop so apps can read those values at runtime without storing them per-workspace. - **PRE_INSTALLED_APPS config variable.** Comma-separated list of npm package names. Default empty — opt-in by server admin. - **PreInstalledAppsService.** On application bootstrap, fetches each package's manifest from the app registry CDN, upserts an ApplicationRegistration, and seeds declared server variables from matching env vars (EXA_API_KEY env -> encrypted registration variable). Exposes installOnWorkspace(workspaceId) for hooks and backfill. - **Auto-install on new workspace activation.** WorkspaceService invokes PreInstalledAppsService.installOnWorkspace in prefillCreatedWorkspaceRecords. Failures are non-blocking — the admin can backfill later. - **install-pre-installed-apps CLI command.** Idempotent backfill that iterates active and suspended workspaces. Run after changing PRE_INSTALLED_APPS to roll the change out to existing tenants. - **POST /app/billing/charge endpoint.** Authenticated via APPLICATION_ACCESS token (DEFAULT_APP_ACCESS_TOKEN already injected into logic function execution env). Emits USAGE_RECORDED workspace events with applicationId as resourceId. Generic — future apps (call recorder, etc.) use the same endpoint. - **Tool name prefix change `logic_function_` -> `app_`.** Shorter, signals that these tools come from installed apps. Only affects tools sourced from LogicFunctionToolProvider; other providers unchanged. No user-visible change yet — PRE_INSTALLED_APPS defaults to empty. The follow-up PR ships the Exa app package, sets the default, and removes the current WebSearchTool / WebSearchService / ExaDriver. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../commands/database-command.module.ts | 4 + .../install-pre-installed-apps.command.ts | 45 ++++ .../app-billing/app-billing.controller.ts | 49 +++++ .../app-billing/app-billing.module.ts | 13 ++ .../app-billing/app-billing.service.ts | 50 +++++ .../app-billing/dtos/charge.dto.ts | 28 +++ .../marketplace.module.ts | 1 + .../pre-installed-apps.module.ts | 28 +++ .../pre-installed-apps.service.ts | 201 ++++++++++++++++++ .../engine/core-modules/core-engine.module.ts | 4 + .../logic-function-executor.module.ts | 3 + .../logic-function-executor.service.ts | 49 ++++- .../providers/logic-function-tool.provider.ts | 2 +- .../twenty-config/config-variables.ts | 9 + .../workspace/services/workspace.service.ts | 15 ++ .../workspace/workspace.module.ts | 2 + 16 files changed, 501 insertions(+), 2 deletions(-) create mode 100644 packages/twenty-server/src/database/commands/install-pre-installed-apps.command.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/app-billing/app-billing.controller.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/app-billing/app-billing.module.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/app-billing/app-billing.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/app-billing/dtos/charge.dto.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.module.ts create mode 100644 packages/twenty-server/src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.service.ts diff --git a/packages/twenty-server/src/database/commands/database-command.module.ts b/packages/twenty-server/src/database/commands/database-command.module.ts index bcfcb56d192..82e55e49cfb 100644 --- a/packages/twenty-server/src/database/commands/database-command.module.ts +++ b/packages/twenty-server/src/database/commands/database-command.module.ts @@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { CronRegisterAllCommand } from 'src/database/commands/cron-register-all.command'; import { DataSeedWorkspaceCommand } from 'src/database/commands/data-seed-dev-workspace.command'; import { GenerateInstanceCommandCommand } from 'src/database/commands/generate-instance-command.command'; +import { InstallPreInstalledAppsCommand } from 'src/database/commands/install-pre-installed-apps.command'; import { InstanceCommandGenerationService } from 'src/database/commands/instance-command-generation.service'; import { ListOrphanedWorkspaceEntitiesCommand } from 'src/database/commands/list-and-delete-orphaned-workspace-entities.command'; import { ConfirmationQuestion } from 'src/database/commands/questions/confirmation.question'; @@ -15,6 +16,7 @@ import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module'; import { GenerateApiKeyCommand } from 'src/engine/core-modules/api-key/commands/generate-api-key.command'; import { MarketplaceModule } from 'src/engine/core-modules/application/application-marketplace/marketplace.module'; import { StaleRegistrationCleanupModule } from 'src/engine/core-modules/application/application-oauth/stale-registration-cleanup/stale-registration-cleanup.module'; +import { PreInstalledAppsModule } from 'src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.module'; import { ApplicationUpgradeModule } from 'src/engine/core-modules/application/application-upgrade/application-upgrade.module'; import { RebuildApplicationDefaultDepsCommand } from 'src/database/commands/rebuild-application-default-deps.command'; import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module'; @@ -77,6 +79,7 @@ import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/au MarketplaceModule, ApplicationUpgradeModule, StaleRegistrationCleanupModule, + PreInstalledAppsModule, WorkspaceIteratorModule, ApplicationModule, WorkspaceCacheModule, @@ -96,6 +99,7 @@ import { AutomatedTriggerModule } from 'src/modules/workflow/workflow-trigger/au EnforceUsageCapCronCommand, UpgradeStatusCommand, RebuildApplicationDefaultDepsCommand, + InstallPreInstalledAppsCommand, ], }) export class DatabaseCommandModule {} diff --git a/packages/twenty-server/src/database/commands/install-pre-installed-apps.command.ts b/packages/twenty-server/src/database/commands/install-pre-installed-apps.command.ts new file mode 100644 index 00000000000..07a4de2c689 --- /dev/null +++ b/packages/twenty-server/src/database/commands/install-pre-installed-apps.command.ts @@ -0,0 +1,45 @@ +import { Command } from 'nest-commander'; + +import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner'; +import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service'; +import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner'; +import { PreInstalledAppsService } from 'src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.service'; + +// Backfill command for the `PRE_INSTALLED_APPS` config. Iterates active and +// suspended workspaces and installs every pre-installed app that isn't yet +// installed. Idempotent — workspaces that already have the app skip over +// cleanly (ApplicationInstallService short-circuits when the same version +// is already present). Run after changing `PRE_INSTALLED_APPS` to roll the +// change out to existing tenants. +@Command({ + name: 'install-pre-installed-apps', + description: + 'Install all apps listed in PRE_INSTALLED_APPS on every active and suspended workspace. Idempotent.', +}) +export class InstallPreInstalledAppsCommand extends ActiveOrSuspendedWorkspaceCommandRunner { + constructor( + protected readonly workspaceIteratorService: WorkspaceIteratorService, + private readonly preInstalledAppsService: PreInstalledAppsService, + ) { + super(workspaceIteratorService); + } + + override async runOnWorkspace({ + workspaceId, + options, + index, + total, + }: RunOnWorkspaceArgs): Promise { + const dryRun = options.dryRun ?? false; + + this.logger.log( + `${dryRun ? '[DRY RUN] ' : ''}Installing pre-installed apps on workspace ${workspaceId} (${index + 1}/${total})`, + ); + + if (dryRun) { + return; + } + + await this.preInstalledAppsService.installOnWorkspace(workspaceId); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application/app-billing/app-billing.controller.ts b/packages/twenty-server/src/engine/core-modules/application/app-billing/app-billing.controller.ts new file mode 100644 index 00000000000..a0d32be8baf --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/app-billing/app-billing.controller.ts @@ -0,0 +1,49 @@ +import { + BadRequestException, + Body, + Controller, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +import { Request } from 'express'; + +import { AppBillingService } from 'src/engine/core-modules/application/app-billing/app-billing.service'; +import { ChargeDto } from 'src/engine/core-modules/application/app-billing/dtos/charge.dto'; +import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; + +// Authenticated via an APPLICATION_ACCESS token (JwtAuthStrategy sets +// request.user to the AuthContext with `application` + `workspace` when +// the token type is APPLICATION_ACCESS). Apps receive the token in their +// execution env as DEFAULT_APP_ACCESS_TOKEN, so no new auth mechanism +// is introduced here. +@Controller('app/billing') +@UseGuards(AuthGuard('jwt')) +export class AppBillingController { + constructor(private readonly appBillingService: AppBillingService) {} + + @Post('charge') + async charge( + @Req() request: Request, + @Body() charge: ChargeDto, + ): Promise<{ success: true }> { + const authContext = request.user as AuthContext | undefined; + + if (!authContext?.application || !authContext.workspace) { + throw new BadRequestException( + 'App billing endpoint requires an APPLICATION_ACCESS token. The caller must be a logic function running inside an installed application.', + ); + } + + this.appBillingService.emitChargeEvent({ + workspaceId: authContext.workspace.id, + applicationId: authContext.application.id, + userWorkspaceId: authContext.userWorkspaceId, + charge, + }); + + return { success: true }; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application/app-billing/app-billing.module.ts b/packages/twenty-server/src/engine/core-modules/application/app-billing/app-billing.module.ts new file mode 100644 index 00000000000..dc9bfc6201b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/app-billing/app-billing.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; + +import { AppBillingController } from 'src/engine/core-modules/application/app-billing/app-billing.controller'; +import { AppBillingService } from 'src/engine/core-modules/application/app-billing/app-billing.service'; +import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module'; + +@Module({ + imports: [WorkspaceEventEmitterModule], + controllers: [AppBillingController], + providers: [AppBillingService], + exports: [AppBillingService], +}) +export class AppBillingModule {} diff --git a/packages/twenty-server/src/engine/core-modules/application/app-billing/app-billing.service.ts b/packages/twenty-server/src/engine/core-modules/application/app-billing/app-billing.service.ts new file mode 100644 index 00000000000..0f43451c1cc --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/app-billing/app-billing.service.ts @@ -0,0 +1,50 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { type ChargeDto } from 'src/engine/core-modules/application/app-billing/dtos/charge.dto'; +import { USAGE_RECORDED } from 'src/engine/core-modules/usage/constants/usage-recorded.constant'; +import { UsageResourceType } from 'src/engine/core-modules/usage/enums/usage-resource-type.enum'; +import { type UsageEvent } from 'src/engine/core-modules/usage/types/usage-event.type'; +import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; + +// Receives billing events from installed apps (running as logic functions) +// and emits USAGE_RECORDED workspace events. Apps call this through the +// POST /app/billing/charge endpoint using their injected app access token; +// workspaceId + applicationId come from the token, never from the body. +@Injectable() +export class AppBillingService { + private readonly logger = new Logger(AppBillingService.name); + + constructor(private readonly workspaceEventEmitter: WorkspaceEventEmitter) {} + + emitChargeEvent(params: { + workspaceId: string; + applicationId: string; + userWorkspaceId?: string | null; + charge: ChargeDto; + }): void { + const { workspaceId, applicationId, userWorkspaceId, charge } = params; + + this.logger.log( + `App charge from applicationId=${applicationId} workspaceId=${workspaceId}: ` + + `${charge.creditsUsedMicro} micro-credits (${charge.quantity} ${charge.unit}, ` + + `${charge.operationType})`, + ); + + this.workspaceEventEmitter.emitCustomBatchEvent( + USAGE_RECORDED, + [ + { + resourceType: UsageResourceType.AI, + operationType: charge.operationType, + creditsUsedMicro: charge.creditsUsedMicro, + quantity: charge.quantity, + unit: charge.unit, + resourceId: applicationId, + resourceContext: charge.resourceContext ?? null, + userWorkspaceId: userWorkspaceId ?? null, + }, + ], + workspaceId, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/application/app-billing/dtos/charge.dto.ts b/packages/twenty-server/src/engine/core-modules/application/app-billing/dtos/charge.dto.ts new file mode 100644 index 00000000000..9c223683424 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/app-billing/dtos/charge.dto.ts @@ -0,0 +1,28 @@ +import { IsEnum, IsInt, IsOptional, Min } from 'class-validator'; + +import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-operation-type.enum'; +import { UsageUnit } from 'src/engine/core-modules/usage/enums/usage-unit.enum'; + +// Billing payload apps send to POST /app/billing/charge. `operationType` is +// the existing workspace usage taxonomy — apps pick the appropriate entry +// (e.g. WEB_SEARCH for search-style apps, CODE_EXECUTION for sandbox-style +// apps). `unit` and `quantity` describe the billable thing; the credit +// amount is expressed in micro-credits so fractional pricing works. +export class ChargeDto { + @IsInt() + @Min(0) + creditsUsedMicro!: number; + + @IsInt() + @Min(1) + quantity!: number; + + @IsEnum(UsageUnit) + unit!: UsageUnit; + + @IsEnum(UsageOperationType) + operationType!: UsageOperationType; + + @IsOptional() + resourceContext?: string; +} diff --git a/packages/twenty-server/src/engine/core-modules/application/application-marketplace/marketplace.module.ts b/packages/twenty-server/src/engine/core-modules/application/application-marketplace/marketplace.module.ts index e825cb7e0ea..080a307d366 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-marketplace/marketplace.module.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-marketplace/marketplace.module.ts @@ -34,6 +34,7 @@ import { MarketplaceCatalogSyncCommand } from 'src/engine/core-modules/applicati MarketplaceCatalogSyncService, MarketplaceQueryService, MarketplaceCatalogSyncCronCommand, + MarketplaceService, ], }) export class MarketplaceModule {} diff --git a/packages/twenty-server/src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.module.ts b/packages/twenty-server/src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.module.ts new file mode 100644 index 00000000000..e2d81f86928 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { ApplicationInstallModule } from 'src/engine/core-modules/application/application-install/application-install.module'; +import { MarketplaceModule } from 'src/engine/core-modules/application/application-marketplace/marketplace.module'; +import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity'; +import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity'; +import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module'; +import { PreInstalledAppsService } from 'src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.service'; +import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryption/secret-encryption.module'; +import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + ApplicationRegistrationEntity, + ApplicationRegistrationVariableEntity, + ]), + ApplicationInstallModule, + ApplicationRegistrationModule, + MarketplaceModule, + SecretEncryptionModule, + TwentyConfigModule, + ], + providers: [PreInstalledAppsService], + exports: [PreInstalledAppsService], +}) +export class PreInstalledAppsModule {} diff --git a/packages/twenty-server/src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.service.ts b/packages/twenty-server/src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.service.ts new file mode 100644 index 00000000000..a6d657a3a32 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.service.ts @@ -0,0 +1,201 @@ +import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { isDefined } from 'twenty-shared/utils'; +import { Repository } from 'typeorm'; + +import { ApplicationInstallService } from 'src/engine/core-modules/application/application-install/application-install.service'; +import { MarketplaceService } from 'src/engine/core-modules/application/application-marketplace/marketplace.service'; +import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity'; +import { ApplicationRegistrationEntity } from 'src/engine/core-modules/application/application-registration/application-registration.entity'; +import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/application/application-registration/enums/application-registration-source-type.enum'; +import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service'; +import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; + +// Resolves the `PRE_INSTALLED_APPS` server config into `ApplicationRegistration` +// rows on startup and installs them on workspaces (new workspace hook + +// backfill CLI command). Server admins set `PRE_INSTALLED_APPS` to a +// comma-separated list of npm package names; any server-variable values +// declared in the manifest are seeded from matching env vars. +@Injectable() +export class PreInstalledAppsService implements OnApplicationBootstrap { + private readonly logger = new Logger(PreInstalledAppsService.name); + + constructor( + private readonly twentyConfigService: TwentyConfigService, + private readonly marketplaceService: MarketplaceService, + private readonly applicationRegistrationService: ApplicationRegistrationService, + private readonly applicationInstallService: ApplicationInstallService, + private readonly secretEncryptionService: SecretEncryptionService, + @InjectRepository(ApplicationRegistrationEntity) + private readonly applicationRegistrationRepository: Repository, + @InjectRepository(ApplicationRegistrationVariableEntity) + private readonly applicationRegistrationVariableRepository: Repository, + ) {} + + async onApplicationBootstrap(): Promise { + try { + await this.ensureRegistrationsExist(); + } catch (error) { + // Bootstrap failures must not prevent the server from starting. + // New workspaces will retry registration resolution at install time. + this.logger.error( + `Failed to ensure pre-installed app registrations at bootstrap: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + + getPreInstalledPackageNames(): string[] { + const raw = this.twentyConfigService.get('PRE_INSTALLED_APPS') ?? ''; + + return raw + .split(',') + .map((name) => name.trim()) + .filter((name) => name.length > 0); + } + + // Idempotent: fetches each pre-installed package's manifest from the + // app registry CDN, upserts its ApplicationRegistration, and seeds any + // declared server variables from matching env vars. + async ensureRegistrationsExist(): Promise { + const packageNames = this.getPreInstalledPackageNames(); + + if (packageNames.length === 0) { + return []; + } + + const registryPackages = await this.marketplaceService + .fetchAppsFromRegistry() + .catch(() => []); + + const versionByName = new Map( + registryPackages.map((pkg) => [pkg.name, pkg.version] as const), + ); + + const resolvedRegistrations: ApplicationRegistrationEntity[] = []; + + for (const packageName of packageNames) { + const version = versionByName.get(packageName); + + if (!isDefined(version)) { + this.logger.warn( + `Pre-installed app "${packageName}" not found in the app registry`, + ); + continue; + } + + const manifest = + await this.marketplaceService.fetchManifestFromRegistryCdn( + packageName, + version, + ); + + if (!manifest) { + this.logger.warn( + `Manifest not found for pre-installed app "${packageName}@${version}"`, + ); + continue; + } + + const universalIdentifier = manifest.application.universalIdentifier; + + await this.applicationRegistrationService.upsertFromCatalog({ + universalIdentifier, + name: manifest.application.displayName ?? packageName, + sourceType: ApplicationRegistrationSourceType.NPM, + sourcePackage: packageName, + latestAvailableVersion: version, + isListed: true, + isFeatured: false, + manifest, + ownerWorkspaceId: null, + }); + + await this.seedServerVariablesFromEnv(universalIdentifier); + + const registration = await this.applicationRegistrationRepository.findOne( + { + where: { universalIdentifier }, + }, + ); + + if (registration) { + resolvedRegistrations.push(registration); + } + } + + return resolvedRegistrations; + } + + // Installs all pre-installed apps on a single workspace. Tolerates + // per-app failures so a bad install never blocks workspace creation. + async installOnWorkspace(workspaceId: string): Promise { + const packageNames = this.getPreInstalledPackageNames(); + + if (packageNames.length === 0) { + return; + } + + const registrations = await this.applicationRegistrationRepository.find({ + where: packageNames.map((sourcePackage) => ({ + sourcePackage, + sourceType: ApplicationRegistrationSourceType.NPM, + })), + }); + + for (const registration of registrations) { + try { + await this.applicationInstallService.installApplication({ + appRegistrationId: registration.id, + workspaceId, + }); + } catch (error) { + this.logger.error( + `Failed to install pre-installed app "${registration.sourcePackage}" on workspace ${workspaceId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + } + + // Reads declared server variables on the registration and, for any that + // are still empty, populates them with the value of a matching env var + // (same name). Existing values are never overwritten — a server admin + // can manage them through the admin UI once set. + private async seedServerVariablesFromEnv( + universalIdentifier: string, + ): Promise { + const registration = await this.applicationRegistrationRepository.findOne({ + where: { universalIdentifier }, + relations: ['variables'], + }); + + if (!registration) { + return; + } + + for (const variable of registration.variables) { + if (variable.encryptedValue !== '') { + continue; + } + + const envValue = process.env[variable.key]; + + if (!isDefined(envValue) || envValue.length === 0) { + continue; + } + + const encryptedValue = variable.isSecret + ? this.secretEncryptionService.encrypt(envValue) + : envValue; + + await this.applicationRegistrationVariableRepository.update(variable.id, { + encryptedValue, + }); + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts index f05bb46ab42..b1c2dfc2f26 100644 --- a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts +++ b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts @@ -16,6 +16,8 @@ import { ApplicationOAuthModule } from 'src/engine/core-modules/application/appl import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module'; import { ApplicationUpgradeModule } from 'src/engine/core-modules/application/application-upgrade/application-upgrade.module'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; +import { AppBillingModule } from 'src/engine/core-modules/application/app-billing/app-billing.module'; +import { PreInstalledAppsModule } from 'src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.module'; import { ApprovedAccessDomainModule } from 'src/engine/core-modules/approved-access-domain/approved-access-domain.module'; import { AuthModule } from 'src/engine/core-modules/auth/auth.module'; import { BillingWebhookModule } from 'src/engine/core-modules/billing-webhook/billing-webhook.module'; @@ -101,6 +103,8 @@ import { FileModule } from './file/file.module'; ApplicationUpgradeModule, ApplicationDevelopmentModule, MarketplaceModule, + PreInstalledAppsModule, + AppBillingModule, AppTokenModule, TimelineMessagingModule, TimelineCalendarEventModule, diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.module.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.module.ts index 130387357e3..6d933ab2f1f 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.module.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.module.ts @@ -1,5 +1,7 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity'; import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service'; import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module'; import { AuditModule } from 'src/engine/core-modules/audit/audit.module'; @@ -16,6 +18,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache SecretEncryptionModule, SubscriptionsModule, WorkspaceCacheModule, + TypeOrmModule.forFeature([ApplicationRegistrationVariableEntity]), ], providers: [LogicFunctionExecutorService], exports: [LogicFunctionExecutorService], diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service.ts index 58f751debfd..d9052d4cff7 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service.ts @@ -1,4 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; import { DEFAULT_API_KEY_NAME, @@ -6,6 +7,7 @@ import { DEFAULT_APP_ACCESS_TOKEN_NAME, } from 'twenty-shared/application'; import { isDefined } from 'twenty-shared/utils'; +import { Repository } from 'typeorm'; import { v4 } from 'uuid'; import { @@ -16,6 +18,7 @@ import { import { ApplicationLogsService } from 'src/engine/core-modules/application-logs/application-logs.service'; import { parseApplicationLogLines } from 'src/engine/core-modules/application-logs/utils/parse-application-log-lines'; +import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity'; import type { FlatApplicationVariable } from 'src/engine/core-modules/application/application-variable/types/flat-application-variable.type'; import { FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; import { AuditService } from 'src/engine/core-modules/audit/services/audit.service'; @@ -69,6 +72,8 @@ export class LogicFunctionExecutorService { private readonly auditService: AuditService, private readonly applicationLogsService: ApplicationLogsService, private readonly workspaceEventEmitter: WorkspaceEventEmitter, + @InjectRepository(ApplicationRegistrationVariableEntity) + private readonly applicationRegistrationVariableRepository: Repository, ) {} async execute({ @@ -219,15 +224,57 @@ export class LogicFunctionExecutorService { const baseUrl = cleanServerUrl(this.twentyConfigService.get('SERVER_URL')); + const serverVariables = await this.buildServerVariableEnvMap( + flatApplication.applicationRegistrationId, + ); + const workspaceVariables = buildEnvVar( + flatApplicationVariables, + this.secretEncryptionService, + ); + return { [DEFAULT_API_URL_NAME]: baseUrl ?? '', [DEFAULT_APP_ACCESS_TOKEN_NAME]: applicationAccessToken.token, [DEFAULT_API_KEY_NAME]: applicationAccessToken.token, APPLICATION_ID: flatApplication.id, - ...buildEnvVar(flatApplicationVariables, this.secretEncryptionService), + // Server variables first, workspace variables override. Workspace-level + // values let a specific tenant customize a server default. + ...serverVariables, + ...workspaceVariables, }; } + // Resolves encrypted server-level variables (ApplicationRegistrationVariable) + // for the application's registration. Returns an empty object when the + // application isn't linked to a registration (legacy LOCAL apps) or when no + // server variables are configured. + private async buildServerVariableEnvMap( + applicationRegistrationId: string | null, + ): Promise> { + if (!isDefined(applicationRegistrationId)) { + return {}; + } + + const serverVariables = + await this.applicationRegistrationVariableRepository.find({ + where: { applicationRegistrationId }, + }); + + const envMap: Record = {}; + + for (const variable of serverVariables) { + if (variable.encryptedValue === '') { + continue; + } + + envMap[variable.key] = variable.isSecret + ? this.secretEncryptionService.decrypt(variable.encryptedValue) + : variable.encryptedValue; + } + + return envMap; + } + private async handleExecutionResult({ result, flatApplication, diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/logic-function-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/logic-function-tool.provider.ts index 30bd19de0a0..ee9d6cb9e85 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/logic-function-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/logic-function-tool.provider.ts @@ -94,7 +94,7 @@ export class LogicFunctionToolProvider implements ToolProvider { } private buildLogicFunctionToolName(functionName: string): string { - return `logic_function_${functionName + return `app_${functionName .toLowerCase() .replace(/[^a-z0-9]+/g, '_') .replace(/^_+|_+$/g, '')}`; diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index c0111afe414..2f6982c1676 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -695,6 +695,15 @@ export class ConfigVariables { @ValidateIf((env) => env.WEB_SEARCH_DRIVER === WebSearchDriverType.EXA) EXA_API_KEY?: string; + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.ADVANCED_SETTINGS, + description: + 'Comma-separated list of npm package names to auto-install on every new workspace. Existing workspaces are backfilled via the `install-pre-installed-apps` CLI command. Example: `@twenty-apps/exa,@twenty-apps/call-recorder`', + type: ConfigVariableType.STRING, + }) + @IsOptional() + PRE_INSTALLED_APPS: string = ''; + @ConfigVariablesMetadata({ group: ConfigVariablesGroup.ANALYTICS_CONFIG, description: 'Enable or disable analytics for telemetry', diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts index 545865de8d6..ba57654b954 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts @@ -13,6 +13,7 @@ import { DataSource, QueryRunner, Repository } from 'typeorm'; import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service'; import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { PreInstalledAppsService } from 'src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.service'; import { type AuthContextUser } from 'src/engine/core-modules/auth/types/auth-context.type'; import { BillingSubscriptionService } from 'src/engine/core-modules/billing/services/billing-subscription.service'; import { BillingService } from 'src/engine/core-modules/billing/services/billing.service'; @@ -119,6 +120,7 @@ export class WorkspaceService extends TypeOrmQueryService { private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, private readonly prefillLogicFunctionService: PrefillLogicFunctionService, private readonly applicationService: ApplicationService, + private readonly preInstalledAppsService: PreInstalledAppsService, private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, private readonly workspaceCacheStorageService: WorkspaceCacheStorageService, private readonly subdomainManagerService: SubdomainManagerService, @@ -819,6 +821,19 @@ export class WorkspaceService extends TypeOrmQueryService { ); this.exceptionHandlerService.captureExceptions([error as Error]); } + + // Install apps declared in PRE_INSTALLED_APPS. Failures here are + // non-critical to workspace activation — the admin can backfill later via + // the `install-pre-installed-apps` CLI command. + try { + await this.preInstalledAppsService.installOnWorkspace(workspaceId); + } catch (error) { + this.logger.error( + `Non-critical: failed to install pre-installed apps for workspace ${workspaceId}`, + error, + ); + this.exceptionHandlerService.captureExceptions([error as Error]); + } } async findOneWorkspaceById(id: string) { diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts index 33263e7591b..828a93a9fbf 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.module.ts @@ -6,6 +6,7 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; import { TypeORMModule } from 'src/database/typeorm/typeorm.module'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; +import { PreInstalledAppsModule } from 'src/engine/core-modules/application/pre-installed-apps/pre-installed-apps.module'; import { AuditModule } from 'src/engine/core-modules/audit/audit.module'; import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; import { BillingModule } from 'src/engine/core-modules/billing/billing.module'; @@ -82,6 +83,7 @@ import { StandardObjectsPrefillModule } from 'src/engine/workspace-manager/stand ViewModule, WorkspaceManyOrAllFlatEntityMapsCacheModule, ApplicationModule, + PreInstalledAppsModule, EnterpriseModule, StandardObjectsPrefillModule, WorkspaceMigrationModule,