feat(server): run server-exposed logic functions in the owner workspace (#22002)

## Summary

Implements the server-level logic-function tier in the simplest shape: a
logic function is "server-exposed" iff its manifest entry carries
`serverWebhookTriggerSettings`. Execution delegates to the
owner-workspace copy of that function — billing, throttling, env vars,
and the existing executor all apply uniformly against that workspace.

Supersedes #21971 with the simplified design from that discussion (no
`applicationRegistrationLogicFunction` registry, no dedicated manifest
type, no separate SDK helper, no special throttling).

## Design

- **Manifest**: `LogicFunctionManifest` gains
`serverWebhookTriggerSettings?`. The declarative `workspaceIdResolver`
shape is dropped.
- **Materialization**: those settings become two new jsonb columns on
`LogicFunctionEntity`. The manifest → flat converter and the
create-from-source DTO/util forward them; the property-config map and
editable-properties list are extended.
- **Lookup**: a single QB query joins `logicFunction → application →
applicationRegistration` and filters on `lf.workspaceId =
reg.workspaceId` to get only the owner workspace's copy.
- **Webhook**: `POST /webhooks/server/:logicFunctionUniversalIdentifier`
→ `ServerWebhookTriggerService.handle` → join lookup →
`LogicFunctionTriggerService.run`. No registry table, no
`:applicationRegistrationUniversalIdentifier` segment, no resolver.
- **Gate**: `IS_SERVER_LOGIC_FUNCTION_ENABLED` config var (disabled by
default).

## Test plan
- [x] `npx jest server-webhook-trigger` — 9 unit tests across the
webhook service.
- [x] `npx jest logic-function` — 88 existing tests stay green.
- [x] `npx nx typecheck twenty-server`.
- [x] `npx nx lint:diff-with-main twenty-server`.
- [x] Reset DB → init → run `database:migrate:prod` → run
`database:migrate:generate --name pending-migration-check` → no drift.
- [ ] Manual: hit `/webhooks/server/<uid>` end-to-end against a manifest
carrying `serverWebhookTriggerSettings`.

https://claude.ai/code/session_01GgsnCGmYJ26xRirx8va1Yh

---
_Generated by [Claude
Code](https://claude.ai/code/session_01GgsnCGmYJ26xRirx8va1Yh)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22002?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
martmull
2026-06-24 15:34:12 +02:00
committed by GitHub
parent 20ac0a52bf
commit b5a1aed24b
36 changed files with 1231 additions and 1061 deletions
@@ -36,6 +36,8 @@ export const fromLogicFunctionManifestToUniversalFlatLogicFunction = ({
logicFunctionManifest.databaseEventTriggerSettings ?? null,
httpRouteTriggerSettings:
logicFunctionManifest.httpRouteTriggerSettings ?? null,
serverRouteTriggerSettings:
logicFunctionManifest.serverRouteTriggerSettings ?? null,
toolTriggerSettings: logicFunctionManifest.toolTriggerSettings ?? null,
workflowActionTriggerSettings:
logicFunctionManifest.workflowActionTriggerSettings ?? null,
@@ -0,0 +1,325 @@
import { type Request } from 'express';
import { type Repository } from 'typeorm';
import { type LogicFunctionExecuteResult } from 'src/engine/core-modules/logic-function/logic-function-drivers/interfaces/logic-function-driver.interface';
import {
LogicFunctionExecutionException,
LogicFunctionExecutionExceptionCode,
type LogicFunctionExecutorService,
} from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service';
import { ServerRouteTriggerExceptionCode } from 'src/engine/core-modules/server-route-trigger/exceptions/server-route-trigger.exception';
import { ServerRouteTriggerService } from 'src/engine/core-modules/server-route-trigger/server-route-trigger.service';
import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { type LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
import { LogicFunctionExecutionStatus } from 'src/engine/metadata-modules/logic-function/dtos/logic-function-execution-result.dto';
const RESOLVER_UID = 'resolver-uid';
const TARGET_UID = 'target-uid';
const buildExecuteResult = (
data: object | null,
error?: { errorMessage: string },
): LogicFunctionExecuteResult => ({
data,
duration: 1,
logs: '',
status: error
? LogicFunctionExecutionStatus.ERROR
: LogicFunctionExecutionStatus.SUCCESS,
...(error
? {
error: {
errorType: 'Error',
errorMessage: error.errorMessage,
stackTrace: '',
},
}
: {}),
});
const buildRequest = (body: object | null = {}): Request =>
({
method: 'POST',
path: `/webhooks/server/${RESOLVER_UID}`,
query: {},
headers: {},
rawBody: Buffer.from(JSON.stringify(body ?? {}), 'utf-8'),
body,
}) as unknown as Request;
describe('ServerRouteTriggerService', () => {
let service: ServerRouteTriggerService;
let logicFunctionRepository: jest.Mocked<
Pick<Repository<LogicFunctionEntity>, 'find' | 'findOne'>
>;
let logicFunctionExecutorService: jest.Mocked<
Pick<LogicFunctionExecutorService, 'execute'>
>;
let twentyConfigService: jest.Mocked<Pick<TwentyConfigService, 'get'>>;
const handle = () =>
service.handle({
request: buildRequest(),
resolverLogicFunctionUniversalIdentifier: RESOLVER_UID,
});
const buildResolverRow = (overrides: Record<string, unknown> = {}) => ({
id: 'resolver-id',
universalIdentifier: RESOLVER_UID,
workspaceId: 'owner-ws',
serverRouteTriggerSettings: { forwardedRequestHeaders: ['x-test'] },
application: {
applicationRegistration: { ownerWorkspaceId: 'owner-ws' },
},
...overrides,
});
beforeEach(() => {
logicFunctionRepository = {
find: jest.fn().mockResolvedValue([buildResolverRow()]),
findOne: jest
.fn()
// resolver lookup inside runFunction
.mockResolvedValueOnce({ id: 'resolver-id' })
// target lookup inside runFunction
.mockResolvedValueOnce({ id: 'target-id' }),
};
logicFunctionExecutorService = {
execute: jest
.fn()
// resolver returns { workspaceId, targetLogicFunctionUniversalIdentifier, payload }
.mockResolvedValueOnce(
buildExecuteResult({
workspaceId: 'target-ws',
targetLogicFunctionUniversalIdentifier: TARGET_UID,
payload: { from: 'resolver' },
}),
)
// target returns the final response body
.mockResolvedValueOnce(buildExecuteResult({ ok: true })),
};
twentyConfigService = { get: jest.fn().mockReturnValue(true) };
service = new ServerRouteTriggerService(
logicFunctionRepository as unknown as Repository<LogicFunctionEntity>,
logicFunctionExecutorService as unknown as LogicFunctionExecutorService,
twentyConfigService as unknown as TwentyConfigService,
);
});
it('runs the resolver in the owner workspace then the resolver-named target in the resolved workspace', async () => {
const result = await handle();
expect(logicFunctionExecutorService.execute).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
logicFunctionId: 'resolver-id',
workspaceId: 'owner-ws',
}),
);
expect(logicFunctionExecutorService.execute).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
logicFunctionId: 'target-id',
workspaceId: 'target-ws',
payload: { from: 'resolver' },
}),
);
expect(result).toEqual(
expect.objectContaining({
statusCode: 200,
body: { ok: true },
}),
);
});
it('refuses when the feature is disabled', async () => {
twentyConfigService.get.mockReturnValue(false);
await expect(handle()).rejects.toMatchObject({
code: ServerRouteTriggerExceptionCode.FEATURE_DISABLED,
});
});
it('throws LOGIC_FUNCTION_NOT_FOUND when no row matches the universalIdentifier', async () => {
logicFunctionRepository.find.mockResolvedValue([]);
await expect(handle()).rejects.toMatchObject({
code: ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND,
});
});
it('throws LOGIC_FUNCTION_NOT_FOUND when only non-owner-workspace copies exist', async () => {
logicFunctionRepository.find.mockResolvedValue([
buildResolverRow({
workspaceId: 'other-ws',
application: {
applicationRegistration: { ownerWorkspaceId: 'owner-ws' },
},
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] as any);
await expect(handle()).rejects.toMatchObject({
code: ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND,
});
});
it('picks the owner-workspace copy when multiple workspaces installed the app', async () => {
logicFunctionRepository.find.mockResolvedValue([
buildResolverRow({
id: 'tenant-copy',
workspaceId: 'tenant-ws',
application: {
applicationRegistration: { ownerWorkspaceId: 'owner-ws' },
},
}),
buildResolverRow({
id: 'owner-copy',
workspaceId: 'owner-ws',
application: {
applicationRegistration: { ownerWorkspaceId: 'owner-ws' },
},
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] as any);
await handle();
// runFunction's internal findOne is called with the
// (universalIdentifier, workspaceId) of the owner-workspace copy.
expect(logicFunctionRepository.findOne).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
where: expect.objectContaining({
universalIdentifier: RESOLVER_UID,
workspaceId: 'owner-ws',
}),
}),
);
});
it('throws RESOLVER_INVALID_RESULT when the resolver does not return a workspaceId', async () => {
logicFunctionExecutorService.execute.mockReset();
logicFunctionExecutorService.execute.mockResolvedValueOnce(
buildExecuteResult({
targetLogicFunctionUniversalIdentifier: TARGET_UID,
}),
);
await expect(handle()).rejects.toMatchObject({
code: ServerRouteTriggerExceptionCode.RESOLVER_INVALID_RESULT,
});
});
it('throws RESOLVER_INVALID_RESULT when the resolver does not return a targetLogicFunctionUniversalIdentifier', async () => {
logicFunctionExecutorService.execute.mockReset();
logicFunctionExecutorService.execute.mockResolvedValueOnce(
buildExecuteResult({ workspaceId: 'target-ws' }),
);
await expect(handle()).rejects.toMatchObject({
code: ServerRouteTriggerExceptionCode.RESOLVER_INVALID_RESULT,
});
});
it('throws USER_UNCAUGHT_ERROR when the resolver returns an error', async () => {
logicFunctionExecutorService.execute.mockReset();
logicFunctionExecutorService.execute.mockResolvedValueOnce(
buildExecuteResult(null, { errorMessage: 'boom' }),
);
await expect(handle()).rejects.toMatchObject({
code: ServerRouteTriggerExceptionCode.SERVER_ROUTE_USER_UNCAUGHT_ERROR,
});
});
it('throws LOGIC_FUNCTION_NOT_FOUND when the target named by the resolver is missing in the resolved workspace', async () => {
logicFunctionRepository.findOne.mockReset();
logicFunctionRepository.findOne
// resolver lookup succeeds
.mockResolvedValueOnce({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
id: 'resolver-id',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any)
// target lookup returns null
.mockResolvedValueOnce(null);
await expect(handle()).rejects.toMatchObject({
code: ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND,
});
});
it('surfaces a target userError as a server-route exception', async () => {
logicFunctionExecutorService.execute.mockReset();
logicFunctionExecutorService.execute
.mockResolvedValueOnce(
buildExecuteResult({
workspaceId: 'target-ws',
targetLogicFunctionUniversalIdentifier: TARGET_UID,
}),
)
.mockResolvedValueOnce(
buildExecuteResult(null, { errorMessage: 'boom' }),
);
await expect(handle()).rejects.toMatchObject({
code: ServerRouteTriggerExceptionCode.SERVER_ROUTE_USER_UNCAUGHT_ERROR,
});
});
it('maps a LogicFunctionExecutionException(LOGIC_FUNCTION_NOT_FOUND) to the server-route not-found code', async () => {
logicFunctionExecutorService.execute.mockReset();
logicFunctionExecutorService.execute.mockRejectedValue(
new LogicFunctionExecutionException(
'not found',
LogicFunctionExecutionExceptionCode.LOGIC_FUNCTION_NOT_FOUND,
),
);
await expect(handle()).rejects.toMatchObject({
code: ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND,
});
});
it('falls back to PLATFORM_ERROR for any other thrown executor error', async () => {
logicFunctionExecutorService.execute.mockReset();
logicFunctionExecutorService.execute.mockRejectedValue(new Error('boom'));
await expect(handle()).rejects.toMatchObject({
code: ServerRouteTriggerExceptionCode.SERVER_ROUTE_PLATFORM_ERROR,
});
});
it('looks up the resolver by universalIdentifier and loads the application registration chain', async () => {
await handle();
const findArgs = logicFunctionRepository.find.mock.calls[0][0];
expect(findArgs?.where).toEqual(
expect.objectContaining({ universalIdentifier: RESOLVER_UID }),
);
expect(findArgs?.relations).toEqual(
expect.objectContaining({
application: expect.objectContaining({
applicationRegistration: true,
}),
}),
);
});
it('maps a LogicFunctionExecutionException(RATE_LIMIT_EXCEEDED) to the server-route rate-limit code', async () => {
logicFunctionExecutorService.execute.mockReset();
logicFunctionExecutorService.execute.mockRejectedValue(
new LogicFunctionExecutionException(
'too many requests',
LogicFunctionExecutionExceptionCode.RATE_LIMIT_EXCEEDED,
),
);
await expect(handle()).rejects.toMatchObject({
code: ServerRouteTriggerExceptionCode.RATE_LIMIT_EXCEEDED,
});
});
});
@@ -6,40 +6,46 @@ import {
import type { Response } from 'express';
import {
ServerWebhookTriggerException,
ServerWebhookTriggerExceptionCode,
} from 'src/engine/core-modules/server-webhook-trigger/exceptions/server-webhook-trigger.exception';
import type { CustomException } from 'src/utils/custom-exception';
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
import {
ServerRouteTriggerException,
ServerRouteTriggerExceptionCode,
} from 'src/engine/core-modules/server-route-trigger/exceptions/server-route-trigger.exception';
import type { CustomException } from 'src/utils/custom-exception';
@Catch(ServerWebhookTriggerException)
export class ServerWebhookTriggerRestApiExceptionFilter implements ExceptionFilter {
@Catch(ServerRouteTriggerException)
export class ServerRouteTriggerRestApiExceptionFilter implements ExceptionFilter {
constructor(
private readonly httpExceptionHandlerService: HttpExceptionHandlerService,
) {}
catch(exception: ServerWebhookTriggerException, host: ArgumentsHost) {
catch(exception: ServerRouteTriggerException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
switch (exception.code) {
case ServerWebhookTriggerExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND:
case ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_TRIGGER_NOT_CONFIGURED:
case ServerWebhookTriggerExceptionCode.APPLICATION_NOT_INSTALLED:
case ServerWebhookTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND:
case ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND:
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
404,
);
case ServerWebhookTriggerExceptionCode.WORKSPACE_ID_NOT_RESOLVED:
case ServerRouteTriggerExceptionCode.FEATURE_DISABLED:
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
400,
503,
);
case ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_USER_UNCAUGHT_ERROR:
case ServerRouteTriggerExceptionCode.RATE_LIMIT_EXCEEDED:
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
429,
undefined,
undefined,
{ shouldBeCapturedBySentry: false },
);
case ServerRouteTriggerExceptionCode.SERVER_ROUTE_USER_UNCAUGHT_ERROR:
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
@@ -48,12 +54,21 @@ export class ServerWebhookTriggerRestApiExceptionFilter implements ExceptionFilt
undefined,
{ shouldBeCapturedBySentry: false },
);
case ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_PLATFORM_ERROR:
case ServerRouteTriggerExceptionCode.SERVER_ROUTE_PLATFORM_ERROR:
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
500,
);
case ServerRouteTriggerExceptionCode.RESOLVER_INVALID_RESULT:
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
502,
undefined,
undefined,
{ shouldBeCapturedBySentry: false },
);
default: {
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
@@ -0,0 +1,49 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { CustomException } from 'src/utils/custom-exception';
export enum ServerRouteTriggerExceptionCode {
FEATURE_DISABLED = 'FEATURE_DISABLED',
LOGIC_FUNCTION_NOT_FOUND = 'LOGIC_FUNCTION_NOT_FOUND',
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
SERVER_ROUTE_USER_UNCAUGHT_ERROR = 'SERVER_ROUTE_USER_UNCAUGHT_ERROR',
SERVER_ROUTE_PLATFORM_ERROR = 'SERVER_ROUTE_PLATFORM_ERROR',
RESOLVER_INVALID_RESULT = 'RESOLVER_INVALID_RESULT',
}
const getServerRouteTriggerExceptionUserFriendlyMessage = (
code: ServerRouteTriggerExceptionCode,
) => {
switch (code) {
case ServerRouteTriggerExceptionCode.FEATURE_DISABLED:
return msg`Server logic functions are disabled on this instance.`;
case ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND:
return msg`Server logic function not found.`;
case ServerRouteTriggerExceptionCode.RATE_LIMIT_EXCEEDED:
return msg`Rate limit exceeded.`;
case ServerRouteTriggerExceptionCode.SERVER_ROUTE_USER_UNCAUGHT_ERROR:
return msg`Logic function execution failed.`;
case ServerRouteTriggerExceptionCode.SERVER_ROUTE_PLATFORM_ERROR:
return msg`An unexpected error occurred while handling the server route.`;
case ServerRouteTriggerExceptionCode.RESOLVER_INVALID_RESULT:
return msg`Resolver logic function returned an invalid result.`;
default:
assertUnreachable(code);
}
};
export class ServerRouteTriggerException extends CustomException<ServerRouteTriggerExceptionCode> {
constructor(
message: string,
code: ServerRouteTriggerExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ??
getServerRouteTriggerExceptionUserFriendlyMessage(code),
});
}
}
@@ -0,0 +1,42 @@
import {
Controller,
Param,
Post,
Req,
Res,
UseFilters,
UseGuards,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { sendRouteTriggerResponse } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util';
import { ServerRouteTriggerRestApiExceptionFilter } from 'src/engine/core-modules/server-route-trigger/exceptions/server-route-trigger-rest-api-exception-filter';
import { ServerRouteTriggerService } from 'src/engine/core-modules/server-route-trigger/server-route-trigger.service';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
@Controller('webhooks/server')
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
@UseFilters(ServerRouteTriggerRestApiExceptionFilter)
export class ServerRouteTriggerController {
constructor(
private readonly serverRouteTriggerService: ServerRouteTriggerService,
) {}
@Post(':resolverLogicFunctionUniversalIdentifier')
async post(
@Param('resolverLogicFunctionUniversalIdentifier')
resolverLogicFunctionUniversalIdentifier: string,
@Req() request: Request,
@Res() response: Response,
) {
sendRouteTriggerResponse(
response,
await this.serverRouteTriggerService.handle({
request,
resolverLogicFunctionUniversalIdentifier,
}),
);
}
}
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { LogicFunctionExecutorModule } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.module';
import { ServerRouteTriggerController } from 'src/engine/core-modules/server-route-trigger/server-route-trigger.controller';
import { ServerRouteTriggerService } from 'src/engine/core-modules/server-route-trigger/server-route-trigger.service';
import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
@Module({
imports: [
TypeOrmModule.forFeature([LogicFunctionEntity]),
LogicFunctionExecutorModule,
],
controllers: [ServerRouteTriggerController],
providers: [ServerRouteTriggerService],
})
export class ServerRouteTriggerModule {}
@@ -0,0 +1,216 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isString } from '@sniptt/guards';
import { Request } from 'express';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import {
LogicFunctionExecutionException,
LogicFunctionExecutionExceptionCode,
LogicFunctionExecutorService,
} from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service';
import { buildLogicFunctionEvent } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/build-logic-function-event.util';
import {
type RouteTriggerResponse,
buildRouteTriggerResponse,
} from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util';
import {
ServerRouteTriggerException,
ServerRouteTriggerExceptionCode,
} from 'src/engine/core-modules/server-route-trigger/exceptions/server-route-trigger.exception';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
type ResolverResult = {
workspaceId: string;
targetLogicFunctionUniversalIdentifier: string;
payload?: object;
};
@Injectable()
export class ServerRouteTriggerService {
private readonly logger = new Logger(ServerRouteTriggerService.name);
constructor(
@InjectRepository(LogicFunctionEntity)
private readonly logicFunctionRepository: Repository<LogicFunctionEntity>,
private readonly logicFunctionExecutorService: LogicFunctionExecutorService,
private readonly twentyConfigService: TwentyConfigService,
) {}
async handle({
request,
resolverLogicFunctionUniversalIdentifier,
}: {
request: Request;
resolverLogicFunctionUniversalIdentifier: string;
}): Promise<RouteTriggerResponse> {
if (!this.twentyConfigService.get('IS_SERVER_LOGIC_FUNCTION_ENABLED')) {
throw new ServerRouteTriggerException(
'Server logic functions are disabled on this instance',
ServerRouteTriggerExceptionCode.FEATURE_DISABLED,
);
}
const resolver = await this.findResolver({
logicFunctionUniversalIdentifier:
resolverLogicFunctionUniversalIdentifier,
});
if (!isDefined(resolver)) {
throw new ServerRouteTriggerException(
`Server resolver function ${resolverLogicFunctionUniversalIdentifier} not found`,
ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND,
);
}
const event = buildLogicFunctionEvent({
request,
pathParameters: {},
forwardedRequestHeaders:
resolver.serverRouteTriggerSettings?.forwardedRequestHeaders ?? [],
userWorkspaceId: null,
});
const resolverResult = await this.runFunction({
logicFunctionUniversalIdentifier: resolver.universalIdentifier,
workspaceId: resolver.workspaceId,
payload: event,
});
const resolved = this.parseResolverResult(resolverResult);
const targetResult = await this.runFunction({
logicFunctionUniversalIdentifier:
resolved.targetLogicFunctionUniversalIdentifier,
workspaceId: resolved.workspaceId,
payload: resolved.payload ?? event,
});
if (isDefined(targetResult.error)) {
throw new ServerRouteTriggerException(
targetResult.error.errorMessage,
ServerRouteTriggerExceptionCode.SERVER_ROUTE_USER_UNCAUGHT_ERROR,
);
}
return buildRouteTriggerResponse(targetResult.data);
}
private async findResolver({
logicFunctionUniversalIdentifier,
}: {
logicFunctionUniversalIdentifier: string;
}): Promise<LogicFunctionEntity | null> {
const candidates = await this.logicFunctionRepository.find({
where: { universalIdentifier: logicFunctionUniversalIdentifier },
relations: { application: { applicationRegistration: true } },
});
return (
candidates.find(
(candidate) =>
isDefined(candidate.application?.applicationRegistration) &&
candidate.workspaceId ===
candidate.application.applicationRegistration.ownerWorkspaceId,
) ?? null
);
}
private parseResolverResult(result: {
data: object | null;
error?: { errorMessage: string };
}): ResolverResult {
if (isDefined(result.error)) {
throw new ServerRouteTriggerException(
result.error.errorMessage,
ServerRouteTriggerExceptionCode.SERVER_ROUTE_USER_UNCAUGHT_ERROR,
);
}
const data = result.data as {
workspaceId?: unknown;
targetLogicFunctionUniversalIdentifier?: unknown;
payload?: unknown;
};
if (
!isString(data?.workspaceId) ||
!isString(data?.targetLogicFunctionUniversalIdentifier)
) {
throw new ServerRouteTriggerException(
'Resolver logic function must return { workspaceId: string; targetLogicFunctionUniversalIdentifier: string; payload?: object }',
ServerRouteTriggerExceptionCode.RESOLVER_INVALID_RESULT,
);
}
return {
workspaceId: data.workspaceId,
targetLogicFunctionUniversalIdentifier:
data.targetLogicFunctionUniversalIdentifier,
payload:
typeof data.payload === 'object' && data.payload !== null
? (data.payload as object)
: undefined,
};
}
private async runFunction({
logicFunctionUniversalIdentifier,
workspaceId,
payload,
}: {
logicFunctionUniversalIdentifier: string;
workspaceId: string;
payload: object;
}): Promise<{ data: object | null; error?: { errorMessage: string } }> {
const logicFunction = await this.logicFunctionRepository.findOne({
where: {
universalIdentifier: logicFunctionUniversalIdentifier,
workspaceId,
},
});
if (!isDefined(logicFunction)) {
throw new ServerRouteTriggerException(
`Logic function ${logicFunctionUniversalIdentifier} not found in workspace ${workspaceId}`,
ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND,
);
}
try {
return await this.logicFunctionExecutorService.execute({
logicFunctionId: logicFunction.id,
workspaceId,
payload,
});
} catch (error) {
this.logger.error(
`Server logic function ${logicFunction.id} failed in workspace ${workspaceId}: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error.stack : undefined,
);
throw new ServerRouteTriggerException(
error instanceof Error ? error.message : String(error),
this.mapExecutorErrorToServerRouteCode(error),
);
}
}
private mapExecutorErrorToServerRouteCode(
error: unknown,
): ServerRouteTriggerExceptionCode {
if (!(error instanceof LogicFunctionExecutionException)) {
return ServerRouteTriggerExceptionCode.SERVER_ROUTE_PLATFORM_ERROR;
}
switch (error.code) {
case LogicFunctionExecutionExceptionCode.LOGIC_FUNCTION_NOT_FOUND:
return ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND;
case LogicFunctionExecutionExceptionCode.RATE_LIMIT_EXCEEDED:
return ServerRouteTriggerExceptionCode.RATE_LIMIT_EXCEEDED;
default:
return ServerRouteTriggerExceptionCode.SERVER_ROUTE_PLATFORM_ERROR;
}
}
}
@@ -1,172 +0,0 @@
import { type Request } from 'express';
import { type Repository } from 'typeorm';
import { ServerWebhookTriggerService } from 'src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.service';
import { ServerWebhookTriggerExceptionCode } from 'src/engine/core-modules/server-webhook-trigger/exceptions/server-webhook-trigger.exception';
import { type ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { type LogicFunctionTriggerService } from 'src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.service';
import { type LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
const REGISTRATION_UID = 'reg-universal-id';
const LOGIC_FUNCTION_UID = 'lf-universal-id';
const WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000';
const buildRequest = (body: object | null): Request =>
({
method: 'POST',
path: `/webhooks/server/${REGISTRATION_UID}/${LOGIC_FUNCTION_UID}`,
query: {},
headers: {},
rawBody: Buffer.from(JSON.stringify(body ?? {}), 'utf-8'),
body,
}) as unknown as Request;
type RegistrationResult = Awaited<
ReturnType<ApplicationRegistrationService['findOneByUniversalIdentifier']>
>;
const asRegistration = (value: object): RegistrationResult =>
value as unknown as RegistrationResult;
const REGISTRATION_WITH_TRIGGER = asRegistration({
id: 'reg-1',
manifest: {
logicFunctions: [
{
universalIdentifier: LOGIC_FUNCTION_UID,
serverWebhookTriggerSettings: {
workspaceIdResolver: {
source: 'body',
path: 'metadata.twentyWorkspaceId',
},
},
},
],
},
});
describe('ServerWebhookTriggerService', () => {
let service: ServerWebhookTriggerService;
let applicationRegistrationService: jest.Mocked<
Pick<ApplicationRegistrationService, 'findOneByUniversalIdentifier'>
>;
let logicFunctionTriggerService: jest.Mocked<
Pick<LogicFunctionTriggerService, 'run'>
>;
let logicFunctionRepository: jest.Mocked<
Pick<Repository<LogicFunctionEntity>, 'findOne'>
>;
let applicationRepository: jest.Mocked<
Pick<Repository<ApplicationEntity>, 'findOne'>
>;
const handle = (
body: object | null = { metadata: { twentyWorkspaceId: WORKSPACE_ID } },
) =>
service.handle({
request: buildRequest(body),
applicationRegistrationUniversalIdentifier: REGISTRATION_UID,
logicFunctionUniversalIdentifier: LOGIC_FUNCTION_UID,
});
beforeEach(() => {
applicationRegistrationService = {
findOneByUniversalIdentifier: jest
.fn()
.mockResolvedValue(REGISTRATION_WITH_TRIGGER),
};
logicFunctionTriggerService = {
run: jest.fn().mockResolvedValue({
kind: 'response',
response: { statusCode: 200, headers: {}, body: { ok: true } },
}),
};
logicFunctionRepository = {
findOne: jest.fn().mockResolvedValue({ id: 'lf-1' }),
};
applicationRepository = {
findOne: jest
.fn()
.mockResolvedValue({ id: 'app-1', workspaceId: WORKSPACE_ID }),
};
service = new ServerWebhookTriggerService(
applicationRegistrationService as unknown as ApplicationRegistrationService,
logicFunctionTriggerService as unknown as LogicFunctionTriggerService,
logicFunctionRepository as unknown as Repository<LogicFunctionEntity>,
applicationRepository as unknown as Repository<ApplicationEntity>,
);
});
it('resolves the workspace and runs the function synchronously', async () => {
const result = await handle();
expect(applicationRepository.findOne).toHaveBeenCalledWith({
where: { workspaceId: WORKSPACE_ID, applicationRegistrationId: 'reg-1' },
});
expect(logicFunctionTriggerService.run).toHaveBeenCalledWith(
expect.objectContaining({ logicFunction: { id: 'lf-1' } }),
);
expect(result).toEqual({
statusCode: 200,
headers: {},
body: { ok: true },
});
});
it('throws when the application registration does not exist', async () => {
applicationRegistrationService.findOneByUniversalIdentifier.mockResolvedValue(
null,
);
await expect(handle()).rejects.toMatchObject({
code: ServerWebhookTriggerExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND,
});
});
it('throws when the logic function has no webhook trigger settings', async () => {
applicationRegistrationService.findOneByUniversalIdentifier.mockResolvedValue(
asRegistration({ id: 'reg-1', manifest: { logicFunctions: [] } }),
);
await expect(handle()).rejects.toMatchObject({
code: ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_TRIGGER_NOT_CONFIGURED,
});
});
it('throws when the resolved workspaceId is not a valid uuid', async () => {
await expect(
handle({ metadata: { twentyWorkspaceId: 'not-a-uuid' } }),
).rejects.toMatchObject({
code: ServerWebhookTriggerExceptionCode.WORKSPACE_ID_NOT_RESOLVED,
});
});
it('throws when the app is not installed in the resolved workspace', async () => {
applicationRepository.findOne.mockResolvedValue(null);
await expect(handle()).rejects.toMatchObject({
code: ServerWebhookTriggerExceptionCode.APPLICATION_NOT_INSTALLED,
});
});
it('throws when the function is not installed in the workspace', async () => {
logicFunctionRepository.findOne.mockResolvedValue(null);
await expect(handle()).rejects.toMatchObject({
code: ServerWebhookTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND,
});
});
it('surfaces a user uncaught error from the function', async () => {
logicFunctionTriggerService.run.mockResolvedValue({
kind: 'userError',
errorMessage: 'boom',
});
await expect(handle()).rejects.toMatchObject({
code: ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_USER_UNCAUGHT_ERROR,
});
});
});
@@ -1,52 +0,0 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { CustomException } from 'src/utils/custom-exception';
export enum ServerWebhookTriggerExceptionCode {
APPLICATION_REGISTRATION_NOT_FOUND = 'APPLICATION_REGISTRATION_NOT_FOUND',
SERVER_WEBHOOK_TRIGGER_NOT_CONFIGURED = 'SERVER_WEBHOOK_TRIGGER_NOT_CONFIGURED',
WORKSPACE_ID_NOT_RESOLVED = 'WORKSPACE_ID_NOT_RESOLVED',
APPLICATION_NOT_INSTALLED = 'APPLICATION_NOT_INSTALLED',
LOGIC_FUNCTION_NOT_FOUND = 'LOGIC_FUNCTION_NOT_FOUND',
SERVER_WEBHOOK_USER_UNCAUGHT_ERROR = 'SERVER_WEBHOOK_USER_UNCAUGHT_ERROR',
SERVER_WEBHOOK_PLATFORM_ERROR = 'SERVER_WEBHOOK_PLATFORM_ERROR',
}
const getServerWebhookTriggerExceptionUserFriendlyMessage = (
code: ServerWebhookTriggerExceptionCode,
) => {
switch (code) {
case ServerWebhookTriggerExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND:
return msg`Application registration not found.`;
case ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_TRIGGER_NOT_CONFIGURED:
return msg`Server webhook trigger is not configured for this application registration.`;
case ServerWebhookTriggerExceptionCode.WORKSPACE_ID_NOT_RESOLVED:
return msg`Could not resolve a workspace from the webhook payload.`;
case ServerWebhookTriggerExceptionCode.APPLICATION_NOT_INSTALLED:
return msg`Application is not installed in this workspace.`;
case ServerWebhookTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND:
return msg`Logic function not found.`;
case ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_USER_UNCAUGHT_ERROR:
return msg`Logic function execution failed.`;
case ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_PLATFORM_ERROR:
return msg`An unexpected error occurred while handling the webhook.`;
default:
assertUnreachable(code);
}
};
export class ServerWebhookTriggerException extends CustomException<ServerWebhookTriggerExceptionCode> {
constructor(
message: string,
code: ServerWebhookTriggerExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ??
getServerWebhookTriggerExceptionUserFriendlyMessage(code),
});
}
}
@@ -1,47 +0,0 @@
import {
Controller,
Param,
Post,
Req,
Res,
UseFilters,
UseGuards,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { ServerWebhookTriggerRestApiExceptionFilter } from 'src/engine/core-modules/server-webhook-trigger/exceptions/server-webhook-trigger-rest-api-exception-filter';
import { ServerWebhookTriggerService } from 'src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.service';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
import { sendRouteTriggerResponse } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util';
@Controller('webhooks/server')
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
@UseFilters(ServerWebhookTriggerRestApiExceptionFilter)
export class ServerWebhookTriggerController {
constructor(
private readonly serverWebhookTriggerService: ServerWebhookTriggerService,
) {}
@Post(
':applicationRegistrationUniversalIdentifier/:logicFunctionUniversalIdentifier',
)
async post(
@Param('applicationRegistrationUniversalIdentifier')
applicationRegistrationUniversalIdentifier: string,
@Param('logicFunctionUniversalIdentifier')
logicFunctionUniversalIdentifier: string,
@Req() request: Request,
@Res() response: Response,
) {
sendRouteTriggerResponse(
response,
await this.serverWebhookTriggerService.handle({
request,
applicationRegistrationUniversalIdentifier,
logicFunctionUniversalIdentifier,
}),
);
}
}
@@ -1,20 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ServerWebhookTriggerController } from 'src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.controller';
import { ServerWebhookTriggerService } from 'src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.service';
import { LogicFunctionTriggerModule } from 'src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.module';
import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
@Module({
imports: [
TypeOrmModule.forFeature([LogicFunctionEntity, ApplicationEntity]),
ApplicationRegistrationModule,
LogicFunctionTriggerModule,
],
controllers: [ServerWebhookTriggerController],
providers: [ServerWebhookTriggerService],
})
export class ServerWebhookTriggerModule {}
@@ -1,184 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Request } from 'express';
import { type ServerWebhookTriggerSettings } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { validate as uuidValidate } from 'uuid';
import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import {
ServerWebhookTriggerException,
ServerWebhookTriggerExceptionCode,
} from 'src/engine/core-modules/server-webhook-trigger/exceptions/server-webhook-trigger.exception';
import { resolveWorkspaceIdFromRequest } from 'src/engine/core-modules/server-webhook-trigger/utils/resolve-workspace-id-from-request.util';
import {
LogicFunctionExecutionException,
LogicFunctionExecutionExceptionCode,
} from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service';
import { LogicFunctionTriggerService } from 'src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.service';
import { type RouteTriggerResponse } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util';
import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
const WEBHOOK_WORKSPACE_ID_SOURCES = new Set(['body', 'query', 'header']);
@Injectable()
export class ServerWebhookTriggerService {
private readonly logger = new Logger(ServerWebhookTriggerService.name);
constructor(
private readonly applicationRegistrationService: ApplicationRegistrationService,
private readonly logicFunctionTriggerService: LogicFunctionTriggerService,
@InjectRepository(LogicFunctionEntity)
private readonly logicFunctionRepository: Repository<LogicFunctionEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
) {}
private getServerWebhookTriggerSettingsOrThrow(
settings: ServerWebhookTriggerSettings | undefined,
): ServerWebhookTriggerSettings {
const resolver = settings?.workspaceIdResolver;
if (
!isDefined(resolver) ||
!WEBHOOK_WORKSPACE_ID_SOURCES.has(resolver.source) ||
typeof resolver.path !== 'string' ||
resolver.path.length === 0
) {
throw new ServerWebhookTriggerException(
'Server webhook trigger is not configured for this logic function',
ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_TRIGGER_NOT_CONFIGURED,
);
}
return settings as ServerWebhookTriggerSettings;
}
async handle({
request,
applicationRegistrationUniversalIdentifier,
logicFunctionUniversalIdentifier,
}: {
request: Request;
applicationRegistrationUniversalIdentifier: string;
logicFunctionUniversalIdentifier: string;
}): Promise<RouteTriggerResponse> {
const applicationRegistration =
await this.applicationRegistrationService.findOneByUniversalIdentifier(
applicationRegistrationUniversalIdentifier,
);
if (!isDefined(applicationRegistration)) {
throw new ServerWebhookTriggerException(
`Application registration ${applicationRegistrationUniversalIdentifier} not found`,
ServerWebhookTriggerExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND,
);
}
const logicFunctionManifest =
applicationRegistration.manifest?.logicFunctions?.find(
(candidate) =>
candidate.universalIdentifier === logicFunctionUniversalIdentifier,
);
const serverWebhookTriggerSettings =
this.getServerWebhookTriggerSettingsOrThrow(
logicFunctionManifest?.serverWebhookTriggerSettings,
);
const workspaceId = resolveWorkspaceIdFromRequest({
resolver: serverWebhookTriggerSettings.workspaceIdResolver,
request,
});
if (!isDefined(workspaceId) || !uuidValidate(workspaceId)) {
throw new ServerWebhookTriggerException(
'Could not resolve a valid workspaceId from the webhook payload',
ServerWebhookTriggerExceptionCode.WORKSPACE_ID_NOT_RESOLVED,
);
}
const application = await this.applicationRepository.findOne({
where: {
workspaceId,
applicationRegistrationId: applicationRegistration.id,
},
});
if (!isDefined(application)) {
throw new ServerWebhookTriggerException(
`Application is not installed in workspace ${workspaceId} for this registration`,
ServerWebhookTriggerExceptionCode.APPLICATION_NOT_INSTALLED,
);
}
const logicFunction = await this.logicFunctionRepository.findOne({
where: {
workspaceId,
applicationId: application.id,
universalIdentifier: logicFunctionUniversalIdentifier,
},
});
if (!isDefined(logicFunction)) {
throw new ServerWebhookTriggerException(
`Logic function ${logicFunctionUniversalIdentifier} is not installed in workspace ${workspaceId}`,
ServerWebhookTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND,
);
}
let outcome;
try {
outcome = await this.logicFunctionTriggerService.run({
logicFunction,
request,
pathParameters: {},
forwardedRequestHeaders:
serverWebhookTriggerSettings.forwardedRequestHeaders ?? [],
userId: null,
userWorkspaceId: null,
});
} catch (error) {
if (error instanceof ServerWebhookTriggerException) {
throw error;
}
this.logger.error(
`Unexpected error executing logic function ${logicFunction.id}: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? error.stack : undefined,
);
throw new ServerWebhookTriggerException(
`Logic function execution failed for ${logicFunction.id}`,
this.mapErrorToWebhookCode(error),
);
}
if (outcome.kind === 'userError') {
throw new ServerWebhookTriggerException(
outcome.errorMessage,
ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_USER_UNCAUGHT_ERROR,
);
}
return outcome.response;
}
private mapErrorToWebhookCode(
error: unknown,
): ServerWebhookTriggerExceptionCode {
if (
error instanceof LogicFunctionExecutionException &&
error.code ===
LogicFunctionExecutionExceptionCode.LOGIC_FUNCTION_NOT_FOUND
) {
return ServerWebhookTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND;
}
return ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_PLATFORM_ERROR;
}
}
@@ -1,89 +0,0 @@
import { type Request } from 'express';
import { resolveWorkspaceIdFromRequest } from 'src/engine/core-modules/server-webhook-trigger/utils/resolve-workspace-id-from-request.util';
const buildRequest = (overrides: Partial<Request>): Request =>
({ query: {}, headers: {}, body: null, ...overrides }) as unknown as Request;
describe('resolveWorkspaceIdFromRequest', () => {
it('resolves a nested value from the body', () => {
const request = buildRequest({
body: { metadata: { twentyWorkspaceId: 'ws-1' } },
});
expect(
resolveWorkspaceIdFromRequest({
resolver: { source: 'body', path: 'metadata.twentyWorkspaceId' },
request,
}),
).toBe('ws-1');
});
it('resolves from a query parameter', () => {
const request = buildRequest({ query: { twentyWorkspaceId: 'ws-2' } });
expect(
resolveWorkspaceIdFromRequest({
resolver: { source: 'query', path: 'twentyWorkspaceId' },
request,
}),
).toBe('ws-2');
});
it('resolves from a header (taking the first value of an array)', () => {
const request = buildRequest({
headers: { 'x-workspace-id': ['ws-3', 'ws-other'] } as Request['headers'],
});
expect(
resolveWorkspaceIdFromRequest({
resolver: { source: 'header', path: 'x-workspace-id' },
request,
}),
).toBe('ws-3');
});
it('rejects prototype-pollution path segments', () => {
const request = buildRequest({ body: { metadata: { id: 'ws-4' } } });
expect(
resolveWorkspaceIdFromRequest({
resolver: { source: 'body', path: '__proto__.id' },
request,
}),
).toBeUndefined();
});
it('resolves kebab-case keys (e.g. header names)', () => {
const request = buildRequest({ body: { 'a-b': 'ws-5' } });
expect(
resolveWorkspaceIdFromRequest({
resolver: { source: 'body', path: 'a-b' },
request,
}),
).toBe('ws-5');
});
it('rejects path segments with unsafe characters', () => {
const request = buildRequest({ body: { 'a b': 'ws-6' } });
expect(
resolveWorkspaceIdFromRequest({
resolver: { source: 'body', path: 'a b' },
request,
}),
).toBeUndefined();
});
it('returns undefined when the value is absent', () => {
const request = buildRequest({ body: { metadata: {} } });
expect(
resolveWorkspaceIdFromRequest({
resolver: { source: 'body', path: 'metadata.twentyWorkspaceId' },
request,
}),
).toBeUndefined();
});
});
@@ -1,79 +0,0 @@
import { isArray, isObject, isString } from '@sniptt/guards';
import { type Request } from 'express';
import { type ServerWebhookTriggerSettings } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
import { extractBody } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/build-logic-function-event.util';
const SAFE_PATH_SEGMENT = /^[A-Za-z0-9_-]+$/;
const FORBIDDEN_PATH_SEGMENTS = new Set([
'__proto__',
'prototype',
'constructor',
]);
const asRecord = (value: unknown): Record<string, unknown> | undefined =>
isObject(value) && !isArray(value)
? (value as Record<string, unknown>)
: undefined;
const asNonEmptyString = (value: unknown): string | undefined =>
isString(value) && value.length > 0 ? value : undefined;
const asString = (value: unknown): string | undefined => {
if (isArray(value)) {
return asNonEmptyString(value[0]);
}
return asNonEmptyString(value);
};
const getResolverRoot = (
source: ServerWebhookTriggerSettings['workspaceIdResolver']['source'],
request: Request,
): Record<string, unknown> | undefined => {
switch (source) {
case 'body':
return asRecord(extractBody(request));
case 'query':
return asRecord(request.query);
case 'header':
return asRecord(request.headers);
default:
return undefined;
}
};
export const resolveWorkspaceIdFromRequest = ({
resolver,
request,
}: {
resolver: ServerWebhookTriggerSettings['workspaceIdResolver'];
request: Request;
}): string | undefined => {
const segments = resolver.path.split('.');
if (
segments.length === 0 ||
segments.some(
(segment) =>
!SAFE_PATH_SEGMENT.test(segment) ||
FORBIDDEN_PATH_SEGMENTS.has(segment),
)
) {
return undefined;
}
const root = getResolverRoot(resolver.source, request);
if (!isDefined(root)) {
return undefined;
}
const value = segments.reduce<unknown>(
(current, key) => asRecord(current)?.[key],
root,
);
return asString(value);
};
@@ -694,6 +694,14 @@ export class ConfigVariables {
@IsAWSRegion()
LOGIC_FUNCTION_LAMBDA_LAYER_BUCKET_REGION?: AwsRegion;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LOGIC_FUNCTION_CONFIG,
description: 'Enable instance-level (server) logic functions',
type: ConfigVariableType.BOOLEAN,
})
@IsOptional()
IS_SERVER_LOGIC_FUNCTION_ENABLED = false;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.CODE_INTERPRETER_CONFIG,
description:
@@ -132,6 +132,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
"cronTriggerSettings",
"databaseEventTriggerSettings",
"httpRouteTriggerSettings",
"serverRouteTriggerSettings",
"toolTriggerSettings",
"workflowActionTriggerSettings",
"builtHandlerPath",
@@ -140,6 +141,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
"cronTriggerSettings",
"databaseEventTriggerSettings",
"httpRouteTriggerSettings",
"serverRouteTriggerSettings",
"toolTriggerSettings",
"workflowActionTriggerSettings",
],
@@ -675,6 +675,11 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
toStringify: true,
universalProperty: undefined,
},
serverRouteTriggerSettings: {
toCompare: true,
toStringify: true,
universalProperty: undefined,
},
toolTriggerSettings: {
toCompare: true,
toStringify: true,
@@ -10,6 +10,7 @@ export const FLAT_LOGIC_FUNCTION_EDITABLE_PROPERTIES = [
'cronTriggerSettings',
'databaseEventTriggerSettings',
'httpRouteTriggerSettings',
'serverRouteTriggerSettings',
'toolTriggerSettings',
'workflowActionTriggerSettings',
'isBuildUpToDate',
@@ -15,6 +15,7 @@ import {
CronTriggerSettings,
DatabaseEventTriggerSettings,
HttpRouteTriggerSettings,
ServerRouteTriggerSettings,
ToolTriggerSettings,
WorkflowActionTriggerSettings,
} from 'twenty-shared/application';
@@ -72,6 +73,11 @@ export class CreateLogicFunctionFromSourceInput {
@IsOptional()
httpRouteTriggerSettings?: JsonbProperty<HttpRouteTriggerSettings>;
@IsObject()
@Field(() => graphqlTypeJson, { nullable: true })
@IsOptional()
serverRouteTriggerSettings?: JsonbProperty<ServerRouteTriggerSettings>;
@IsObject()
@Field(() => graphqlTypeJson, { nullable: true })
@IsOptional()
@@ -12,6 +12,7 @@ import {
CronTriggerSettings,
DatabaseEventTriggerSettings,
HttpRouteTriggerSettings,
ServerRouteTriggerSettings,
ToolTriggerSettings,
WorkflowActionTriggerSettings,
} from 'twenty-shared/application';
@@ -85,6 +86,9 @@ export class LogicFunctionEntity
@Column({ nullable: true, type: 'jsonb' })
httpRouteTriggerSettings: JsonbProperty<HttpRouteTriggerSettings> | null;
@Column({ nullable: true, type: 'jsonb' })
serverRouteTriggerSettings: JsonbProperty<ServerRouteTriggerSettings> | null;
@Column({ nullable: true, type: 'jsonb' })
toolTriggerSettings: JsonbProperty<ToolTriggerSettings> | null;
@@ -50,6 +50,7 @@ export const buildUniversalFlatLogicFunctionToCreate = (
cronTriggerSettings: input.cronTriggerSettings ?? null,
databaseEventTriggerSettings: input.databaseEventTriggerSettings ?? null,
httpRouteTriggerSettings: input.httpRouteTriggerSettings ?? null,
serverRouteTriggerSettings: input.serverRouteTriggerSettings ?? null,
toolTriggerSettings: input.toolTriggerSettings ?? null,
workflowActionTriggerSettings: input.workflowActionTriggerSettings ?? null,
createdAt: now,
@@ -56,6 +56,8 @@ export const fromCreateLogicFunctionFromSourceInputToUniversalFlatLogicFunctionT
createLogicFunctionFromSourceInput.databaseEventTriggerSettings ?? null,
httpRouteTriggerSettings:
createLogicFunctionFromSourceInput.httpRouteTriggerSettings ?? null,
serverRouteTriggerSettings:
createLogicFunctionFromSourceInput.serverRouteTriggerSettings ?? null,
toolTriggerSettings:
createLogicFunctionFromSourceInput.toolTriggerSettings ?? null,
workflowActionTriggerSettings:
@@ -22,7 +22,7 @@ import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadat
import { PermissionFlagModule } from 'src/engine/metadata-modules/permission-flag/permission-flag.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { MinimalMetadataModule } from 'src/engine/metadata-modules/minimal-metadata/minimal-metadata.module';
import { ServerWebhookTriggerModule } from 'src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.module';
import { ServerRouteTriggerModule } from 'src/engine/core-modules/server-route-trigger/server-route-trigger.module';
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
import { RouteTriggerModule } from 'src/engine/metadata-modules/route-trigger/route-trigger.module';
import { SearchFieldMetadataModule } from 'src/engine/metadata-modules/search-field-metadata/search-field-metadata.module';
@@ -55,7 +55,7 @@ import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/work
PermissionsModule,
PermissionFlagModule,
RouteTriggerModule,
ServerWebhookTriggerModule,
ServerRouteTriggerModule,
WebhookModule,
ConnectedAccountMetadataModule,
MessageChannelMetadataModule,