feat(app): infrastructure for pre-installed apps

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) <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-04-22 15:21:04 +02:00
co-authored by Claude Opus 4.7
parent 0c929e7903
commit fcd68aa6dd
16 changed files with 501 additions and 2 deletions
@@ -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 {}
@@ -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<void> {
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);
}
}
@@ -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 };
}
}
@@ -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 {}
@@ -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<UsageEvent>(
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,
);
}
}
@@ -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;
}
@@ -34,6 +34,7 @@ import { MarketplaceCatalogSyncCommand } from 'src/engine/core-modules/applicati
MarketplaceCatalogSyncService,
MarketplaceQueryService,
MarketplaceCatalogSyncCronCommand,
MarketplaceService,
],
})
export class MarketplaceModule {}
@@ -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 {}
@@ -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<ApplicationRegistrationEntity>,
@InjectRepository(ApplicationRegistrationVariableEntity)
private readonly applicationRegistrationVariableRepository: Repository<ApplicationRegistrationVariableEntity>,
) {}
async onApplicationBootstrap(): Promise<void> {
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<ApplicationRegistrationEntity[]> {
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<void> {
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<void> {
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,
});
}
}
}
@@ -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,
@@ -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],
@@ -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<ApplicationRegistrationVariableEntity>,
) {}
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<Record<string, string>> {
if (!isDefined(applicationRegistrationId)) {
return {};
}
const serverVariables =
await this.applicationRegistrationVariableRepository.find({
where: { applicationRegistrationId },
});
const envMap: Record<string, string> = {};
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,
@@ -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, '')}`;
@@ -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',
@@ -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<WorkspaceEntity> {
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<WorkspaceEntity> {
);
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) {
@@ -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,