Add more control on http trigger (#21216)

add "new Response" utils to define response code or content type of http
route triggered logic function responses

follow up of https://github.com/twentyhq/twenty/pull/21214

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
martmull
2026-06-04 17:34:10 +02:00
committed by GitHub
parent d3a1781a59
commit e0d42323af
11 changed files with 514 additions and 67 deletions
@@ -145,6 +145,33 @@ const handler = async (event: RoutePayload) => {
Header names are normalized to lowercase. Access them using lowercase keys (e.g., `event.headers['content-type']`).
</Note>
#### Custom HTTP response
By default, returning a plain value from your handler sends it back as a `200` response (JSON for objects, `text/plain` for strings). To control the status code and response headers, return a `Response` from `twenty-sdk/logic-function`:
```ts
import { Response } from 'twenty-sdk/logic-function';
const handler = async (event: RoutePayload) => {
return new Response('<h1>Hello</h1>', {
status: 201,
headers: { 'content-type': 'text/html' },
});
};
```
For security reasons, response headers are restricted to an allow-list. Any header that is not on the list (e.g. `Set-Cookie`, CORS headers such as `Access-Control-Allow-Origin`, or custom `X-*` headers) is silently dropped before the response is sent. The allowed response headers are:
- `content-type`
- `content-language`
- `content-disposition`
- `cache-control`
- `retry-after`
<Note>
The status code must be a valid HTTP status code (between 100 and 599). Response header names are matched case-insensitively.
</Note>
#### Database event trigger payload
When a database event trigger invokes your logic function, it receives one `DatabaseEventPayload` per changed record. The payload combines metadata about the source workspace and object with the record-level event.
@@ -0,0 +1,33 @@
import { isLogicFunctionHttpResponse } from 'twenty-shared/types';
import { Response } from '@/sdk/logic-function/response';
describe('Response', () => {
it('stores body, status and headers', () => {
const response = new Response('<h1>Hi</h1>', {
status: 201,
headers: { 'Content-Type': 'text/html' },
});
expect(response.body).toBe('<h1>Hi</h1>');
expect(response.status).toBe(201);
expect(response.headers).toEqual({ 'Content-Type': 'text/html' });
});
it('defaults status and headers to undefined', () => {
const response = new Response({ ok: true });
expect(response.status).toBeUndefined();
expect(response.headers).toBeUndefined();
});
it('is detected as an http response after JSON round-trip', () => {
const response = new Response({ ok: true }, { status: 200 });
const roundTripped = JSON.parse(JSON.stringify(response));
expect(isLogicFunctionHttpResponse(roundTripped)).toBe(true);
expect(roundTripped.body).toEqual({ ok: true });
expect(roundTripped.status).toBe(200);
});
});
@@ -44,3 +44,5 @@ export type { ListConnectionsFilter } from '@/sdk/logic-function/connections/lis
export { findConnectionForRequest } from '@/sdk/logic-function/connections/find-connection-for-request';
export { AppConnectionAuthFailedError } from '@/sdk/logic-function/connections/errors/app-connection-auth-failed.error';
export type { AppConnection } from '@/sdk/logic-function/connections/types/app-connection.type';
export { Response } from '@/sdk/logic-function/response';
export type { ResponseInit } from '@/sdk/logic-function/response';
@@ -0,0 +1,19 @@
import { type LogicFunctionHttpResponse } from 'twenty-shared/types';
export type ResponseInit = {
status?: number;
headers?: Record<string, string>;
};
export class Response implements LogicFunctionHttpResponse {
readonly __twentyHttpResponse = true as const;
readonly body: unknown;
readonly status?: number;
readonly headers?: Record<string, string>;
constructor(body: unknown, init?: ResponseInit) {
this.body = body;
this.status = init?.status;
this.headers = init?.headers;
}
}
@@ -0,0 +1,49 @@
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';
describe('buildRouteTriggerResponse', () => {
it('wraps a plain body with status 200 and no headers', () => {
expect(buildRouteTriggerResponse({ message: 'hi' })).toEqual({
statusCode: 200,
headers: {},
body: { message: 'hi' },
});
});
it('passes through null/undefined as a 200 with that body', () => {
expect(buildRouteTriggerResponse(null)).toEqual({
statusCode: 200,
headers: {},
body: null,
});
});
it('reads status, headers and body from a wrapped response', () => {
const data = {
[LOGIC_FUNCTION_HTTP_RESPONSE_MARKER]: true,
body: '<h1>Hi</h1>',
status: 201,
headers: { 'Content-Type': 'text/html' },
};
expect(buildRouteTriggerResponse(data)).toEqual({
statusCode: 201,
headers: { 'Content-Type': 'text/html' },
body: '<h1>Hi</h1>',
});
});
it('defaults a wrapped response without status/headers to 200 and {}', () => {
const data = {
[LOGIC_FUNCTION_HTTP_RESPONSE_MARKER]: true,
body: { ok: true },
};
expect(buildRouteTriggerResponse(data)).toEqual({
statusCode: 200,
headers: {},
body: { ok: true },
});
});
});
@@ -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 } from 'twenty-shared/types';
import { HTTPMethod, isLogicFunctionHttpResponse } 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';
@@ -26,6 +26,26 @@ import {
} 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);
@@ -223,7 +243,7 @@ export class RouteTriggerService {
}
if (!isDefined(result)) {
return result;
return buildRouteTriggerResponse(result);
}
if (result.error) {
@@ -233,6 +253,6 @@ export class RouteTriggerService {
);
}
return result.data;
return buildRouteTriggerResponse(result.data);
}
}
@@ -1,20 +1,29 @@
import { HttpStatus } from '@nestjs/common';
import { HTTP_CODE_METADATA } from '@nestjs/common/constants';
import { type Request } from 'express';
import { type Response } from 'express';
import { HTTPMethod } from 'twenty-shared/types';
import { RouteTriggerService } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service';
import { RouteTriggerController } from 'src/engine/metadata-modules/route-trigger/route-trigger.controller';
const createResponseMock = () => {
const headers: Record<string, string> = {};
return {
status: jest.fn(),
setHeader: jest.fn((key: string, value: string) => {
headers[key.toLowerCase()] = value;
}),
getHeader: jest.fn((key: string) => headers[key.toLowerCase()]),
send: jest.fn(),
json: jest.fn(),
} as unknown as Response;
};
describe('RouteTriggerController', () => {
let controller: RouteTriggerController;
const handle = jest.fn();
beforeEach(() => {
const routeTriggerService = {
handle,
} as unknown as RouteTriggerService;
const routeTriggerService = { handle } as unknown as RouteTriggerService;
controller = new RouteTriggerController(routeTriggerService);
});
@@ -27,34 +36,132 @@ describe('RouteTriggerController', () => {
expect(controller).toBeDefined();
});
describe('response status code', () => {
it.each([
['get', RouteTriggerController.prototype.get],
['post', RouteTriggerController.prototype.post],
['put', RouteTriggerController.prototype.put],
['patch', RouteTriggerController.prototype.patch],
['delete', RouteTriggerController.prototype.delete],
])('should respond with 200 for %s', (_method, handler) => {
const httpCode = Reflect.getMetadata(HTTP_CODE_METADATA, handler);
it('delegates to the service with the POST http method and applies status 200', async () => {
const request = { path: '/s/webhooks/google/leads' } as never;
const response = createResponseMock();
expect(httpCode).toBe(HttpStatus.OK);
handle.mockResolvedValue({
statusCode: 200,
headers: {},
body: { ok: true },
});
await controller.post(request, response);
expect(handle).toHaveBeenCalledWith({
request,
httpMethod: HTTPMethod.POST,
});
expect(response.status).toHaveBeenCalledWith(200);
expect(response.json).toHaveBeenCalledWith({ ok: true });
});
describe('post', () => {
it('should delegate to the service with the POST http method', async () => {
const request = { path: '/s/webhooks/google/leads' } as Request;
const expectedResult = {};
it('applies the status code and allow-listed headers from the service result', async () => {
const response = createResponseMock();
handle.mockResolvedValue(expectedResult);
const result = await controller.post(request);
expect(handle).toHaveBeenCalledWith({
request,
httpMethod: HTTPMethod.POST,
});
expect(result).toBe(expectedResult);
handle.mockResolvedValue({
statusCode: 201,
headers: { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' },
body: '<h1>Hi</h1>',
});
await controller.get({} as never, response);
expect(response.status).toHaveBeenCalledWith(201);
expect(response.setHeader).toHaveBeenCalledWith(
'Content-Type',
'text/html',
);
expect(response.setHeader).toHaveBeenCalledWith(
'Cache-Control',
'no-store',
);
expect(response.send).toHaveBeenCalledWith('<h1>Hi</h1>');
});
it('drops headers that are not in the allow-list', async () => {
const response = createResponseMock();
handle.mockResolvedValue({
statusCode: 200,
headers: {
'Content-Type': 'text/html',
'Set-Cookie': 'session=abc',
'Access-Control-Allow-Origin': '*',
'X-Custom': 'foo',
},
body: '<h1>Hi</h1>',
});
await controller.get({} as never, response);
expect(response.setHeader).toHaveBeenCalledWith(
'Content-Type',
'text/html',
);
expect(response.setHeader).not.toHaveBeenCalledWith(
'Set-Cookie',
'session=abc',
);
expect(response.setHeader).not.toHaveBeenCalledWith(
'Access-Control-Allow-Origin',
'*',
);
expect(response.setHeader).not.toHaveBeenCalledWith('X-Custom', 'foo');
});
it('sends an empty response when the body is nil', async () => {
const response = createResponseMock();
handle.mockResolvedValue({ statusCode: 200, headers: {}, body: null });
await controller.get({} as never, response);
expect(response.status).toHaveBeenCalledWith(200);
expect(response.send).toHaveBeenCalledWith();
expect(response.json).not.toHaveBeenCalled();
});
it('defaults a string body content-type to text/plain when none is set', async () => {
const response = createResponseMock();
handle.mockResolvedValue({ statusCode: 200, headers: {}, body: 'plain' });
await controller.get({} as never, response);
expect(response.setHeader).toHaveBeenCalledWith(
'content-type',
'text/plain',
);
expect(response.send).toHaveBeenCalledWith('plain');
});
it('sends an object body as JSON when no content-type is set', async () => {
const response = createResponseMock();
handle.mockResolvedValue({
statusCode: 200,
headers: {},
body: { ok: true },
});
await controller.get({} as never, response);
expect(response.json).toHaveBeenCalledWith({ ok: true });
});
it('pre-serializes an object body when a custom content-type is set', async () => {
const response = createResponseMock();
handle.mockResolvedValue({
statusCode: 200,
headers: { 'Content-Type': 'application/ld+json' },
body: { ok: true },
});
await controller.get({} as never, response);
expect(response.send).toHaveBeenCalledWith(JSON.stringify({ ok: true }));
expect(response.json).not.toHaveBeenCalled();
});
});
@@ -2,23 +2,34 @@ import {
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Patch,
Post,
Put,
Req,
Res,
UseFilters,
UseGuards,
} from '@nestjs/common';
import { Request } from 'express';
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 { RouteTriggerService } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service';
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',
]);
@Controller('s')
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
@@ -27,47 +38,96 @@ export class RouteTriggerController {
constructor(private readonly routeTriggerService: RouteTriggerService) {}
@Get('*path')
@HttpCode(HttpStatus.OK)
async get(@Req() request: Request) {
return await this.routeTriggerService.handle({
request,
httpMethod: HTTPMethod.GET,
});
async get(@Req() request: Request, @Res() response: Response) {
this.sendResponse(
response,
await this.routeTriggerService.handle({
request,
httpMethod: HTTPMethod.GET,
}),
);
}
@Post('*path')
@HttpCode(HttpStatus.OK)
async post(@Req() request: Request) {
return await this.routeTriggerService.handle({
request,
httpMethod: HTTPMethod.POST,
});
async post(@Req() request: Request, @Res() response: Response) {
this.sendResponse(
response,
await this.routeTriggerService.handle({
request,
httpMethod: HTTPMethod.POST,
}),
);
}
@Put('*path')
@HttpCode(HttpStatus.OK)
async put(@Req() request: Request) {
return await this.routeTriggerService.handle({
request,
httpMethod: HTTPMethod.PUT,
});
async put(@Req() request: Request, @Res() response: Response) {
this.sendResponse(
response,
await this.routeTriggerService.handle({
request,
httpMethod: HTTPMethod.PUT,
}),
);
}
@Patch('*path')
@HttpCode(HttpStatus.OK)
async patch(@Req() request: Request) {
return await this.routeTriggerService.handle({
request,
httpMethod: HTTPMethod.PATCH,
});
async patch(@Req() request: Request, @Res() response: Response) {
this.sendResponse(
response,
await this.routeTriggerService.handle({
request,
httpMethod: HTTPMethod.PATCH,
}),
);
}
@Delete('*path')
@HttpCode(HttpStatus.OK)
async delete(@Req() request: Request) {
return await this.routeTriggerService.handle({
request,
httpMethod: HTTPMethod.DELETE,
});
async delete(@Req() request: Request, @Res() response: Response) {
this.sendResponse(
response,
await this.routeTriggerService.handle({
request,
httpMethod: HTTPMethod.DELETE,
}),
);
}
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);
}
}
@@ -0,0 +1,37 @@
export const LOGIC_FUNCTION_HTTP_RESPONSE_MARKER = '__twentyHttpResponse';
export type LogicFunctionHttpResponse = {
__twentyHttpResponse: true;
body: unknown;
status?: number;
headers?: Record<string, string>;
};
const isValidHttpStatusCode = (value: unknown): value is number =>
typeof value === 'number' &&
Number.isInteger(value) &&
value >= 100 &&
value <= 599;
const isStringRecord = (value: unknown): value is Record<string, string> =>
typeof value === 'object' &&
value !== null &&
!Array.isArray(value) &&
Object.values(value).every((entry) => typeof entry === 'string');
export const isLogicFunctionHttpResponse = (
value: unknown,
): value is LogicFunctionHttpResponse => {
if (typeof value !== 'object' || value === null) {
return false;
}
const candidate = value as Record<string, unknown>;
return (
candidate[LOGIC_FUNCTION_HTTP_RESPONSE_MARKER] === true &&
(candidate.status === undefined ||
isValidHttpStatusCode(candidate.status)) &&
(candidate.headers === undefined || isStringRecord(candidate.headers))
);
};
@@ -0,0 +1,88 @@
import {
isLogicFunctionHttpResponse,
LOGIC_FUNCTION_HTTP_RESPONSE_MARKER,
} from '../LogicFunctionResponse';
describe('isLogicFunctionHttpResponse', () => {
it('returns true when the marker is present and true', () => {
expect(
isLogicFunctionHttpResponse({
[LOGIC_FUNCTION_HTTP_RESPONSE_MARKER]: true,
body: { ok: true },
status: 201,
}),
).toBe(true);
});
it('returns false for a plain object with body/status keys but no marker', () => {
expect(isLogicFunctionHttpResponse({ body: 'x', status: 200 })).toBe(false);
});
it.each([null, undefined, 'string', 42, true])(
'returns false for non-object value %p',
(value) => {
expect(isLogicFunctionHttpResponse(value)).toBe(false);
},
);
it('returns false when the marker is not strictly true', () => {
expect(
isLogicFunctionHttpResponse({
[LOGIC_FUNCTION_HTTP_RESPONSE_MARKER]: 'yes',
body: null,
}),
).toBe(false);
});
it('returns true when status and headers are absent', () => {
expect(
isLogicFunctionHttpResponse({
[LOGIC_FUNCTION_HTTP_RESPONSE_MARKER]: true,
body: { ok: true },
}),
).toBe(true);
});
it('returns true for valid status and headers', () => {
expect(
isLogicFunctionHttpResponse({
[LOGIC_FUNCTION_HTTP_RESPONSE_MARKER]: true,
body: 'ok',
status: 204,
headers: { 'Content-Type': 'text/plain', 'X-Custom': 'foo' },
}),
).toBe(true);
});
it.each([
['a string', 'oops'],
['NaN', Number.NaN],
['Infinity', Number.POSITIVE_INFINITY],
['a non-integer', 200.5],
['below the http range', 99],
['above the http range', 600],
])('returns false when status is %s', (_label, status) => {
expect(
isLogicFunctionHttpResponse({
[LOGIC_FUNCTION_HTTP_RESPONSE_MARKER]: true,
body: null,
status,
}),
).toBe(false);
});
it.each([
['a string', 'oops'],
['an array', ['a', 'b']],
['null', null],
['a record with a non-string value', { 'Content-Length': 42 }],
])('returns false when headers is %s', (_label, headers) => {
expect(
isLogicFunctionHttpResponse({
[LOGIC_FUNCTION_HTTP_RESPONSE_MARKER]: true,
body: null,
headers,
}),
).toBe(false);
});
});
@@ -135,6 +135,11 @@ export type { IsGreaterOrEqual } from './IsGreaterOrEqual.type';
export type { IsNever } from './IsNever.type';
export type { IsSerializedRelation } from './IsSerializedRelation.type';
export type { LogicFunctionEvent } from './LogicFunctionEvent';
export type { LogicFunctionHttpResponse } from './LogicFunctionResponse';
export {
LOGIC_FUNCTION_HTTP_RESPONSE_MARKER,
isLogicFunctionHttpResponse,
} from './LogicFunctionResponse';
export { MessageChannelContactAutoCreationPolicy } from './MessageChannelContactAutoCreationPolicy';
export { MessageChannelPendingGroupEmailsAction } from './MessageChannelPendingGroupEmailsAction';
export { MessageChannelSyncStage } from './MessageChannelSyncStage';