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:
+2
@@ -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,
|
||||
|
||||
+325
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+31
-16
@@ -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,
|
||||
+49
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
+42
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
+17
@@ -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 {}
|
||||
+216
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
-172
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
-52
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
-47
@@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
-20
@@ -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 {}
|
||||
-184
@@ -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;
|
||||
}
|
||||
}
|
||||
-89
@@ -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();
|
||||
});
|
||||
});
|
||||
-79
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user