feat(app): infrastructure for pre-installed apps (#19973)
**PR 1 of 2.** Follow-up PR ships the Exa app, sets it as a default
pre-installed app, and removes the current `WebSearchTool` /
`WebSearchService` / `ExaDriver`. This PR adds the plumbing; no
user-visible change yet.
## Summary
- Server admins can declare a list of npm app packages to auto-install
on every new workspace and backfill onto existing workspaces via CLI.
- Server-level secrets (like Exa's API key) live on the
`ApplicationRegistration` (one row per server, encrypted) and are
injected into logic function execution env at runtime. No more
per-workspace storage of global secrets.
- A generic `POST /app/billing/charge` endpoint lets app logic functions
emit workspace usage events for metered features. Exa uses it in PR 2;
future apps (call recorder, etc.) reuse it.
- `LogicFunctionToolProvider` tool name prefix changes `logic_function_`
→ `app_`. Shorter, accurate (they come from installed apps).
## What's in this PR
**Logic function executor — server-level variables**
- `LogicFunctionExecutorService.getExecutionEnvVariables` now resolves
env vars in the order: hardcoded defaults →
`ApplicationRegistrationVariable[]` (server-level) →
`ApplicationVariable[]` (workspace-level override). The manifest
`serverVariables` schema has existed; this closes the loop.
**Config**
- `PRE_INSTALLED_APPS` — comma-separated list of npm packages. Default:
empty.
**\`PreInstalledAppsService\`** (new module)
- \`onApplicationBootstrap()\` — fetches each package's manifest from
the app registry CDN, upserts an \`ApplicationRegistration\`, and seeds
declared \`serverVariables\` from matching env vars (e.g.
\`EXA_API_KEY\` env → encrypted registration variable).
- \`installOnWorkspace(workspaceId)\` — installs all pre-installed apps
on a single workspace. Tolerates per-app failures.
**Auto-install on new workspace activation**
- \`WorkspaceService.prefillCreatedWorkspaceRecords\` invokes
\`installOnWorkspace\` after prefilling standard records. Non-blocking
on failure.
**Backfill CLI command**
- \`install-pre-installed-apps\` — iterates active and suspended
workspaces, installs pre-installed apps that aren't yet installed.
Idempotent. Run after changing \`PRE_INSTALLED_APPS\`.
**App billing endpoint**
- \`POST /app/billing/charge\`. Authenticated via \`APPLICATION_ACCESS\`
token (already injected into logic function execution env as
\`DEFAULT_APP_ACCESS_TOKEN\`). Body: \`{ creditsUsedMicro, quantity,
unit, operationType, resourceContext? }\`. Emits \`USAGE_RECORDED\` with
\`applicationId\` as \`resourceId\`. Generic — reusable by any app.
**Tool name prefix**
- \`LogicFunctionToolProvider.buildLogicFunctionToolName\` now produces
\`app_<name>\` instead of \`logic_function_<name>\`. Only affects tools
sourced from logic functions; other tool providers unchanged.
## Stats
- 16 files, +501 / −2
- 7 new files (1 command, 1 service × 2, 1 controller, 1 DTO, 2 modules)
- Typecheck: 7 pre-existing errors, zero new
- Prettier clean
## Behavior deltas
- **\`PRE_INSTALLED_APPS\` default = empty**: existing servers see no
change on merge.
- **\`ApplicationRegistrationVariable\` is now read by the executor**:
apps that were using manifest \`serverVariables\` but expecting them to
be ignored by the executor will now see them injected. No apps ship with
\`isTool: true\` logic functions today, so this is latent — first
consumer is Exa in PR 2.
- **Tool prefix**: currently no logic-function tools are named
\`logic_function_*\` in any production flow. The prefix change affects
only future tools emitted by \`LogicFunctionToolProvider\`.
## Risks
- **CDN unavailability at startup**: if the app registry CDN is down,
\`ensureRegistrationsExist\` logs warnings but doesn't block server
start. Installation on new workspaces during this window will find no
registrations and log a non-blocking error. Backfill command can retry
after CDN recovers.
- **Cold-start overhead**: \`ensureRegistrationsExist\` is called once
per process on bootstrap. Current configurable default is empty, so zero
overhead. When an admin sets \`PRE_INSTALLED_APPS\`, they accept one
HTTP call per package at boot.
- **Server-level variables flow**:
\`ApplicationRegistrationVariable.encryptedValue\` is shared by all
workspaces of a server. Appropriate for a single-tenant Exa key. Not
appropriate for per-tenant keys — those go in workspace-level
\`ApplicationVariable\` and override.
## Test plan
- [ ] \`npx nx typecheck twenty-server\` passes (verified: 7
pre-existing unrelated errors, zero new)
- [ ] Set \`PRE_INSTALLED_APPS=@twenty-apps/hello-world\` (or any real
npm-published app), \`HELLO_WORLD_API_KEY=xxx\`, restart server:
\`ApplicationRegistration\` row is upserted,
\`ApplicationRegistrationVariable\` for HELLO_WORLD_API_KEY is populated
(encrypted).
- [ ] Create a new workspace: the app is auto-installed,
\`ApplicationEntity\` row created, \`LogicFunctionEntity\` rows created.
- [ ] Existing workspace: run \`yarn nx run twenty-server:command
install-pre-installed-apps\`: apps install across all workspaces,
idempotent on re-run.
- [ ] Trigger a logic function that reads
\`process.env.HELLO_WORLD_API_KEY\`: value resolves from the
server-level \`ApplicationRegistrationVariable\`.
- [ ] Log a charge from the handler: \`POST /app/billing/charge\` with
\`Authorization: Bearer \$DEFAULT_APP_ACCESS_TOKEN\` body
\`{creditsUsedMicro: 1000, quantity: 1, unit: "INVOCATION",
operationType: "WEB_SEARCH"}\` → returns \`{success: true}\`,
\`USAGE_RECORDED\` event emitted with correct
\`resourceId=applicationId\`.
- [ ] Tool name generated by \`LogicFunctionToolProvider\` starts with
\`app_\`.
## What's NOT in this PR (PR 2 scope)
- The Exa app itself (\`packages/twenty-apps/...\` directory)
- Removing \`WebSearchTool\`, \`WebSearchService\`, \`ExaDriver\`,
\`web-search\` module
- Removing \`WEB_SEARCH_DRIVER\` config var
- Removing the current \`exa_web_search\` entry in
\`ActionToolProvider\`
- Chat preload list updated to \`app_exa_web_search\`
- Frontend \`getToolDisplayMessage\` branch for \`app_exa_web_search\`
- Setting \`PRE_INSTALLED_APPS\` default to include \`@twenty-apps/exa\`
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+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],
|
||||
|
||||
+57
-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 { Not, 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,66 @@ 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).
|
||||
//
|
||||
// Runs on every logic function execution — the query is indexed on
|
||||
// applicationRegistrationId and filters unfilled rows server-side. Most
|
||||
// apps have 0-3 server variables so the round-trip is cheap, but if this
|
||||
// becomes a hot path, move to a WorkspaceCacheProvider mirroring
|
||||
// WorkspaceApplicationVariableMapCacheService.
|
||||
private async buildServerVariableEnvMap(
|
||||
applicationRegistrationId: string | null,
|
||||
): Promise<Record<string, string>> {
|
||||
if (!isDefined(applicationRegistrationId)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const serverVariables =
|
||||
await this.applicationRegistrationVariableRepository.find({
|
||||
where: {
|
||||
applicationRegistrationId,
|
||||
encryptedValue: Not(''),
|
||||
},
|
||||
});
|
||||
|
||||
const envMap: Record<string, string> = {};
|
||||
|
||||
// ApplicationRegistrationVariable.encryptedValue is always written
|
||||
// encrypted (ApplicationRegistrationVariableService.createVariable and
|
||||
// .updateVariable call encrypt unconditionally), independent of
|
||||
// `isSecret`. `isSecret` is display metadata — the storage contract is
|
||||
// not conditional, so decryption isn't either.
|
||||
for (const variable of serverVariables) {
|
||||
envMap[variable.key] = this.secretEncryptionService.decrypt(
|
||||
variable.encryptedValue,
|
||||
);
|
||||
}
|
||||
|
||||
return envMap;
|
||||
}
|
||||
|
||||
private async handleExecutionResult({
|
||||
result,
|
||||
flatApplication,
|
||||
|
||||
Reference in New Issue
Block a user