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
@@ -13,23 +13,12 @@ import {
import { Request, Response } from 'express';
import { HTTPMethod } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
import { RouteTriggerRestApiExceptionFilter } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger-rest-api-exception-filter';
import {
RouteTriggerResponse,
RouteTriggerService,
} from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service';
const ALLOWED_RESPONSE_HEADERS = new Set([
'content-type',
'content-language',
'content-disposition',
'cache-control',
'retry-after',
]);
import { RouteTriggerService } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service';
import { sendRouteTriggerResponse } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util';
@Controller('s')
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
@@ -39,7 +28,7 @@ export class RouteTriggerController {
@Get('*path')
async get(@Req() request: Request, @Res() response: Response) {
this.sendResponse(
sendRouteTriggerResponse(
response,
await this.routeTriggerService.handle({
request,
@@ -50,7 +39,7 @@ export class RouteTriggerController {
@Post('*path')
async post(@Req() request: Request, @Res() response: Response) {
this.sendResponse(
sendRouteTriggerResponse(
response,
await this.routeTriggerService.handle({
request,
@@ -61,7 +50,7 @@ export class RouteTriggerController {
@Put('*path')
async put(@Req() request: Request, @Res() response: Response) {
this.sendResponse(
sendRouteTriggerResponse(
response,
await this.routeTriggerService.handle({
request,
@@ -72,7 +61,7 @@ export class RouteTriggerController {
@Patch('*path')
async patch(@Req() request: Request, @Res() response: Response) {
this.sendResponse(
sendRouteTriggerResponse(
response,
await this.routeTriggerService.handle({
request,
@@ -83,7 +72,7 @@ export class RouteTriggerController {
@Delete('*path')
async delete(@Req() request: Request, @Res() response: Response) {
this.sendResponse(
sendRouteTriggerResponse(
response,
await this.routeTriggerService.handle({
request,
@@ -91,43 +80,4 @@ export class RouteTriggerController {
}),
);
}
private sendResponse(
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);
}
}