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:
+7
-1
@@ -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 {}
|
||||
|
||||
+67
@@ -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
-1
@@ -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', () => {
|
||||
|
||||
+15
-45
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -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 {
|
||||
|
||||
+70
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user