Add recall io webhook endpoint (#21879)

## Context

Bot-recording integrations (e.g. the Recall.ai meeting bot) receive
webhooks from a third-party provider that delivers **every
tenant's events to a single URL**. Our existing `route-trigger` (`/s/…`)
resolves the workspace from the request host, which can't
work for one shared multi-tenant webhook URL. We need an instance-scoped
ingress that identifies the target workspace from the payload
  instead.

  ## Strategy

Add a new **`ingress-trigger`** logic-function trigger, mirroring
`route-trigger`:

  - A public endpoint keyed by the app's identifiers: `POST

/webhooks/ingress/:applicationRegistrationUniversalIdentifier/:logicFunctionUniversalIdentifier`.
- The logic function declares an `ingressTriggerSettings` block in its
manifest describing how to find the workspace in the payload
  (`workspaceId: { source: 'body' | 'query' | 'header', path }`).
- Core only **resolves the workspace** (declarative, fail-closed,
prototype-safe path getter), verifies the app is installed in that
workspace, then runs the function **synchronously** so the provider sees
the response (status codes / retries).
- **Signature verification stays in the logic function** (it gets
`rawBody` + forwarded headers), keeping core provider-agnostic.
- Shared execution logic (`build event → execute → map response`)
extracted into `LogicFunctionTriggerService`, now reused by both
  `route-trigger` and `ingress-trigger`.

  ## Major changes

- **twenty-shared**: new `ingressTriggerSettings` on
`LogicFunctionManifest` (`IngressTriggerSettings` type).
- **twenty-server**: new `ingress-trigger` module (controller, service,
exception + filter, workspace-id resolver util).
- **twenty-server**: extracted `LogicFunctionTriggerService` +
`route-trigger-response.util` (response builder + sender); refactored
  `RouteTriggerService` and both controllers to reuse them.
- **twenty-docs**: documented the ingress trigger (endpoint, workspace
resolution, signature responsibility, provider HMAC examples).
  - Unit tests for the resolver and the ingress service.
This commit is contained in:
martmull
2026-06-19 23:34:43 +02:00
committed by GitHub
parent dd6fbbe854
commit 6423c4cd3c
23 changed files with 1314 additions and 439 deletions
@@ -7,6 +7,7 @@ import { LogicFunctionTriggerJob } from 'src/engine/core-modules/logic-function/
import { CronTriggerCronCommand } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/cron/cron-trigger.cron.command';
import { CronTriggerCronJob } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/cron/cron-trigger.cron.job';
import { CallDatabaseEventTriggerJobsJob } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/database-event/call-database-event-trigger-jobs.job';
import { LogicFunctionTriggerService } from 'src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.service';
import { RouteTriggerService } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
@@ -24,8 +25,13 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
CronTriggerCronJob,
CronTriggerCronCommand,
CallDatabaseEventTriggerJobsJob,
LogicFunctionTriggerService,
RouteTriggerService,
],
exports: [
CronTriggerCronCommand,
LogicFunctionTriggerService,
RouteTriggerService,
],
exports: [CronTriggerCronCommand, RouteTriggerService],
})
export class LogicFunctionTriggerModule {}
@@ -0,0 +1,67 @@
import { Injectable } from '@nestjs/common';
import { Request } from 'express';
import { isDefined } from 'twenty-shared/utils';
import { 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 {
RouteTriggerResponse,
buildRouteTriggerResponse,
} 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';
export type LogicFunctionTriggerOutcome =
| { kind: 'response'; response: RouteTriggerResponse }
| { kind: 'userError'; errorMessage: string };
@Injectable()
export class LogicFunctionTriggerService {
constructor(
private readonly logicFunctionExecutorService: LogicFunctionExecutorService,
) {}
async run({
logicFunction,
request,
pathParameters,
forwardedRequestHeaders,
userId,
userWorkspaceId,
}: {
logicFunction: LogicFunctionEntity;
request: Request;
pathParameters: Record<string, string | string[] | undefined>;
forwardedRequestHeaders: string[];
userId?: string | null;
userWorkspaceId?: string | null;
}): Promise<LogicFunctionTriggerOutcome> {
const event = buildLogicFunctionEvent({
request,
pathParameters,
forwardedRequestHeaders,
userWorkspaceId: userWorkspaceId ?? null,
});
const result = await this.logicFunctionExecutorService.execute({
logicFunctionId: logicFunction.id,
workspaceId: logicFunction.workspaceId,
payload: event,
...(isDefined(userId) ? { userId } : {}),
...(isDefined(userWorkspaceId) ? { userWorkspaceId } : {}),
});
if (!isDefined(result)) {
return { kind: 'response', response: buildRouteTriggerResponse(result) };
}
if (result.error) {
return { kind: 'userError', errorMessage: result.error.errorMessage };
}
return {
kind: 'response',
response: buildRouteTriggerResponse(result.data),
};
}
}
@@ -1,6 +1,6 @@
import { LOGIC_FUNCTION_HTTP_RESPONSE_MARKER } from 'twenty-shared/types';
import { buildRouteTriggerResponse } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service';
import { buildRouteTriggerResponse } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util';
describe('buildRouteTriggerResponse', () => {
it('wraps a plain body with status 200 and no headers', () => {
@@ -5,7 +5,7 @@ import { Request } from 'express';
import { match } from 'path-to-regexp';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { IsNull, Not, Repository } from 'typeorm';
import { HTTPMethod, isLogicFunctionHttpResponse } from 'twenty-shared/types';
import { HTTPMethod } from 'twenty-shared/types';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
@@ -13,7 +13,7 @@ import {
RouteTriggerException,
RouteTriggerExceptionCode,
} from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger.exception';
import { buildLogicFunctionEvent } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/build-logic-function-event.util';
import { LogicFunctionTriggerService } from 'src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.service';
import {
LogicFunctionException,
LogicFunctionExceptionCode,
@@ -22,37 +22,16 @@ import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/
import {
LogicFunctionExecutionException,
LogicFunctionExecutionExceptionCode,
LogicFunctionExecutorService,
} from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service';
import { CustomException } from 'src/utils/custom-exception';
export type RouteTriggerResponse = {
statusCode: number;
headers: Record<string, string>;
body: unknown;
};
export const buildRouteTriggerResponse = (
data: unknown,
): RouteTriggerResponse => {
if (isLogicFunctionHttpResponse(data)) {
return {
statusCode: data.status ?? 200,
headers: data.headers ?? {},
body: data.body,
};
}
return { statusCode: 200, headers: {}, body: data };
};
@Injectable()
export class RouteTriggerService {
private readonly logger = new Logger(RouteTriggerService.name);
constructor(
private readonly accessTokenService: AccessTokenService,
private readonly logicFunctionExecutorService: LogicFunctionExecutorService,
private readonly logicFunctionTriggerService: LogicFunctionTriggerService,
private readonly workspaceDomainsService: WorkspaceDomainsService,
@InjectRepository(LogicFunctionEntity)
private readonly logicFunctionRepository: Repository<LogicFunctionEntity>,
@@ -203,22 +182,17 @@ export class RouteTriggerService {
userId = authContext.user?.id ?? null;
}
const event = buildLogicFunctionEvent({
request,
pathParameters: pathParams,
forwardedRequestHeaders: httpRouteSettings?.forwardedRequestHeaders ?? [],
userWorkspaceId,
});
let result;
let outcome;
try {
result = await this.logicFunctionExecutorService.execute({
logicFunctionId: logicFunction.id,
workspaceId: logicFunction.workspaceId,
payload: event,
...(userId ? { userId } : {}),
...(userWorkspaceId ? { userWorkspaceId } : {}),
outcome = await this.logicFunctionTriggerService.run({
logicFunction,
request,
pathParameters: pathParams,
forwardedRequestHeaders:
httpRouteSettings?.forwardedRequestHeaders ?? [],
userId,
userWorkspaceId,
});
} catch (error) {
if (error instanceof RouteTriggerException) {
@@ -244,17 +218,13 @@ export class RouteTriggerService {
);
}
if (!isDefined(result)) {
return buildRouteTriggerResponse(result);
}
if (result.error) {
if (outcome.kind === 'userError') {
throw new RouteTriggerException(
result.error.errorMessage,
outcome.errorMessage,
RouteTriggerExceptionCode.ROUTE_TRIGGER_USER_UNCAUGHT_ERROR,
);
}
return buildRouteTriggerResponse(result.data);
return outcome.response;
}
}
@@ -2,6 +2,7 @@ import { type RawBodyRequest } from '@nestjs/common';
import { type Request } from 'express';
import { type LogicFunctionEvent } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { isObject, isString } from '@sniptt/guards';
export const filterRequestHeaders = ({
requestHeaders,
@@ -40,15 +41,15 @@ export const extractRawBody = (request: Request): string | undefined => {
};
export const extractBody = (request: Request): object | null => {
if (request.body === undefined || request.body === null) {
if (!isDefined(request.body)) {
return null;
}
if (typeof request.body === 'object' && !Buffer.isBuffer(request.body)) {
if (isObject(request.body) && !Buffer.isBuffer(request.body)) {
return request.body;
}
if (typeof request.body === 'string') {
if (isString(request.body)) {
try {
return JSON.parse(request.body);
} catch {
@@ -0,0 +1,70 @@
import { type Response } from 'express';
import { isLogicFunctionHttpResponse } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export type RouteTriggerResponse = {
statusCode: number;
headers: Record<string, string>;
body: unknown;
};
const ALLOWED_RESPONSE_HEADERS = new Set([
'content-type',
'content-language',
'content-disposition',
'cache-control',
'retry-after',
]);
export const buildRouteTriggerResponse = (
data: unknown,
): RouteTriggerResponse => {
if (isLogicFunctionHttpResponse(data)) {
return {
statusCode: data.status ?? 200,
headers: data.headers ?? {},
body: data.body,
};
}
return { statusCode: 200, headers: {}, body: data };
};
export const sendRouteTriggerResponse = (
response: Response,
{ statusCode, headers, body }: RouteTriggerResponse,
) => {
response.status(statusCode);
for (const [key, value] of Object.entries(headers)) {
if (ALLOWED_RESPONSE_HEADERS.has(key.toLowerCase())) {
response.setHeader(key, value);
}
}
if (!isDefined(body)) {
response.send();
return;
}
const hasContentType = isDefined(response.getHeader('content-type'));
if (typeof body === 'string') {
if (!hasContentType) {
response.setHeader('content-type', 'text/plain');
}
response.send(body);
return;
}
if (hasContentType) {
response.send(JSON.stringify(body));
return;
}
response.json(body);
};
@@ -0,0 +1,172 @@
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,
});
});
});
@@ -0,0 +1,66 @@
import {
type ArgumentsHost,
Catch,
type ExceptionFilter,
} from '@nestjs/common';
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';
@Catch(ServerWebhookTriggerException)
export class ServerWebhookTriggerRestApiExceptionFilter implements ExceptionFilter {
constructor(
private readonly httpExceptionHandlerService: HttpExceptionHandlerService,
) {}
catch(exception: ServerWebhookTriggerException, 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:
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
404,
);
case ServerWebhookTriggerExceptionCode.WORKSPACE_ID_NOT_RESOLVED:
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
400,
);
case ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_USER_UNCAUGHT_ERROR:
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
500,
undefined,
undefined,
{ shouldBeCapturedBySentry: false },
);
case ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_PLATFORM_ERROR:
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
500,
);
default: {
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
400,
);
}
}
}
}
@@ -0,0 +1,52 @@
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),
});
}
}
@@ -0,0 +1,47 @@
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,
}),
);
}
}
@@ -0,0 +1,20 @@
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 {}
@@ -0,0 +1,184 @@
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;
}
}
@@ -0,0 +1,89 @@
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();
});
});
@@ -0,0 +1,79 @@
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);
};