Add forwardedRequestHeaders in routeTriggers (#17151)
- add `forwardedRequestHeaders` `string[]` column in `core.routeTrigger` - filter request headers and forward filtered headers to function payload (avoid spreading unexpectedly token or cookie) - add `forwardedRequestHeaders` option in twenty-sdk `defineFunction` util BREAKING for actual routeTrigger payload but only 16 to migrate in production
This commit is contained in:
@@ -443,18 +443,18 @@ Each function file uses `defineFunction()` to export a configuration with a hand
|
||||
```typescript
|
||||
// src/app/createPostCard.function.ts
|
||||
import { defineFunction } from 'twenty-sdk';
|
||||
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload } from 'twenty-sdk';
|
||||
import type { DatabaseEventPayload, ObjectRecordCreateEvent, CronPayload, RoutePayload } from 'twenty-sdk';
|
||||
import Twenty, { type Person } from '../../generated';
|
||||
|
||||
const handler = async (
|
||||
params:
|
||||
| { name?: string }
|
||||
| RoutePayload
|
||||
| DatabaseEventPayload<ObjectRecordCreateEvent<Person>>
|
||||
| CronPayload,
|
||||
) => {
|
||||
const client = new Twenty(); // generated typed client
|
||||
const name = 'name' in params
|
||||
? params.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world'
|
||||
const name = 'name' in params.queryStringParameters
|
||||
? params.queryStringParameters.name ?? process.env.DEFAULT_RECIPIENT_NAME ?? 'Hello world'
|
||||
: 'Hello world';
|
||||
|
||||
const result = await client.mutation({
|
||||
@@ -508,6 +508,96 @@ Notes:
|
||||
- The `triggers` array is optional. Functions without triggers can be used as utility functions called by other functions.
|
||||
- You can mix multiple trigger types in a single function.
|
||||
|
||||
### Route trigger payload
|
||||
|
||||
<Warning>
|
||||
**Breaking change (v1.16, January 2026):** The route trigger payload format has changed. Prior to v1.16, query parameters, path parameters, and body were sent directly as the payload. Starting with v1.16, they are nested inside a structured `RoutePayload` object.
|
||||
|
||||
**Before v1.16:**
|
||||
```typescript
|
||||
const handler = async (params) => {
|
||||
const { param1, param2 } = params; // Direct access
|
||||
};
|
||||
```
|
||||
|
||||
**After v1.16:**
|
||||
```typescript
|
||||
const handler = async (event: RoutePayload) => {
|
||||
const { param1, param2 } = event.body; // Access via .body
|
||||
const { queryParam } = event.queryStringParameters;
|
||||
const { id } = event.pathParameters;
|
||||
};
|
||||
```
|
||||
|
||||
**To migrate existing functions:** Update your handler to destructure from `event.body`, `event.queryStringParameters`, or `event.pathParameters` instead of directly from the params object.
|
||||
</Warning>
|
||||
|
||||
When a route trigger invokes your function, it receives a `RoutePayload` object that follows the AWS HTTP API v2 format. Import the type from `twenty-sdk`:
|
||||
|
||||
```typescript
|
||||
import { defineFunction, type RoutePayload } from 'twenty-sdk';
|
||||
|
||||
const handler = async (event: RoutePayload) => {
|
||||
// Access request data
|
||||
const { headers, queryStringParameters, pathParameters, body } = event;
|
||||
|
||||
// HTTP method and path are available in requestContext
|
||||
const { method, path } = event.requestContext.http;
|
||||
|
||||
return { message: 'Success' };
|
||||
};
|
||||
```
|
||||
|
||||
The `RoutePayload` type has the following structure:
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `headers` | `Record<string, string \| undefined>` | HTTP headers (only those listed in `forwardedRequestHeaders`) |
|
||||
| `queryStringParameters` | `Record<string, string \| undefined>` | Query string parameters (multiple values joined with commas) |
|
||||
| `pathParameters` | `Record<string, string \| undefined>` | Path parameters extracted from the route pattern (e.g., `/users/:id` → `{ id: '123' }`) |
|
||||
| `body` | `object \| null` | Parsed request body (JSON) |
|
||||
| `isBase64Encoded` | `boolean` | Whether the body is base64 encoded |
|
||||
| `requestContext.http.method` | `string` | HTTP method (GET, POST, PUT, PATCH, DELETE) |
|
||||
| `requestContext.http.path` | `string` | Raw request path |
|
||||
|
||||
### Forwarding HTTP headers
|
||||
|
||||
By default, HTTP headers from incoming requests are **not** passed to your serverless function for security reasons. To access specific headers, explicitly list them in the `forwardedRequestHeaders` array:
|
||||
|
||||
```typescript
|
||||
export default defineFunction({
|
||||
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
|
||||
name: 'webhook-handler',
|
||||
handler,
|
||||
triggers: [
|
||||
{
|
||||
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
|
||||
type: 'route',
|
||||
path: '/webhook',
|
||||
httpMethod: 'POST',
|
||||
isAuthRequired: false,
|
||||
forwardedRequestHeaders: ['x-webhook-signature', 'content-type'],
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
In your handler, you can then access these headers:
|
||||
|
||||
```typescript
|
||||
const handler = async (event: RoutePayload) => {
|
||||
const signature = event.headers['x-webhook-signature'];
|
||||
const contentType = event.headers['content-type'];
|
||||
|
||||
// Validate webhook signature...
|
||||
return { received: true };
|
||||
};
|
||||
```
|
||||
|
||||
<Note>
|
||||
Header names are normalized to lowercase. Access them using lowercase keys (for example, `event.headers['content-type']`).
|
||||
</Note>
|
||||
|
||||
You can create new functions in two ways:
|
||||
|
||||
- **Scaffolded**: Run `yarn create-entity` and choose the option to add a new function. This generates a starter file with a handler and config.
|
||||
|
||||
@@ -942,6 +942,8 @@ export type CreateRoleInput = {
|
||||
};
|
||||
|
||||
export type CreateRouteTriggerInput = {
|
||||
/** List of HTTP header names to forward to the serverless function event */
|
||||
forwardedRequestHeaders?: Array<Scalars['String']>;
|
||||
httpMethod?: HttpMethod;
|
||||
isAuthRequired?: Scalars['Boolean'];
|
||||
path: Scalars['String'];
|
||||
@@ -3912,6 +3914,8 @@ export type Role = {
|
||||
export type RouteTrigger = {
|
||||
__typename?: 'RouteTrigger';
|
||||
createdAt: Scalars['DateTime'];
|
||||
/** List of HTTP header names to forward to the serverless function event */
|
||||
forwardedRequestHeaders: Array<Scalars['String']>;
|
||||
httpMethod: HttpMethod;
|
||||
id: Scalars['ID'];
|
||||
isAuthRequired: Scalars['Boolean'];
|
||||
@@ -4595,6 +4599,8 @@ export type UpdateRouteTriggerInput = {
|
||||
};
|
||||
|
||||
export type UpdateRouteTriggerInputUpdates = {
|
||||
/** List of HTTP header names to forward to the serverless function event */
|
||||
forwardedRequestHeaders?: Array<Scalars['String']>;
|
||||
httpMethod: HttpMethod;
|
||||
isAuthRequired: Scalars['Boolean'];
|
||||
path: Scalars['String'];
|
||||
@@ -5442,7 +5448,7 @@ export type UpdateOneApplicationVariableMutationVariables = Exact<{
|
||||
|
||||
export type UpdateOneApplicationVariableMutation = { __typename?: 'Mutation', updateOneApplicationVariable: boolean };
|
||||
|
||||
export type ApplicationFieldsFragment = { __typename?: 'Application', id: string, name: string, description: string, version: string, universalIdentifier: string, canBeUninstalled: boolean, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, evaluationInputs: Array<string>, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array<Array<string>> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, applicationId?: string | null, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, serverlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }> };
|
||||
export type ApplicationFieldsFragment = { __typename?: 'Application', id: string, name: string, description: string, version: string, universalIdentifier: string, canBeUninstalled: boolean, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, evaluationInputs: Array<string>, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array<Array<string>> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, applicationId?: string | null, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, serverlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, forwardedRequestHeaders: Array<string>, createdAt: string, updatedAt: string }> | null }> };
|
||||
|
||||
export type FindManyApplicationsQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
@@ -5454,7 +5460,7 @@ export type FindOneApplicationQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type FindOneApplicationQuery = { __typename?: 'Query', findOneApplication: { __typename?: 'Application', id: string, name: string, description: string, version: string, universalIdentifier: string, canBeUninstalled: boolean, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, evaluationInputs: Array<string>, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array<Array<string>> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, applicationId?: string | null, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, serverlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }> } };
|
||||
export type FindOneApplicationQuery = { __typename?: 'Query', findOneApplication: { __typename?: 'Application', id: string, name: string, description: string, version: string, universalIdentifier: string, canBeUninstalled: boolean, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, evaluationInputs: Array<string>, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array<Array<string>> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, applicationId?: string | null, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, serverlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, forwardedRequestHeaders: Array<string>, createdAt: string, updatedAt: string }> | null }> } };
|
||||
|
||||
export type UploadFileMutationVariables = Exact<{
|
||||
file: Scalars['Upload'];
|
||||
@@ -6246,21 +6252,21 @@ export type GetSsoIdentityProvidersQueryVariables = Exact<{ [key: string]: never
|
||||
|
||||
export type GetSsoIdentityProvidersQuery = { __typename?: 'Query', getSSOIdentityProviders: Array<{ __typename?: 'FindAvailableSSOIDPOutput', type: IdentityProviderType, id: string, name: string, issuer: string, status: SsoIdentityProviderStatus }> };
|
||||
|
||||
export type ServerlessFunctionFieldsFragment = { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null };
|
||||
export type ServerlessFunctionFieldsFragment = { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, forwardedRequestHeaders: Array<string>, createdAt: string, updatedAt: string }> | null };
|
||||
|
||||
export type CreateOneServerlessFunctionItemMutationVariables = Exact<{
|
||||
input: CreateServerlessFunctionInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type CreateOneServerlessFunctionItemMutation = { __typename?: 'Mutation', createOneServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } };
|
||||
export type CreateOneServerlessFunctionItemMutation = { __typename?: 'Mutation', createOneServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, forwardedRequestHeaders: Array<string>, createdAt: string, updatedAt: string }> | null } };
|
||||
|
||||
export type DeleteOneServerlessFunctionMutationVariables = Exact<{
|
||||
input: ServerlessFunctionIdInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type DeleteOneServerlessFunctionMutation = { __typename?: 'Mutation', deleteOneServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } };
|
||||
export type DeleteOneServerlessFunctionMutation = { __typename?: 'Mutation', deleteOneServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, forwardedRequestHeaders: Array<string>, createdAt: string, updatedAt: string }> | null } };
|
||||
|
||||
export type ExecuteOneServerlessFunctionMutationVariables = Exact<{
|
||||
input: ExecuteServerlessFunctionInput;
|
||||
@@ -6274,14 +6280,14 @@ export type PublishOneServerlessFunctionMutationVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type PublishOneServerlessFunctionMutation = { __typename?: 'Mutation', publishServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } };
|
||||
export type PublishOneServerlessFunctionMutation = { __typename?: 'Mutation', publishServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, forwardedRequestHeaders: Array<string>, createdAt: string, updatedAt: string }> | null } };
|
||||
|
||||
export type UpdateOneServerlessFunctionMutationVariables = Exact<{
|
||||
input: UpdateServerlessFunctionInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type UpdateOneServerlessFunctionMutation = { __typename?: 'Mutation', updateOneServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } };
|
||||
export type UpdateOneServerlessFunctionMutation = { __typename?: 'Mutation', updateOneServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, forwardedRequestHeaders: Array<string>, createdAt: string, updatedAt: string }> | null } };
|
||||
|
||||
export type FindManyAvailablePackagesQueryVariables = Exact<{
|
||||
input: ServerlessFunctionIdInput;
|
||||
@@ -6293,14 +6299,14 @@ export type FindManyAvailablePackagesQuery = { __typename?: 'Query', getAvailabl
|
||||
export type GetManyServerlessFunctionsQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type GetManyServerlessFunctionsQuery = { __typename?: 'Query', findManyServerlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }> };
|
||||
export type GetManyServerlessFunctionsQuery = { __typename?: 'Query', findManyServerlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, forwardedRequestHeaders: Array<string>, createdAt: string, updatedAt: string }> | null }> };
|
||||
|
||||
export type GetOneServerlessFunctionQueryVariables = Exact<{
|
||||
input: ServerlessFunctionIdInput;
|
||||
}>;
|
||||
|
||||
|
||||
export type GetOneServerlessFunctionQuery = { __typename?: 'Query', findOneServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } };
|
||||
export type GetOneServerlessFunctionQuery = { __typename?: 'Query', findOneServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, forwardedRequestHeaders: Array<string>, createdAt: string, updatedAt: string }> | null } };
|
||||
|
||||
export type FindOneServerlessFunctionSourceCodeQueryVariables = Exact<{
|
||||
input: GetServerlessFunctionSourceCodeInput;
|
||||
@@ -6984,6 +6990,7 @@ export const ServerlessFunctionFieldsFragmentDoc = gql`
|
||||
path
|
||||
isAuthRequired
|
||||
httpMethod
|
||||
forwardedRequestHeaders
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
|
||||
@@ -933,6 +933,8 @@ export type CreateRoleInput = {
|
||||
};
|
||||
|
||||
export type CreateRouteTriggerInput = {
|
||||
/** List of HTTP header names to forward to the serverless function event */
|
||||
forwardedRequestHeaders?: Array<Scalars['String']>;
|
||||
httpMethod?: HttpMethod;
|
||||
isAuthRequired?: Scalars['Boolean'];
|
||||
path: Scalars['String'];
|
||||
@@ -3787,6 +3789,8 @@ export type Role = {
|
||||
export type RouteTrigger = {
|
||||
__typename?: 'RouteTrigger';
|
||||
createdAt: Scalars['DateTime'];
|
||||
/** List of HTTP header names to forward to the serverless function event */
|
||||
forwardedRequestHeaders: Array<Scalars['String']>;
|
||||
httpMethod: HttpMethod;
|
||||
id: Scalars['ID'];
|
||||
isAuthRequired: Scalars['Boolean'];
|
||||
@@ -4470,6 +4474,8 @@ export type UpdateRouteTriggerInput = {
|
||||
};
|
||||
|
||||
export type UpdateRouteTriggerInputUpdates = {
|
||||
/** List of HTTP header names to forward to the serverless function event */
|
||||
forwardedRequestHeaders?: Array<Scalars['String']>;
|
||||
httpMethod: HttpMethod;
|
||||
isAuthRequired: Scalars['Boolean'];
|
||||
path: Scalars['String'];
|
||||
|
||||
+1
@@ -28,6 +28,7 @@ export const SERVERLESS_FUNCTION_FRAGMENT = gql`
|
||||
path
|
||||
isAuthRequired
|
||||
httpMethod
|
||||
forwardedRequestHeaders
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export type { ServerlessFunctionEvent as RoutePayload } from 'twenty-shared/types';
|
||||
@@ -41,6 +41,7 @@ export type {
|
||||
ObjectRecordRestoreEvent,
|
||||
ObjectRecordUpsertEvent,
|
||||
} from './functions/triggers/database-event-payload-type';
|
||||
export type { RoutePayload } from './functions/triggers/route-payload-type';
|
||||
export { defineObject } from './objects/define-object';
|
||||
export { extendObject } from './objects/extend-object';
|
||||
export { Object } from './objects/object.decorator';
|
||||
|
||||
@@ -16,6 +16,7 @@ export default defineFunction({
|
||||
path: '/post-card/create',
|
||||
httpMethod: 'GET',
|
||||
isAuthRequired: false,
|
||||
forwardedRequestHeaders: ['signature'],
|
||||
},
|
||||
{
|
||||
universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
|
||||
|
||||
@@ -96,6 +96,7 @@ describe('loadManifest with test-app', () => {
|
||||
);
|
||||
expect(routeTrigger?.path).toBe('/post-card/create');
|
||||
expect(routeTrigger?.httpMethod).toBe('GET');
|
||||
expect(routeTrigger?.forwardedRequestHeaders).toEqual(['signature']);
|
||||
|
||||
const cronTrigger = testFunction.triggers.find((t) => t.type === 'cron');
|
||||
expect(cronTrigger).toBeDefined();
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { type MigrationInterface, type QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddForwardedRequestHeadersInRouteTriggers1768399525609
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'AddForwardedRequestHeadersInRouteTriggers1768399525609';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."routeTrigger" ADD "forwardedRequestHeaders" jsonb NOT NULL DEFAULT '[]'`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."routeTrigger" DROP COLUMN "forwardedRequestHeaders"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1361,6 +1361,7 @@ export class ApplicationSyncService {
|
||||
path: triggerToSync.path,
|
||||
httpMethod: triggerToSync.httpMethod as HTTPMethod,
|
||||
isAuthRequired: triggerToSync.isAuthRequired,
|
||||
forwardedRequestHeaders: triggerToSync.forwardedRequestHeaders ?? [],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1379,6 +1380,7 @@ export class ApplicationSyncService {
|
||||
path: triggerToCreate.path,
|
||||
httpMethod: triggerToCreate.httpMethod as HTTPMethod,
|
||||
isAuthRequired: triggerToCreate.isAuthRequired,
|
||||
forwardedRequestHeaders: triggerToCreate.forwardedRequestHeaders ?? [],
|
||||
serverlessFunctionId,
|
||||
};
|
||||
|
||||
|
||||
+1
@@ -4,4 +4,5 @@ export const FLAT_ROUTE_TRIGGER_EDITABLE_PROPERTIES = [
|
||||
'path',
|
||||
'isAuthRequired',
|
||||
'httpMethod',
|
||||
'forwardedRequestHeaders',
|
||||
] as const satisfies (keyof FlatRouteTrigger)[];
|
||||
|
||||
+12
@@ -1,9 +1,11 @@
|
||||
import { Field, HideField, InputType } from '@nestjs/graphql';
|
||||
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
@@ -31,6 +33,16 @@ export class CreateRouteTriggerInput {
|
||||
@Field()
|
||||
serverlessFunctionId: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
@Field(() => [String], {
|
||||
defaultValue: [],
|
||||
description:
|
||||
'List of HTTP header names to forward to the serverless function event',
|
||||
})
|
||||
forwardedRequestHeaders: string[];
|
||||
|
||||
@HideField()
|
||||
universalIdentifier?: string;
|
||||
|
||||
|
||||
+6
@@ -18,6 +18,12 @@ export class RouteTriggerDTO {
|
||||
@Field(() => HTTPMethod)
|
||||
httpMethod: HTTPMethod;
|
||||
|
||||
@Field(() => [String], {
|
||||
description:
|
||||
'List of HTTP header names to forward to the serverless function event',
|
||||
})
|
||||
forwardedRequestHeaders: string[];
|
||||
|
||||
@Field()
|
||||
createdAt: Date;
|
||||
|
||||
|
||||
+12
@@ -2,9 +2,11 @@ import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
ValidateNested,
|
||||
@@ -27,6 +29,16 @@ class UpdateRouteTriggerInputUpdates {
|
||||
@IsNotEmpty()
|
||||
@Field(() => HTTPMethod)
|
||||
httpMethod: HTTPMethod;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
@Field(() => [String], {
|
||||
defaultValue: [],
|
||||
description:
|
||||
'List of HTTP header names to forward to the serverless function event',
|
||||
})
|
||||
forwardedRequestHeaders: string[];
|
||||
}
|
||||
|
||||
@InputType()
|
||||
|
||||
+3
@@ -41,6 +41,9 @@ export class RouteTriggerEntity
|
||||
})
|
||||
httpMethod: HTTPMethod;
|
||||
|
||||
@Column({ nullable: false, type: 'jsonb', default: [] })
|
||||
forwardedRequestHeaders: string[];
|
||||
|
||||
@ManyToOne(
|
||||
() => ServerlessFunctionEntity,
|
||||
(serverlessFunction) => serverlessFunction.routeTriggers,
|
||||
|
||||
+8
-10
@@ -14,6 +14,7 @@ import {
|
||||
RouteTriggerExceptionCode,
|
||||
} from 'src/engine/metadata-modules/route-trigger/exceptions/route-trigger.exception';
|
||||
import { RouteTriggerEntity } from 'src/engine/metadata-modules/route-trigger/route-trigger.entity';
|
||||
import { buildServerlessFunctionEvent } from 'src/engine/metadata-modules/route-trigger/utils/build-serverless-function-event.util';
|
||||
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
|
||||
|
||||
@Injectable()
|
||||
@@ -128,21 +129,18 @@ export class RouteTriggerService {
|
||||
});
|
||||
}
|
||||
|
||||
const queryParams = request.query;
|
||||
|
||||
const bodyParams = request.body;
|
||||
|
||||
const executionParams = {
|
||||
...queryParams,
|
||||
...bodyParams,
|
||||
...routeTriggerWithPathParams.pathParams,
|
||||
};
|
||||
const event = buildServerlessFunctionEvent({
|
||||
request,
|
||||
pathParameters: routeTriggerWithPathParams.pathParams,
|
||||
forwardedRequestHeaders:
|
||||
routeTriggerWithPathParams.routeTrigger.forwardedRequestHeaders ?? [],
|
||||
});
|
||||
|
||||
const result =
|
||||
await this.serverlessFunctionService.executeOneServerlessFunction({
|
||||
id: routeTriggerWithPathParams.routeTrigger.serverlessFunction.id,
|
||||
workspaceId: routeTriggerWithPathParams.routeTrigger.workspaceId,
|
||||
payload: executionParams,
|
||||
payload: event,
|
||||
version: 'draft',
|
||||
});
|
||||
|
||||
|
||||
+438
@@ -0,0 +1,438 @@
|
||||
import { type Request } from 'express';
|
||||
|
||||
import {
|
||||
buildServerlessFunctionEvent,
|
||||
extractBody,
|
||||
filterRequestHeaders,
|
||||
normalizePathParameters,
|
||||
normalizeQueryStringParameters,
|
||||
} from 'src/engine/metadata-modules/route-trigger/utils/build-serverless-function-event.util';
|
||||
|
||||
describe('filterRequestHeaders', () => {
|
||||
it('should filter headers based on allowed names', () => {
|
||||
const requestHeaders = {
|
||||
'content-type': 'application/json',
|
||||
authorization: 'Bearer token123',
|
||||
'x-custom-header': 'custom-value',
|
||||
'user-agent': 'test-agent',
|
||||
};
|
||||
const forwardedRequestHeaders = ['content-type', 'authorization'];
|
||||
|
||||
const result = filterRequestHeaders({
|
||||
requestHeaders,
|
||||
forwardedRequestHeaders,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
'content-type': 'application/json',
|
||||
authorization: 'Bearer token123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle case-insensitive header names', () => {
|
||||
const requestHeaders = {
|
||||
'content-type': 'application/json',
|
||||
authorization: 'Bearer token123',
|
||||
};
|
||||
const forwardedRequestHeaders = ['Content-Type', 'AUTHORIZATION'];
|
||||
|
||||
const result = filterRequestHeaders({
|
||||
requestHeaders,
|
||||
forwardedRequestHeaders,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
'content-type': 'application/json',
|
||||
authorization: 'Bearer token123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty object when no headers match', () => {
|
||||
const requestHeaders = {
|
||||
'content-type': 'application/json',
|
||||
};
|
||||
const forwardedRequestHeaders = ['x-custom-header'];
|
||||
|
||||
const result = filterRequestHeaders({
|
||||
requestHeaders,
|
||||
forwardedRequestHeaders,
|
||||
});
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should return empty object when forwardedRequestHeaders is empty', () => {
|
||||
const requestHeaders = {
|
||||
'content-type': 'application/json',
|
||||
};
|
||||
|
||||
const result = filterRequestHeaders({
|
||||
requestHeaders,
|
||||
forwardedRequestHeaders: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should convert array header values to comma-separated string', () => {
|
||||
const requestHeaders = {
|
||||
'x-custom-array-header': ['value1', 'value2', 'value3'],
|
||||
};
|
||||
const forwardedRequestHeaders = ['x-custom-array-header'];
|
||||
|
||||
const result = filterRequestHeaders({
|
||||
requestHeaders,
|
||||
forwardedRequestHeaders,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
'x-custom-array-header': 'value1, value2, value3',
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip undefined header values', () => {
|
||||
const requestHeaders = {
|
||||
'content-type': 'application/json',
|
||||
'x-missing': undefined,
|
||||
};
|
||||
const forwardedRequestHeaders = ['content-type', 'x-missing'];
|
||||
|
||||
const result = filterRequestHeaders({
|
||||
requestHeaders,
|
||||
forwardedRequestHeaders,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
'content-type': 'application/json',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractBody', () => {
|
||||
it('should return null for undefined body', () => {
|
||||
const request = { body: undefined } as Request;
|
||||
|
||||
const result = extractBody(request);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for null body', () => {
|
||||
const request = { body: null } as unknown as Request;
|
||||
|
||||
const result = extractBody(request);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should parse string body as JSON', () => {
|
||||
const request = { body: '{"key":"value"}' } as unknown as Request;
|
||||
|
||||
const result = extractBody(request);
|
||||
|
||||
expect(result).toEqual({ key: 'value' });
|
||||
});
|
||||
|
||||
it('should wrap non-JSON string body in raw property', () => {
|
||||
const request = { body: 'plain text body' } as unknown as Request;
|
||||
|
||||
const result = extractBody(request);
|
||||
|
||||
expect(result).toEqual({ raw: 'plain text body' });
|
||||
});
|
||||
|
||||
it('should return object body as-is (parsed JSON)', () => {
|
||||
const request = {
|
||||
body: { key: 'value', nested: { foo: 'bar' } },
|
||||
} as Request;
|
||||
|
||||
const result = extractBody(request);
|
||||
|
||||
expect(result).toEqual({ key: 'value', nested: { foo: 'bar' } });
|
||||
});
|
||||
|
||||
it('should parse Buffer body as JSON', () => {
|
||||
const request = {
|
||||
body: Buffer.from('{"buffered":"json"}'),
|
||||
} as unknown as Request;
|
||||
|
||||
const result = extractBody(request);
|
||||
|
||||
expect(result).toEqual({ buffered: 'json' });
|
||||
});
|
||||
|
||||
it('should wrap non-JSON Buffer body in raw property', () => {
|
||||
const request = {
|
||||
body: Buffer.from('buffer content'),
|
||||
} as unknown as Request;
|
||||
|
||||
const result = extractBody(request);
|
||||
|
||||
expect(result).toEqual({ raw: 'buffer content' });
|
||||
});
|
||||
|
||||
it('should handle empty object body', () => {
|
||||
const request = { body: {} } as Request;
|
||||
|
||||
const result = extractBody(request);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle array body', () => {
|
||||
const request = { body: [1, 2, 3] } as unknown as Request;
|
||||
|
||||
const result = extractBody(request);
|
||||
|
||||
expect(result).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeQueryStringParameters', () => {
|
||||
it('should handle simple string parameters', () => {
|
||||
const query = { page: '1', limit: '10' };
|
||||
|
||||
const result = normalizeQueryStringParameters(query);
|
||||
|
||||
expect(result).toEqual({ page: '1', limit: '10' });
|
||||
});
|
||||
|
||||
it('should join array parameters with commas', () => {
|
||||
const query = { ids: ['1', '2', '3'] };
|
||||
|
||||
const result = normalizeQueryStringParameters(query);
|
||||
|
||||
expect(result).toEqual({ ids: '1,2,3' });
|
||||
});
|
||||
|
||||
it('should skip undefined parameters', () => {
|
||||
const query = { page: '1', missing: undefined };
|
||||
|
||||
const result = normalizeQueryStringParameters(query);
|
||||
|
||||
expect(result).toEqual({ page: '1' });
|
||||
});
|
||||
|
||||
it('should handle empty query object', () => {
|
||||
const query = {};
|
||||
|
||||
const result = normalizeQueryStringParameters(query);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should stringify nested objects', () => {
|
||||
const query = { filter: { name: 'test' } as unknown as string };
|
||||
|
||||
const result = normalizeQueryStringParameters(query);
|
||||
|
||||
expect(result).toEqual({ filter: '{"name":"test"}' });
|
||||
});
|
||||
|
||||
it('should filter non-string values from arrays and join with commas', () => {
|
||||
const query = { ids: ['1', undefined as unknown as string, '2'] };
|
||||
|
||||
const result = normalizeQueryStringParameters(query);
|
||||
|
||||
expect(result).toEqual({ ids: '1,2' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizePathParameters', () => {
|
||||
it('should handle simple string parameters', () => {
|
||||
const pathParams = { id: '123', slug: 'test' };
|
||||
|
||||
const result = normalizePathParameters(pathParams);
|
||||
|
||||
expect(result).toEqual({ id: '123', slug: 'test' });
|
||||
});
|
||||
|
||||
it('should join array parameters with commas', () => {
|
||||
const pathParams = { ids: ['1', '2', '3'] };
|
||||
|
||||
const result = normalizePathParameters(pathParams);
|
||||
|
||||
expect(result).toEqual({ ids: '1,2,3' });
|
||||
});
|
||||
|
||||
it('should skip undefined parameters', () => {
|
||||
const pathParams = { id: '123', missing: undefined };
|
||||
|
||||
const result = normalizePathParameters(pathParams);
|
||||
|
||||
expect(result).toEqual({ id: '123' });
|
||||
});
|
||||
|
||||
it('should handle empty object', () => {
|
||||
const pathParams = {};
|
||||
|
||||
const result = normalizePathParameters(pathParams);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildServerlessFunctionEvent', () => {
|
||||
const createMockRequest = (overrides: Partial<Request> = {}): Request =>
|
||||
({
|
||||
headers: {},
|
||||
query: {},
|
||||
body: undefined,
|
||||
method: 'GET',
|
||||
path: '/test',
|
||||
...overrides,
|
||||
}) as Request;
|
||||
|
||||
it('should build a complete event from Express request', () => {
|
||||
const request = createMockRequest({
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: 'Bearer token',
|
||||
'user-agent': 'test',
|
||||
},
|
||||
query: { page: '1' },
|
||||
body: { data: 'test' },
|
||||
method: 'POST',
|
||||
path: '/s/users/123',
|
||||
});
|
||||
|
||||
const result = buildServerlessFunctionEvent({
|
||||
request,
|
||||
pathParameters: { id: '123' },
|
||||
forwardedRequestHeaders: ['content-type', 'authorization'],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: 'Bearer token',
|
||||
},
|
||||
queryStringParameters: { page: '1' },
|
||||
pathParameters: { id: '123' },
|
||||
body: { data: 'test' },
|
||||
isBase64Encoded: false,
|
||||
requestContext: {
|
||||
http: {
|
||||
method: 'POST',
|
||||
path: '/s/users/123',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve the request path as-is', () => {
|
||||
const request = createMockRequest({
|
||||
path: '/s/api/users',
|
||||
});
|
||||
|
||||
const result = buildServerlessFunctionEvent({
|
||||
request,
|
||||
pathParameters: {},
|
||||
forwardedRequestHeaders: [],
|
||||
});
|
||||
|
||||
expect(result.requestContext.http.path).toBe('/s/api/users');
|
||||
});
|
||||
|
||||
it('should preserve path without prefix', () => {
|
||||
const request = createMockRequest({
|
||||
path: '/api/users',
|
||||
});
|
||||
|
||||
const result = buildServerlessFunctionEvent({
|
||||
request,
|
||||
pathParameters: {},
|
||||
forwardedRequestHeaders: [],
|
||||
});
|
||||
|
||||
expect(result.requestContext.http.path).toBe('/api/users');
|
||||
});
|
||||
|
||||
it('should handle GET request with no body', () => {
|
||||
const request = createMockRequest({
|
||||
method: 'GET',
|
||||
query: { search: 'test' },
|
||||
body: undefined,
|
||||
});
|
||||
|
||||
const result = buildServerlessFunctionEvent({
|
||||
request,
|
||||
pathParameters: {},
|
||||
forwardedRequestHeaders: [],
|
||||
});
|
||||
|
||||
expect(result.body).toBeNull();
|
||||
expect(result.queryStringParameters).toEqual({ search: 'test' });
|
||||
});
|
||||
|
||||
it('should handle DELETE request with path parameters', () => {
|
||||
const request = createMockRequest({
|
||||
method: 'DELETE',
|
||||
path: '/s/users/456',
|
||||
});
|
||||
|
||||
const result = buildServerlessFunctionEvent({
|
||||
request,
|
||||
pathParameters: { userId: '456' },
|
||||
forwardedRequestHeaders: [],
|
||||
});
|
||||
|
||||
expect(result.requestContext.http.method).toBe('DELETE');
|
||||
expect(result.pathParameters).toEqual({ userId: '456' });
|
||||
});
|
||||
|
||||
it('should filter only allowed headers', () => {
|
||||
const request = createMockRequest({
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: 'Bearer secret',
|
||||
'x-api-key': 'key123',
|
||||
cookie: 'session=abc',
|
||||
},
|
||||
});
|
||||
|
||||
const result = buildServerlessFunctionEvent({
|
||||
request,
|
||||
pathParameters: {},
|
||||
forwardedRequestHeaders: ['x-api-key'],
|
||||
});
|
||||
|
||||
expect(result.headers).toEqual({
|
||||
'x-api-key': 'key123',
|
||||
});
|
||||
expect(result.headers['authorization']).toBeUndefined();
|
||||
expect(result.headers['cookie']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should set isBase64Encoded to false', () => {
|
||||
const request = createMockRequest();
|
||||
|
||||
const result = buildServerlessFunctionEvent({
|
||||
request,
|
||||
pathParameters: {},
|
||||
forwardedRequestHeaders: [],
|
||||
});
|
||||
|
||||
expect(result.isBase64Encoded).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle complex path parameters', () => {
|
||||
const request = createMockRequest({
|
||||
path: '/s/organizations/org1/users/user1/posts',
|
||||
});
|
||||
|
||||
const result = buildServerlessFunctionEvent({
|
||||
request,
|
||||
pathParameters: {
|
||||
orgId: 'org1',
|
||||
userId: 'user1',
|
||||
},
|
||||
forwardedRequestHeaders: [],
|
||||
});
|
||||
|
||||
expect(result.pathParameters).toEqual({
|
||||
orgId: 'org1',
|
||||
userId: 'user1',
|
||||
});
|
||||
});
|
||||
});
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
import { type Request } from 'express';
|
||||
import { type ServerlessFunctionEvent } from 'twenty-shared/types';
|
||||
|
||||
/**
|
||||
* Filters HTTP headers from Express request based on allowed header names
|
||||
* Header names are case-insensitive as per HTTP specification
|
||||
*/
|
||||
export const filterRequestHeaders = ({
|
||||
requestHeaders,
|
||||
forwardedRequestHeaders,
|
||||
}: {
|
||||
requestHeaders: Request['headers'];
|
||||
forwardedRequestHeaders: string[];
|
||||
}): Record<string, string | undefined> => {
|
||||
const lowercaseForwardedHeaders = forwardedRequestHeaders.map((h) =>
|
||||
h.toLowerCase(),
|
||||
);
|
||||
|
||||
const filteredHeaders: Record<string, string | undefined> = {};
|
||||
|
||||
for (const headerName of lowercaseForwardedHeaders) {
|
||||
const headerValue = requestHeaders[headerName];
|
||||
|
||||
if (headerValue !== undefined) {
|
||||
// Convert string[] to comma-separated string (as per HTTP spec)
|
||||
filteredHeaders[headerName] = Array.isArray(headerValue)
|
||||
? headerValue.join(', ')
|
||||
: headerValue;
|
||||
}
|
||||
}
|
||||
|
||||
return filteredHeaders;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts the body from Express request as an object
|
||||
* Express body-parser middleware parses JSON bodies automatically
|
||||
* Returns null if body is empty/undefined
|
||||
*/
|
||||
export const extractBody = (request: Request): object | null => {
|
||||
if (request.body === undefined || request.body === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If body is already an object (parsed JSON by body-parser), return as-is
|
||||
if (typeof request.body === 'object' && !Buffer.isBuffer(request.body)) {
|
||||
return request.body;
|
||||
}
|
||||
|
||||
// If body is a string, try to parse as JSON
|
||||
if (typeof request.body === 'string') {
|
||||
try {
|
||||
return JSON.parse(request.body);
|
||||
} catch {
|
||||
// If not valid JSON, wrap in an object
|
||||
return { raw: request.body };
|
||||
}
|
||||
}
|
||||
|
||||
// If body is a Buffer, try to parse as JSON
|
||||
if (Buffer.isBuffer(request.body)) {
|
||||
try {
|
||||
return JSON.parse(request.body.toString('utf-8'));
|
||||
} catch {
|
||||
return { raw: request.body.toString('utf-8') };
|
||||
}
|
||||
}
|
||||
|
||||
return { raw: String(request.body) };
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts Express query parameters to a normalized string format
|
||||
* Arrays are joined with commas (e.g., ['1', '2', '3'] → '1,2,3')
|
||||
*/
|
||||
export const normalizeQueryStringParameters = (
|
||||
query: Request['query'],
|
||||
): Record<string, string | undefined> => {
|
||||
const normalized: Record<string, string | undefined> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// Join array values with commas
|
||||
const stringValues = value.filter(
|
||||
(v): v is string => typeof v === 'string',
|
||||
);
|
||||
|
||||
normalized[key] = stringValues.join(',');
|
||||
} else if (typeof value === 'string') {
|
||||
normalized[key] = value;
|
||||
} else if (typeof value === 'object') {
|
||||
// Handle nested query objects (e.g., ?foo[bar]=baz)
|
||||
// This is uncommon in REST APIs, convert to JSON string as fallback
|
||||
normalized[key] = JSON.stringify(value);
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes path parameters to string format
|
||||
* Arrays are joined with commas (e.g., ['1', '2', '3'] → '1,2,3')
|
||||
*/
|
||||
export const normalizePathParameters = (
|
||||
pathParams: Record<string, string | string[] | undefined>,
|
||||
): Record<string, string | undefined> => {
|
||||
const normalized: Record<string, string | undefined> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(pathParams)) {
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
normalized[key] = value.join(',');
|
||||
} else {
|
||||
normalized[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds an AWS HTTP API v2 compatible event from an Express request
|
||||
* @see https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html
|
||||
*/
|
||||
export const buildServerlessFunctionEvent = ({
|
||||
request,
|
||||
pathParameters,
|
||||
forwardedRequestHeaders,
|
||||
}: {
|
||||
request: Request;
|
||||
pathParameters: Record<string, string | string[] | undefined>;
|
||||
forwardedRequestHeaders: string[];
|
||||
}): ServerlessFunctionEvent => {
|
||||
return {
|
||||
headers: filterRequestHeaders({
|
||||
requestHeaders: request.headers,
|
||||
forwardedRequestHeaders,
|
||||
}),
|
||||
queryStringParameters: normalizeQueryStringParameters(request.query),
|
||||
pathParameters: normalizePathParameters(pathParameters),
|
||||
body: extractBody(request),
|
||||
isBase64Encoded: false,
|
||||
requestContext: {
|
||||
http: {
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
+2
@@ -21,6 +21,8 @@ export const fromCreateRouteTriggerInputToFlatRouteTrigger = ({
|
||||
path: createRouteTriggerInput.path,
|
||||
isAuthRequired: createRouteTriggerInput.isAuthRequired,
|
||||
httpMethod: createRouteTriggerInput.httpMethod,
|
||||
forwardedRequestHeaders:
|
||||
createRouteTriggerInput.forwardedRequestHeaders ?? [],
|
||||
serverlessFunctionId: createRouteTriggerInput.serverlessFunctionId,
|
||||
workspaceId,
|
||||
createdAt: now.toISOString(),
|
||||
|
||||
+3
@@ -29,5 +29,8 @@ export const fromUpdateRouteTriggerInputToFlatRouteTriggerToUpdateOrThrow = ({
|
||||
path: updateRouteTriggerInput.update.path,
|
||||
isAuthRequired: updateRouteTriggerInput.update.isAuthRequired,
|
||||
httpMethod: updateRouteTriggerInput.update.httpMethod,
|
||||
forwardedRequestHeaders:
|
||||
updateRouteTriggerInput.update.forwardedRequestHeaders ??
|
||||
existingFlatRouteTrigger.forwardedRequestHeaders,
|
||||
};
|
||||
};
|
||||
|
||||
+1
@@ -212,6 +212,7 @@ describe('Successful user and workspace creation', () => {
|
||||
isAuthRequired: true,
|
||||
httpMethod: HTTPMethod.GET,
|
||||
serverlessFunctionId: serverlessFunction.id,
|
||||
forwardedRequestHeaders: [],
|
||||
},
|
||||
token: newWorkspaceAccessToken,
|
||||
expectToFail: false,
|
||||
|
||||
@@ -45,6 +45,7 @@ export type RouteTrigger = {
|
||||
path: string;
|
||||
httpMethod: `${HTTPMethod}`;
|
||||
isAuthRequired: boolean;
|
||||
forwardedRequestHeaders?: string[];
|
||||
};
|
||||
|
||||
export type ServerlessFunctionTriggerManifest = SyncableEntityOptions &
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* AWS HTTP API v2 compatible request format for serverless functions
|
||||
* @see https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html
|
||||
*
|
||||
* @typeParam TBody - The type of the request body. Defaults to `object` for parsed JSON bodies.
|
||||
*/
|
||||
export type ServerlessFunctionEvent<TBody = object> = {
|
||||
/** HTTP headers (filtered by forwardedRequestHeaders in route trigger) */
|
||||
headers: Record<string, string | undefined>;
|
||||
|
||||
/** Query string parameters (multiple values are joined with commas, e.g., "1,2,3") */
|
||||
queryStringParameters: Record<string, string | undefined>;
|
||||
|
||||
/** Path parameters extracted from the route pattern (e.g., /users/:id → { id: '123' }). Multiple values are joined with commas. */
|
||||
pathParameters: Record<string, string | undefined>;
|
||||
|
||||
/** Request body */
|
||||
body: TBody | null;
|
||||
|
||||
/** Whether the body is base64 encoded */
|
||||
isBase64Encoded: boolean;
|
||||
|
||||
/** Request context containing HTTP method, path, and other metadata */
|
||||
requestContext: {
|
||||
http: {
|
||||
/** HTTP method (GET, POST, PUT, PATCH, DELETE) */
|
||||
method: string;
|
||||
/** Raw request path (e.g., /users/123) */
|
||||
path: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -196,6 +196,7 @@ export type {
|
||||
RelationPredicateValue,
|
||||
RowLevelPermissionPredicateValue,
|
||||
} from './RowLevelPermissionPredicateValue';
|
||||
export type { ServerlessFunctionEvent } from './ServerlessFunctionEvent';
|
||||
export { SettingsPath } from './SettingsPath';
|
||||
export type { Sources } from './SourcesType';
|
||||
export type {
|
||||
|
||||
Reference in New Issue
Block a user