diff --git a/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx b/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx
index af06862da7..1129ff82e8 100644
--- a/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx
+++ b/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx
@@ -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']`).
+#### 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('
Hello
', {
+ 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`
+
+
+The status code must be a valid HTTP status code (between 100 and 599). Response header names are matched case-insensitively.
+
+
#### 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.
diff --git a/packages/twenty-sdk/src/sdk/logic-function/__tests__/response.spec.ts b/packages/twenty-sdk/src/sdk/logic-function/__tests__/response.spec.ts
new file mode 100644
index 0000000000..d6ebfb77ce
--- /dev/null
+++ b/packages/twenty-sdk/src/sdk/logic-function/__tests__/response.spec.ts
@@ -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('Hi
', {
+ status: 201,
+ headers: { 'Content-Type': 'text/html' },
+ });
+
+ expect(response.body).toBe('Hi
');
+ 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);
+ });
+});
diff --git a/packages/twenty-sdk/src/sdk/logic-function/index.ts b/packages/twenty-sdk/src/sdk/logic-function/index.ts
index 812605b566..f72aa89fee 100644
--- a/packages/twenty-sdk/src/sdk/logic-function/index.ts
+++ b/packages/twenty-sdk/src/sdk/logic-function/index.ts
@@ -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';
diff --git a/packages/twenty-sdk/src/sdk/logic-function/response.ts b/packages/twenty-sdk/src/sdk/logic-function/response.ts
new file mode 100644
index 0000000000..fd51220768
--- /dev/null
+++ b/packages/twenty-sdk/src/sdk/logic-function/response.ts
@@ -0,0 +1,19 @@
+import { type LogicFunctionHttpResponse } from 'twenty-shared/types';
+
+export type ResponseInit = {
+ status?: number;
+ headers?: Record;
+};
+
+export class Response implements LogicFunctionHttpResponse {
+ readonly __twentyHttpResponse = true as const;
+ readonly body: unknown;
+ readonly status?: number;
+ readonly headers?: Record;
+
+ constructor(body: unknown, init?: ResponseInit) {
+ this.body = body;
+ this.status = init?.status;
+ this.headers = init?.headers;
+ }
+}
diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/__tests__/route-trigger.service.spec.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/__tests__/route-trigger.service.spec.ts
new file mode 100644
index 0000000000..582c1267b0
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/__tests__/route-trigger.service.spec.ts
@@ -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: 'Hi
',
+ status: 201,
+ headers: { 'Content-Type': 'text/html' },
+ };
+
+ expect(buildRouteTriggerResponse(data)).toEqual({
+ statusCode: 201,
+ headers: { 'Content-Type': 'text/html' },
+ body: 'Hi
',
+ });
+ });
+
+ 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 },
+ });
+ });
+});
diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts
index 7b37ed30a9..49bd97b27f 100644
--- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service.ts
@@ -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;
+ 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);
}
}
diff --git a/packages/twenty-server/src/engine/metadata-modules/route-trigger/__tests__/route-trigger.controller.spec.ts b/packages/twenty-server/src/engine/metadata-modules/route-trigger/__tests__/route-trigger.controller.spec.ts
index 2b7fcd4c52..1f0c9a8d68 100644
--- a/packages/twenty-server/src/engine/metadata-modules/route-trigger/__tests__/route-trigger.controller.spec.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/route-trigger/__tests__/route-trigger.controller.spec.ts
@@ -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 = {};
+
+ 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: 'Hi
',
});
+
+ 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('Hi
');
+ });
+
+ 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: 'Hi
',
+ });
+
+ 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();
});
});
diff --git a/packages/twenty-server/src/engine/metadata-modules/route-trigger/route-trigger.controller.ts b/packages/twenty-server/src/engine/metadata-modules/route-trigger/route-trigger.controller.ts
index dbfbd29a8a..869fb6212a 100644
--- a/packages/twenty-server/src/engine/metadata-modules/route-trigger/route-trigger.controller.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/route-trigger/route-trigger.controller.ts
@@ -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);
}
}
diff --git a/packages/twenty-shared/src/types/LogicFunctionResponse.ts b/packages/twenty-shared/src/types/LogicFunctionResponse.ts
new file mode 100644
index 0000000000..3a570bd30e
--- /dev/null
+++ b/packages/twenty-shared/src/types/LogicFunctionResponse.ts
@@ -0,0 +1,37 @@
+export const LOGIC_FUNCTION_HTTP_RESPONSE_MARKER = '__twentyHttpResponse';
+
+export type LogicFunctionHttpResponse = {
+ __twentyHttpResponse: true;
+ body: unknown;
+ status?: number;
+ headers?: Record;
+};
+
+const isValidHttpStatusCode = (value: unknown): value is number =>
+ typeof value === 'number' &&
+ Number.isInteger(value) &&
+ value >= 100 &&
+ value <= 599;
+
+const isStringRecord = (value: unknown): value is Record =>
+ 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;
+
+ return (
+ candidate[LOGIC_FUNCTION_HTTP_RESPONSE_MARKER] === true &&
+ (candidate.status === undefined ||
+ isValidHttpStatusCode(candidate.status)) &&
+ (candidate.headers === undefined || isStringRecord(candidate.headers))
+ );
+};
diff --git a/packages/twenty-shared/src/types/__tests__/LogicFunctionResponse.spec.ts b/packages/twenty-shared/src/types/__tests__/LogicFunctionResponse.spec.ts
new file mode 100644
index 0000000000..bcf54dba2e
--- /dev/null
+++ b/packages/twenty-shared/src/types/__tests__/LogicFunctionResponse.spec.ts
@@ -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);
+ });
+});
diff --git a/packages/twenty-shared/src/types/index.ts b/packages/twenty-shared/src/types/index.ts
index ade278d81b..004d6e82da 100644
--- a/packages/twenty-shared/src/types/index.ts
+++ b/packages/twenty-shared/src/types/index.ts
@@ -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';