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:
co-authored by
Claude Opus 4.7
parent
0c929e7903
commit
fcd68aa6dd
@@ -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);
|
||||
}
|
||||
}
|
||||
+49
@@ -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 };
|
||||
}
|
||||
}
|
||||
+13
@@ -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 {}
|
||||
+50
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+28
@@ -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;
|
||||
}
|
||||
+1
@@ -34,6 +34,7 @@ import { MarketplaceCatalogSyncCommand } from 'src/engine/core-modules/applicati
|
||||
MarketplaceCatalogSyncService,
|
||||
MarketplaceQueryService,
|
||||
MarketplaceCatalogSyncCronCommand,
|
||||
MarketplaceService,
|
||||
],
|
||||
})
|
||||
export class MarketplaceModule {}
|
||||
|
||||
+28
@@ -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 {}
|
||||
+201
@@ -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,
|
||||
|
||||
+3
@@ -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],
|
||||
|
||||
+48
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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',
|
||||
|
||||
+15
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user