From 6423c4cd3c00d3c9b62687abcd76304149629556 Mon Sep 17 00:00:00 2001 From: martmull Date: Fri, 19 Jun 2026 23:34:43 +0200 Subject: [PATCH] Add recall io webhook endpoint (#21879) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context Bot-recording integrations (e.g. the Recall.ai meeting bot) receive webhooks from a third-party provider that delivers **every tenant's events to a single URL**. Our existing `route-trigger` (`/s/…`) resolves the workspace from the request host, which can't work for one shared multi-tenant webhook URL. We need an instance-scoped ingress that identifies the target workspace from the payload instead. ## Strategy Add a new **`ingress-trigger`** logic-function trigger, mirroring `route-trigger`: - A public endpoint keyed by the app's identifiers: `POST /webhooks/ingress/:applicationRegistrationUniversalIdentifier/:logicFunctionUniversalIdentifier`. - The logic function declares an `ingressTriggerSettings` block in its manifest describing how to find the workspace in the payload (`workspaceId: { source: 'body' | 'query' | 'header', path }`). - Core only **resolves the workspace** (declarative, fail-closed, prototype-safe path getter), verifies the app is installed in that workspace, then runs the function **synchronously** so the provider sees the response (status codes / retries). - **Signature verification stays in the logic function** (it gets `rawBody` + forwarded headers), keeping core provider-agnostic. - Shared execution logic (`build event → execute → map response`) extracted into `LogicFunctionTriggerService`, now reused by both `route-trigger` and `ingress-trigger`. ## Major changes - **twenty-shared**: new `ingressTriggerSettings` on `LogicFunctionManifest` (`IngressTriggerSettings` type). - **twenty-server**: new `ingress-trigger` module (controller, service, exception + filter, workspace-id resolver util). - **twenty-server**: extracted `LogicFunctionTriggerService` + `route-trigger-response.util` (response builder + sender); refactored `RouteTriggerService` and both controllers to reuse them. - **twenty-docs**: documented the ingress trigger (endpoint, workspace resolution, signature responsibility, provider HMAC examples). - Unit tests for the resolver and the ingress service. --- .../src/metadata/generated/schema.graphql | 100 ++-- .../src/metadata/generated/schema.ts | 84 +-- .../src/metadata/generated/types.ts | 480 +++++++++--------- .../extend/apps/logic/logic-functions.mdx | 85 ++++ .../logic-function-trigger.module.ts | 8 +- .../logic-function-trigger.service.ts | 67 +++ .../__tests__/route-trigger.service.spec.ts | 2 +- .../triggers/route/route-trigger.service.ts | 60 +-- .../utils/build-logic-function-event.util.ts | 7 +- .../utils/route-trigger-response.util.ts | 70 +++ .../server-webhook-trigger.service.spec.ts | 172 +++++++ ...bhook-trigger-rest-api-exception-filter.ts | 66 +++ .../server-webhook-trigger.exception.ts | 52 ++ .../server-webhook-trigger.controller.ts | 47 ++ .../server-webhook-trigger.module.ts | 20 + .../server-webhook-trigger.service.ts | 184 +++++++ ...lve-workspace-id-from-request.util.spec.ts | 89 ++++ .../resolve-workspace-id-from-request.util.ts | 79 +++ .../metadata-engine.module.ts | 2 + .../route-trigger/route-trigger.controller.ts | 64 +-- .../twenty-shared/src/application/index.ts | 4 + .../application/logicFunctionManifestType.ts | 2 + .../serverWebhookTriggerSettingsType.ts | 9 + 23 files changed, 1314 insertions(+), 439 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/server-webhook-trigger/__tests__/server-webhook-trigger.service.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/server-webhook-trigger/exceptions/server-webhook-trigger-rest-api-exception-filter.ts create mode 100644 packages/twenty-server/src/engine/core-modules/server-webhook-trigger/exceptions/server-webhook-trigger.exception.ts create mode 100644 packages/twenty-server/src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.controller.ts create mode 100644 packages/twenty-server/src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.module.ts create mode 100644 packages/twenty-server/src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/server-webhook-trigger/utils/__tests__/resolve-workspace-id-from-request.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/server-webhook-trigger/utils/resolve-workspace-id-from-request.util.ts create mode 100644 packages/twenty-shared/src/application/serverWebhookTriggerSettingsType.ts diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index ae44f73366..5ef751705e 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -3110,6 +3110,13 @@ type Query { getViewGroup(id: String!): ViewGroup myMessageFolders(messageChannelId: UUID): [MessageFolder!]! myCalendarChannels(connectedAccountId: UUID): [CalendarChannel!]! + findApplicationRegistrationByClientId(clientId: String!): PublicApplicationRegistration + findApplicationRegistrationByUniversalIdentifier(universalIdentifier: String!): ApplicationRegistration + findManyApplicationRegistrations: [ApplicationRegistration!]! + findOneApplicationRegistration(id: String!): ApplicationRegistration! + findApplicationRegistrationStats(id: String!): ApplicationRegistrationStats! + findApplicationRegistrationVariables(applicationRegistrationId: String!): [ApplicationRegistrationVariableDTO!]! + applicationRegistrationTarballUrl(id: String!): String minimalMetadata: MinimalMetadata! appConnections(filter: ListAppConnectionsInput): [AppConnection!]! appConnection(id: ID!): AppConnection! @@ -3128,13 +3135,6 @@ type Query { checkWorkspaceSubdomainAvailability(subdomain: String!): SubdomainAvailabilityDTO! getWorkspaceCreationDefaults: WorkspaceCreationDefaultsDTO! validatePasswordResetToken(passwordResetToken: String!): ValidatePasswordResetToken! - findApplicationRegistrationByClientId(clientId: String!): PublicApplicationRegistration - findApplicationRegistrationByUniversalIdentifier(universalIdentifier: String!): ApplicationRegistration - findManyApplicationRegistrations: [ApplicationRegistration!]! - findOneApplicationRegistration(id: String!): ApplicationRegistration! - findApplicationRegistrationStats(id: String!): ApplicationRegistrationStats! - findApplicationRegistrationVariables(applicationRegistrationId: String!): [ApplicationRegistrationVariableDTO!]! - applicationRegistrationTarballUrl(id: String!): String currentUser: User! currentWorkspace: Workspace! getPublicWorkspaceDataByDomain(origin: String): PublicWorkspaceData! @@ -3377,6 +3377,15 @@ type Mutation { updateMessageFolder(input: UpdateMessageFolderInput!): MessageFolder! updateMessageFolders(input: UpdateMessageFoldersInput!): [MessageFolder!]! updateCalendarChannel(input: UpdateCalendarChannelInput!): CalendarChannel! + createApplicationRegistration(input: CreateApplicationRegistrationInput!): CreateApplicationRegistration! + updateApplicationRegistration(input: UpdateApplicationRegistrationInput!): ApplicationRegistration! + deleteApplicationRegistration(id: String!): Boolean! + rotateApplicationRegistrationClientSecret(id: String!): RotateClientSecret! + createApplicationRegistrationVariable(input: CreateApplicationRegistrationVariableInput!): ApplicationRegistrationVariable! + updateApplicationRegistrationVariable(input: UpdateApplicationRegistrationVariableInput!): ApplicationRegistrationVariable! + deleteApplicationRegistrationVariable(id: String!): Boolean! + uploadAppTarball(file: Upload!, universalIdentifier: String): ApplicationRegistration! + transferApplicationRegistrationOwnership(applicationRegistrationId: String!, targetWorkspaceSubdomain: String!): ApplicationRegistration! createChatThread: AgentChatThread! sendChatMessage(threadId: UUID!, text: String!, messageId: UUID!, browsingContext: JSON, modelId: String, fileAttachments: [FileAttachmentInput!]): SendChatMessageResult! stopAgentChatStream(threadId: UUID!): Boolean! @@ -3410,15 +3419,6 @@ type Mutation { generatePlaygroundToken: AuthToken! emailPasswordResetLink(email: String!, workspaceId: UUID): EmailPasswordResetLink! updatePasswordViaResetToken(passwordResetToken: String!, newPassword: String!): InvalidatePassword! - createApplicationRegistration(input: CreateApplicationRegistrationInput!): CreateApplicationRegistration! - updateApplicationRegistration(input: UpdateApplicationRegistrationInput!): ApplicationRegistration! - deleteApplicationRegistration(id: String!): Boolean! - rotateApplicationRegistrationClientSecret(id: String!): RotateClientSecret! - createApplicationRegistrationVariable(input: CreateApplicationRegistrationVariableInput!): ApplicationRegistrationVariable! - updateApplicationRegistrationVariable(input: UpdateApplicationRegistrationVariableInput!): ApplicationRegistrationVariable! - deleteApplicationRegistrationVariable(id: String!): Boolean! - uploadAppTarball(file: Upload!, universalIdentifier: String): ApplicationRegistration! - transferApplicationRegistrationOwnership(applicationRegistrationId: String!, targetWorkspaceSubdomain: String!): ApplicationRegistration! initiateOTPProvisioning(loginToken: String!, origin: String!): InitiateTwoFactorAuthenticationProvisioning! initiateOTPProvisioningForAuthenticatedUser: InitiateTwoFactorAuthenticationProvisioning! deleteTwoFactorAuthenticationMethod(twoFactorAuthenticationMethodId: UUID!): DeleteTwoFactorAuthenticationMethod! @@ -4405,40 +4405,6 @@ input UpdateCalendarChannelInputUpdates { isSyncEnabled: Boolean } -input FileAttachmentInput { - id: UUID! - filename: String! -} - -input CreateSkillInput { - id: UUID - name: String! - label: String! - icon: String - description: String - content: String! -} - -input UpdateSkillInput { - id: UUID! - name: String - label: String - icon: String - description: String - content: String - isActive: Boolean -} - -input GetAuthorizationUrlForSSOInput { - identityProviderId: UUID! - workspaceInviteHash: String -} - -input SignUpInNewWorkspaceInput { - displayName: String - subdomain: String -} - input CreateApplicationRegistrationInput { name: String! universalIdentifier: String @@ -4477,6 +4443,40 @@ input UpdateApplicationRegistrationVariablePayload { description: String } +input FileAttachmentInput { + id: UUID! + filename: String! +} + +input CreateSkillInput { + id: UUID + name: String! + label: String! + icon: String + description: String + content: String! +} + +input UpdateSkillInput { + id: UUID! + name: String + label: String + icon: String + description: String + content: String + isActive: Boolean +} + +input GetAuthorizationUrlForSSOInput { + identityProviderId: UUID! + workspaceInviteHash: String +} + +input SignUpInNewWorkspaceInput { + displayName: String + subdomain: String +} + input UpdateWorkspaceMemberSettingsInput { workspaceMemberId: UUID! update: JSON! diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 2b860b0e28..b975490997 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -2708,6 +2708,13 @@ export interface Query { getViewGroup?: ViewGroup myMessageFolders: MessageFolder[] myCalendarChannels: CalendarChannel[] + findApplicationRegistrationByClientId?: PublicApplicationRegistration + findApplicationRegistrationByUniversalIdentifier?: ApplicationRegistration + findManyApplicationRegistrations: ApplicationRegistration[] + findOneApplicationRegistration: ApplicationRegistration + findApplicationRegistrationStats: ApplicationRegistrationStats + findApplicationRegistrationVariables: ApplicationRegistrationVariableDTO[] + applicationRegistrationTarballUrl?: Scalars['String'] minimalMetadata: MinimalMetadata appConnections: AppConnection[] appConnection: AppConnection @@ -2726,13 +2733,6 @@ export interface Query { checkWorkspaceSubdomainAvailability: SubdomainAvailabilityDTO getWorkspaceCreationDefaults: WorkspaceCreationDefaultsDTO validatePasswordResetToken: ValidatePasswordResetToken - findApplicationRegistrationByClientId?: PublicApplicationRegistration - findApplicationRegistrationByUniversalIdentifier?: ApplicationRegistration - findManyApplicationRegistrations: ApplicationRegistration[] - findOneApplicationRegistration: ApplicationRegistration - findApplicationRegistrationStats: ApplicationRegistrationStats - findApplicationRegistrationVariables: ApplicationRegistrationVariableDTO[] - applicationRegistrationTarballUrl?: Scalars['String'] currentUser: User currentWorkspace: Workspace getPublicWorkspaceDataByDomain: PublicWorkspaceData @@ -2896,6 +2896,15 @@ export interface Mutation { updateMessageFolder: MessageFolder updateMessageFolders: MessageFolder[] updateCalendarChannel: CalendarChannel + createApplicationRegistration: CreateApplicationRegistration + updateApplicationRegistration: ApplicationRegistration + deleteApplicationRegistration: Scalars['Boolean'] + rotateApplicationRegistrationClientSecret: RotateClientSecret + createApplicationRegistrationVariable: ApplicationRegistrationVariable + updateApplicationRegistrationVariable: ApplicationRegistrationVariable + deleteApplicationRegistrationVariable: Scalars['Boolean'] + uploadAppTarball: ApplicationRegistration + transferApplicationRegistrationOwnership: ApplicationRegistration createChatThread: AgentChatThread sendChatMessage: SendChatMessageResult stopAgentChatStream: Scalars['Boolean'] @@ -2929,15 +2938,6 @@ export interface Mutation { generatePlaygroundToken: AuthToken emailPasswordResetLink: EmailPasswordResetLink updatePasswordViaResetToken: InvalidatePassword - createApplicationRegistration: CreateApplicationRegistration - updateApplicationRegistration: ApplicationRegistration - deleteApplicationRegistration: Scalars['Boolean'] - rotateApplicationRegistrationClientSecret: RotateClientSecret - createApplicationRegistrationVariable: ApplicationRegistrationVariable - updateApplicationRegistrationVariable: ApplicationRegistrationVariable - deleteApplicationRegistrationVariable: Scalars['Boolean'] - uploadAppTarball: ApplicationRegistration - transferApplicationRegistrationOwnership: ApplicationRegistration initiateOTPProvisioning: InitiateTwoFactorAuthenticationProvisioning initiateOTPProvisioningForAuthenticatedUser: InitiateTwoFactorAuthenticationProvisioning deleteTwoFactorAuthenticationMethod: DeleteTwoFactorAuthenticationMethod @@ -5867,6 +5867,13 @@ export interface QueryGenqlSelection{ getViewGroup?: (ViewGroupGenqlSelection & { __args: {id: Scalars['String']} }) myMessageFolders?: (MessageFolderGenqlSelection & { __args?: {messageChannelId?: (Scalars['UUID'] | null)} }) myCalendarChannels?: (CalendarChannelGenqlSelection & { __args?: {connectedAccountId?: (Scalars['UUID'] | null)} }) + findApplicationRegistrationByClientId?: (PublicApplicationRegistrationGenqlSelection & { __args: {clientId: Scalars['String']} }) + findApplicationRegistrationByUniversalIdentifier?: (ApplicationRegistrationGenqlSelection & { __args: {universalIdentifier: Scalars['String']} }) + findManyApplicationRegistrations?: ApplicationRegistrationGenqlSelection + findOneApplicationRegistration?: (ApplicationRegistrationGenqlSelection & { __args: {id: Scalars['String']} }) + findApplicationRegistrationStats?: (ApplicationRegistrationStatsGenqlSelection & { __args: {id: Scalars['String']} }) + findApplicationRegistrationVariables?: (ApplicationRegistrationVariableDTOGenqlSelection & { __args: {applicationRegistrationId: Scalars['String']} }) + applicationRegistrationTarballUrl?: { __args: {id: Scalars['String']} } minimalMetadata?: MinimalMetadataGenqlSelection appConnections?: (AppConnectionGenqlSelection & { __args?: {filter?: (ListAppConnectionsInput | null)} }) appConnection?: (AppConnectionGenqlSelection & { __args: {id: Scalars['ID']} }) @@ -5885,13 +5892,6 @@ export interface QueryGenqlSelection{ checkWorkspaceSubdomainAvailability?: (SubdomainAvailabilityDTOGenqlSelection & { __args: {subdomain: Scalars['String']} }) getWorkspaceCreationDefaults?: WorkspaceCreationDefaultsDTOGenqlSelection validatePasswordResetToken?: (ValidatePasswordResetTokenGenqlSelection & { __args: {passwordResetToken: Scalars['String']} }) - findApplicationRegistrationByClientId?: (PublicApplicationRegistrationGenqlSelection & { __args: {clientId: Scalars['String']} }) - findApplicationRegistrationByUniversalIdentifier?: (ApplicationRegistrationGenqlSelection & { __args: {universalIdentifier: Scalars['String']} }) - findManyApplicationRegistrations?: ApplicationRegistrationGenqlSelection - findOneApplicationRegistration?: (ApplicationRegistrationGenqlSelection & { __args: {id: Scalars['String']} }) - findApplicationRegistrationStats?: (ApplicationRegistrationStatsGenqlSelection & { __args: {id: Scalars['String']} }) - findApplicationRegistrationVariables?: (ApplicationRegistrationVariableDTOGenqlSelection & { __args: {applicationRegistrationId: Scalars['String']} }) - applicationRegistrationTarballUrl?: { __args: {id: Scalars['String']} } currentUser?: UserGenqlSelection currentWorkspace?: WorkspaceGenqlSelection getPublicWorkspaceDataByDomain?: (PublicWorkspaceDataGenqlSelection & { __args?: {origin?: (Scalars['String'] | null)} }) @@ -6080,6 +6080,15 @@ export interface MutationGenqlSelection{ updateMessageFolder?: (MessageFolderGenqlSelection & { __args: {input: UpdateMessageFolderInput} }) updateMessageFolders?: (MessageFolderGenqlSelection & { __args: {input: UpdateMessageFoldersInput} }) updateCalendarChannel?: (CalendarChannelGenqlSelection & { __args: {input: UpdateCalendarChannelInput} }) + createApplicationRegistration?: (CreateApplicationRegistrationGenqlSelection & { __args: {input: CreateApplicationRegistrationInput} }) + updateApplicationRegistration?: (ApplicationRegistrationGenqlSelection & { __args: {input: UpdateApplicationRegistrationInput} }) + deleteApplicationRegistration?: { __args: {id: Scalars['String']} } + rotateApplicationRegistrationClientSecret?: (RotateClientSecretGenqlSelection & { __args: {id: Scalars['String']} }) + createApplicationRegistrationVariable?: (ApplicationRegistrationVariableGenqlSelection & { __args: {input: CreateApplicationRegistrationVariableInput} }) + updateApplicationRegistrationVariable?: (ApplicationRegistrationVariableGenqlSelection & { __args: {input: UpdateApplicationRegistrationVariableInput} }) + deleteApplicationRegistrationVariable?: { __args: {id: Scalars['String']} } + uploadAppTarball?: (ApplicationRegistrationGenqlSelection & { __args: {file: Scalars['Upload'], universalIdentifier?: (Scalars['String'] | null)} }) + transferApplicationRegistrationOwnership?: (ApplicationRegistrationGenqlSelection & { __args: {applicationRegistrationId: Scalars['String'], targetWorkspaceSubdomain: Scalars['String']} }) createChatThread?: AgentChatThreadGenqlSelection sendChatMessage?: (SendChatMessageResultGenqlSelection & { __args: {threadId: Scalars['UUID'], text: Scalars['String'], messageId: Scalars['UUID'], browsingContext?: (Scalars['JSON'] | null), modelId?: (Scalars['String'] | null), fileAttachments?: (FileAttachmentInput[] | null)} }) stopAgentChatStream?: { __args: {threadId: Scalars['UUID']} } @@ -6113,15 +6122,6 @@ export interface MutationGenqlSelection{ generatePlaygroundToken?: AuthTokenGenqlSelection emailPasswordResetLink?: (EmailPasswordResetLinkGenqlSelection & { __args: {email: Scalars['String'], workspaceId?: (Scalars['UUID'] | null)} }) updatePasswordViaResetToken?: (InvalidatePasswordGenqlSelection & { __args: {passwordResetToken: Scalars['String'], newPassword: Scalars['String']} }) - createApplicationRegistration?: (CreateApplicationRegistrationGenqlSelection & { __args: {input: CreateApplicationRegistrationInput} }) - updateApplicationRegistration?: (ApplicationRegistrationGenqlSelection & { __args: {input: UpdateApplicationRegistrationInput} }) - deleteApplicationRegistration?: { __args: {id: Scalars['String']} } - rotateApplicationRegistrationClientSecret?: (RotateClientSecretGenqlSelection & { __args: {id: Scalars['String']} }) - createApplicationRegistrationVariable?: (ApplicationRegistrationVariableGenqlSelection & { __args: {input: CreateApplicationRegistrationVariableInput} }) - updateApplicationRegistrationVariable?: (ApplicationRegistrationVariableGenqlSelection & { __args: {input: UpdateApplicationRegistrationVariableInput} }) - deleteApplicationRegistrationVariable?: { __args: {id: Scalars['String']} } - uploadAppTarball?: (ApplicationRegistrationGenqlSelection & { __args: {file: Scalars['Upload'], universalIdentifier?: (Scalars['String'] | null)} }) - transferApplicationRegistrationOwnership?: (ApplicationRegistrationGenqlSelection & { __args: {applicationRegistrationId: Scalars['String'], targetWorkspaceSubdomain: Scalars['String']} }) initiateOTPProvisioning?: (InitiateTwoFactorAuthenticationProvisioningGenqlSelection & { __args: {loginToken: Scalars['String'], origin: Scalars['String']} }) initiateOTPProvisioningForAuthenticatedUser?: InitiateTwoFactorAuthenticationProvisioningGenqlSelection deleteTwoFactorAuthenticationMethod?: (DeleteTwoFactorAuthenticationMethodGenqlSelection & { __args: {twoFactorAuthenticationMethodId: Scalars['UUID']} }) @@ -6494,16 +6494,6 @@ export interface UpdateCalendarChannelInput {id: Scalars['UUID'],update: UpdateC export interface UpdateCalendarChannelInputUpdates {visibility?: (CalendarChannelVisibility | null),isContactAutoCreationEnabled?: (Scalars['Boolean'] | null),contactAutoCreationPolicy?: (CalendarChannelContactAutoCreationPolicy | null),isSyncEnabled?: (Scalars['Boolean'] | null)} -export interface FileAttachmentInput {id: Scalars['UUID'],filename: Scalars['String']} - -export interface CreateSkillInput {id?: (Scalars['UUID'] | null),name: Scalars['String'],label: Scalars['String'],icon?: (Scalars['String'] | null),description?: (Scalars['String'] | null),content: Scalars['String']} - -export interface UpdateSkillInput {id: Scalars['UUID'],name?: (Scalars['String'] | null),label?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),description?: (Scalars['String'] | null),content?: (Scalars['String'] | null),isActive?: (Scalars['Boolean'] | null)} - -export interface GetAuthorizationUrlForSSOInput {identityProviderId: Scalars['UUID'],workspaceInviteHash?: (Scalars['String'] | null)} - -export interface SignUpInNewWorkspaceInput {displayName?: (Scalars['String'] | null),subdomain?: (Scalars['String'] | null)} - export interface CreateApplicationRegistrationInput {name: Scalars['String'],universalIdentifier?: (Scalars['String'] | null),oAuthRedirectUris?: (Scalars['String'][] | null),oAuthScopes?: (Scalars['String'][] | null)} export interface UpdateApplicationRegistrationInput {id: Scalars['String'],update: UpdateApplicationRegistrationPayload} @@ -6516,6 +6506,16 @@ export interface UpdateApplicationRegistrationVariableInput {id: Scalars['String export interface UpdateApplicationRegistrationVariablePayload {value?: (Scalars['String'] | null),resetValue?: (Scalars['Boolean'] | null),description?: (Scalars['String'] | null)} +export interface FileAttachmentInput {id: Scalars['UUID'],filename: Scalars['String']} + +export interface CreateSkillInput {id?: (Scalars['UUID'] | null),name: Scalars['String'],label: Scalars['String'],icon?: (Scalars['String'] | null),description?: (Scalars['String'] | null),content: Scalars['String']} + +export interface UpdateSkillInput {id: Scalars['UUID'],name?: (Scalars['String'] | null),label?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),description?: (Scalars['String'] | null),content?: (Scalars['String'] | null),isActive?: (Scalars['Boolean'] | null)} + +export interface GetAuthorizationUrlForSSOInput {identityProviderId: Scalars['UUID'],workspaceInviteHash?: (Scalars['String'] | null)} + +export interface SignUpInNewWorkspaceInput {displayName?: (Scalars['String'] | null),subdomain?: (Scalars['String'] | null)} + export interface UpdateWorkspaceMemberSettingsInput {workspaceMemberId: Scalars['UUID'],update: Scalars['JSON']} export interface ActivateWorkspaceInput { diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index 1890eacf29..6080ff530a 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -6418,6 +6418,63 @@ export default { ] } ], + "findApplicationRegistrationByClientId": [ + 201, + { + "clientId": [ + 1, + "String!" + ] + } + ], + "findApplicationRegistrationByUniversalIdentifier": [ + 7, + { + "universalIdentifier": [ + 1, + "String!" + ] + } + ], + "findManyApplicationRegistrations": [ + 7 + ], + "findOneApplicationRegistration": [ + 7, + { + "id": [ + 1, + "String!" + ] + } + ], + "findApplicationRegistrationStats": [ + 199, + { + "id": [ + 1, + "String!" + ] + } + ], + "findApplicationRegistrationVariables": [ + 174, + { + "applicationRegistrationId": [ + 1, + "String!" + ] + } + ], + "applicationRegistrationTarballUrl": [ + 1, + { + "id": [ + 1, + "String!" + ] + } + ], "minimalMetadata": [ 336 ], @@ -6546,63 +6603,6 @@ export default { ] } ], - "findApplicationRegistrationByClientId": [ - 201, - { - "clientId": [ - 1, - "String!" - ] - } - ], - "findApplicationRegistrationByUniversalIdentifier": [ - 7, - { - "universalIdentifier": [ - 1, - "String!" - ] - } - ], - "findManyApplicationRegistrations": [ - 7 - ], - "findOneApplicationRegistration": [ - 7, - { - "id": [ - 1, - "String!" - ] - } - ], - "findApplicationRegistrationStats": [ - 199, - { - "id": [ - 1, - "String!" - ] - } - ], - "findApplicationRegistrationVariables": [ - 174, - { - "applicationRegistrationId": [ - 1, - "String!" - ] - } - ], - "applicationRegistrationTarballUrl": [ - 1, - { - "id": [ - 1, - "String!" - ] - } - ], "currentUser": [ 79 ], @@ -8157,6 +8157,94 @@ export default { ] } ], + "createApplicationRegistration": [ + 200, + { + "input": [ + 465, + "CreateApplicationRegistrationInput!" + ] + } + ], + "updateApplicationRegistration": [ + 7, + { + "input": [ + 466, + "UpdateApplicationRegistrationInput!" + ] + } + ], + "deleteApplicationRegistration": [ + 6, + { + "id": [ + 1, + "String!" + ] + } + ], + "rotateApplicationRegistrationClientSecret": [ + 202, + { + "id": [ + 1, + "String!" + ] + } + ], + "createApplicationRegistrationVariable": [ + 5, + { + "input": [ + 468, + "CreateApplicationRegistrationVariableInput!" + ] + } + ], + "updateApplicationRegistrationVariable": [ + 5, + { + "input": [ + 469, + "UpdateApplicationRegistrationVariableInput!" + ] + } + ], + "deleteApplicationRegistrationVariable": [ + 6, + { + "id": [ + 1, + "String!" + ] + } + ], + "uploadAppTarball": [ + 7, + { + "file": [ + 358, + "Upload!" + ], + "universalIdentifier": [ + 1 + ] + } + ], + "transferApplicationRegistrationOwnership": [ + 7, + { + "applicationRegistrationId": [ + 1, + "String!" + ], + "targetWorkspaceSubdomain": [ + 1, + "String!" + ] + } + ], "createChatThread": [ 316 ], @@ -8182,7 +8270,7 @@ export default { 1 ], "fileAttachments": [ - 465, + 471, "[FileAttachmentInput!]" ] } @@ -8249,7 +8337,7 @@ export default { 314, { "input": [ - 466, + 472, "CreateSkillInput!" ] } @@ -8258,7 +8346,7 @@ export default { 314, { "input": [ - 467, + 473, "UpdateSkillInput!" ] } @@ -8316,7 +8404,7 @@ export default { 247, { "input": [ - 468, + 474, "GetAuthorizationUrlForSSOInput!" ] } @@ -8482,7 +8570,7 @@ export default { 250, { "input": [ - 469 + 475 ] } ], @@ -8587,94 +8675,6 @@ export default { ] } ], - "createApplicationRegistration": [ - 200, - { - "input": [ - 470, - "CreateApplicationRegistrationInput!" - ] - } - ], - "updateApplicationRegistration": [ - 7, - { - "input": [ - 471, - "UpdateApplicationRegistrationInput!" - ] - } - ], - "deleteApplicationRegistration": [ - 6, - { - "id": [ - 1, - "String!" - ] - } - ], - "rotateApplicationRegistrationClientSecret": [ - 202, - { - "id": [ - 1, - "String!" - ] - } - ], - "createApplicationRegistrationVariable": [ - 5, - { - "input": [ - 473, - "CreateApplicationRegistrationVariableInput!" - ] - } - ], - "updateApplicationRegistrationVariable": [ - 5, - { - "input": [ - 474, - "UpdateApplicationRegistrationVariableInput!" - ] - } - ], - "deleteApplicationRegistrationVariable": [ - 6, - { - "id": [ - 1, - "String!" - ] - } - ], - "uploadAppTarball": [ - 7, - { - "file": [ - 358, - "Upload!" - ], - "universalIdentifier": [ - 1 - ] - } - ], - "transferApplicationRegistrationOwnership": [ - 7, - { - "applicationRegistrationId": [ - 1, - "String!" - ], - "targetWorkspaceSubdomain": [ - 1, - "String!" - ] - } - ], "initiateOTPProvisioning": [ 241, { @@ -11229,6 +11229,96 @@ export default { 1 ] }, + "CreateApplicationRegistrationInput": { + "name": [ + 1 + ], + "universalIdentifier": [ + 1 + ], + "oAuthRedirectUris": [ + 1 + ], + "oAuthScopes": [ + 1 + ], + "__typename": [ + 1 + ] + }, + "UpdateApplicationRegistrationInput": { + "id": [ + 1 + ], + "update": [ + 467 + ], + "__typename": [ + 1 + ] + }, + "UpdateApplicationRegistrationPayload": { + "name": [ + 1 + ], + "oAuthRedirectUris": [ + 1 + ], + "oAuthScopes": [ + 1 + ], + "isListed": [ + 6 + ], + "__typename": [ + 1 + ] + }, + "CreateApplicationRegistrationVariableInput": { + "applicationRegistrationId": [ + 1 + ], + "key": [ + 1 + ], + "value": [ + 1 + ], + "description": [ + 1 + ], + "isSecret": [ + 6 + ], + "__typename": [ + 1 + ] + }, + "UpdateApplicationRegistrationVariableInput": { + "id": [ + 1 + ], + "update": [ + 470 + ], + "__typename": [ + 1 + ] + }, + "UpdateApplicationRegistrationVariablePayload": { + "value": [ + 1 + ], + "resetValue": [ + 6 + ], + "description": [ + 1 + ], + "__typename": [ + 1 + ] + }, "FileAttachmentInput": { "id": [ 3 @@ -11311,96 +11401,6 @@ export default { 1 ] }, - "CreateApplicationRegistrationInput": { - "name": [ - 1 - ], - "universalIdentifier": [ - 1 - ], - "oAuthRedirectUris": [ - 1 - ], - "oAuthScopes": [ - 1 - ], - "__typename": [ - 1 - ] - }, - "UpdateApplicationRegistrationInput": { - "id": [ - 1 - ], - "update": [ - 472 - ], - "__typename": [ - 1 - ] - }, - "UpdateApplicationRegistrationPayload": { - "name": [ - 1 - ], - "oAuthRedirectUris": [ - 1 - ], - "oAuthScopes": [ - 1 - ], - "isListed": [ - 6 - ], - "__typename": [ - 1 - ] - }, - "CreateApplicationRegistrationVariableInput": { - "applicationRegistrationId": [ - 1 - ], - "key": [ - 1 - ], - "value": [ - 1 - ], - "description": [ - 1 - ], - "isSecret": [ - 6 - ], - "__typename": [ - 1 - ] - }, - "UpdateApplicationRegistrationVariableInput": { - "id": [ - 1 - ], - "update": [ - 475 - ], - "__typename": [ - 1 - ] - }, - "UpdateApplicationRegistrationVariablePayload": { - "value": [ - 1 - ], - "resetValue": [ - 6 - ], - "description": [ - 1 - ], - "__typename": [ - 1 - ] - }, "UpdateWorkspaceMemberSettingsInput": { "workspaceMemberId": [ 3 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 a5de428fd9..31e4e1d22e 100644 --- a/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx +++ b/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx @@ -60,6 +60,7 @@ To invoke a route-triggered logic function from a (headless) front component, se - **cron**: Runs your function on a schedule using a CRON expression. - **databaseEvent**: Runs on workspace object lifecycle events. When the event operation is `updated`, specific fields to listen to can be specified in the `updatedFields` array. If left undefined or empty, any update will trigger the function. > e.g. `person.updated`, `*.created`, `company.*` +- **serverWebhook**: Receives inbound webhooks from a third-party service (Stripe, GitHub, Svix, …) at a single registration-scoped endpoint and resolves the target workspace from the payload. See [Server webhook trigger](#server-webhook-trigger). You can also manually execute a function using the CLI: @@ -172,6 +173,90 @@ For security reasons, response headers are restricted to an allow-list. Any head The status code must be a valid HTTP status code (between 100 and 599). Response header names are matched case-insensitively. +#### Server webhook trigger + +`httpRouteTriggerSettings` exposes a function under `/s/` and resolves the workspace from the request host — which works when each workspace has its own domain. Third-party providers, however, deliver every tenant's events to **one** webhook URL. For that case, use `serverWebhookTriggerSettings`: the function is reachable at a registration-scoped endpoint and the workspace is resolved from the payload. + +```ts src/logic-functions/handle-provider-webhook.logic-function.ts +import { defineLogicFunction } from 'twenty-sdk/define'; +import type { RoutePayload } from 'twenty-sdk/logic-function'; +import { Response } from 'twenty-sdk/logic-function'; + +const handler = async (event: RoutePayload) => { + // Verify the signature yourself before doing anything (see below). + // Return a non-2xx Response to make the provider retry. + return { received: true }; +}; + +export default defineLogicFunction({ + universalIdentifier: 'b3c2f0a1-7d4e-4c9a-9f2b-2e1d6a4c8e10', + name: 'handle-provider-webhook', + handler, + serverWebhookTriggerSettings: { + workspaceIdResolver: { source: 'body', path: 'metadata.twentyWorkspaceId' }, + forwardedRequestHeaders: ['webhook-id', 'webhook-timestamp', 'webhook-signature'], + }, +}); +``` + +The function is reachable at: + +``` +POST https://your-twenty-server.com/webhooks/server/:applicationRegistrationUniversalIdentifier/:logicFunctionUniversalIdentifier +``` + +Both identifiers are the `universalIdentifier`s from your manifest — the application registration's and this logic function's. Register that URL with the provider. + +**Workspace resolution.** Because one endpoint serves every workspace, your integration must put the target `workspaceId` somewhere in the delivery, and `workspaceIdResolver.{ source, path }` tells the platform where to read it: + +| Field | Values | Notes | +|-------|--------|-------| +| `source` | `body` \| `query` \| `header` | `body` reads the parsed JSON. `query` is the most universal — you usually control the callback URL you register, so append `?twentyWorkspaceId=…`. | +| `path` | dot-path, e.g. `metadata.twentyWorkspaceId` | Restricted to alphanumeric / `_` / `-` segments; prototype keys are rejected. | + +The resolved value must be a valid workspace UUID **and** your app must be installed in that workspace, otherwise the request is rejected before the function runs. + + +**Signature verification is your responsibility.** The platform does not verify webhook signatures for this trigger — it only resolves the workspace and runs your function. Your handler must verify the signature itself using `event.rawBody` and the headers you listed in `forwardedRequestHeaders`, comparing against a secret stored as a server/application variable. Always verify **before** any side effect, and use a constant-time comparison. + + +Most providers sign with HMAC-SHA256; the parts that differ are the header name, the digest encoding, and the signed-payload string. A few examples: + +| Provider | Headers to forward | Signed string | Digest | +|----------|--------------------|---------------|--------| +| Svix (Recall, Resend, Clerk) | `webhook-id`, `webhook-timestamp`, `webhook-signature` | `{id}.{timestamp}.{rawBody}` | base64 (secret is base64 after stripping `whsec_`) | +| Stripe | `stripe-signature` | `{timestamp}.{rawBody}` | hex | +| GitHub | `x-hub-signature-256` | `{rawBody}` | hex (prefixed `sha256=`) | +| Shopify | `x-shopify-hmac-sha256` | `{rawBody}` | base64 | +| Slack | `x-slack-signature`, `x-slack-request-timestamp` | `v0:{timestamp}:{rawBody}` | hex (prefixed `v0=`) | + +```ts +import { createHmac, timingSafeEqual } from 'crypto'; + +const handler = async (event: RoutePayload) => { + const signature = event.headers['x-hub-signature-256'] ?? ''; + const expected = + 'sha256=' + + createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET ?? '') + .update(event.rawBody ?? '') + .digest('hex'); + + const a = Buffer.from(signature); + const b = Buffer.from(expected); + + if (a.length !== b.length || !timingSafeEqual(a, b)) { + return new Response({ error: 'invalid signature' }, { status: 401 }); + } + + // ...handle the verified event + return { received: true }; +}; +``` + + +The function runs **synchronously** and your returned value becomes the HTTP response, so providers see your status code and can retry on non-2xx. Keep handlers fast — some providers (e.g. Slack) time out in a few seconds. Because the function runs before the signature is checked, protect this endpoint with rate limiting at your edge. + + #### 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-server/src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.module.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.module.ts index f23e2b384e..9475c3a504 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.module.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.module.ts @@ -7,6 +7,7 @@ import { LogicFunctionTriggerJob } from 'src/engine/core-modules/logic-function/ import { CronTriggerCronCommand } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/cron/cron-trigger.cron.command'; import { CronTriggerCronJob } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/cron/cron-trigger.cron.job'; import { CallDatabaseEventTriggerJobsJob } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/database-event/call-database-event-trigger-jobs.job'; +import { LogicFunctionTriggerService } from 'src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.service'; import { RouteTriggerService } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity'; @@ -24,8 +25,13 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache CronTriggerCronJob, CronTriggerCronCommand, CallDatabaseEventTriggerJobsJob, + LogicFunctionTriggerService, + RouteTriggerService, + ], + exports: [ + CronTriggerCronCommand, + LogicFunctionTriggerService, RouteTriggerService, ], - exports: [CronTriggerCronCommand, RouteTriggerService], }) export class LogicFunctionTriggerModule {} diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.service.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.service.ts new file mode 100644 index 0000000000..dc6b030d42 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.service.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@nestjs/common'; + +import { Request } from 'express'; +import { isDefined } from 'twenty-shared/utils'; + +import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service'; +import { buildLogicFunctionEvent } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/build-logic-function-event.util'; +import { + RouteTriggerResponse, + buildRouteTriggerResponse, +} from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util'; +import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity'; + +export type LogicFunctionTriggerOutcome = + | { kind: 'response'; response: RouteTriggerResponse } + | { kind: 'userError'; errorMessage: string }; + +@Injectable() +export class LogicFunctionTriggerService { + constructor( + private readonly logicFunctionExecutorService: LogicFunctionExecutorService, + ) {} + + async run({ + logicFunction, + request, + pathParameters, + forwardedRequestHeaders, + userId, + userWorkspaceId, + }: { + logicFunction: LogicFunctionEntity; + request: Request; + pathParameters: Record; + forwardedRequestHeaders: string[]; + userId?: string | null; + userWorkspaceId?: string | null; + }): Promise { + const event = buildLogicFunctionEvent({ + request, + pathParameters, + forwardedRequestHeaders, + userWorkspaceId: userWorkspaceId ?? null, + }); + + const result = await this.logicFunctionExecutorService.execute({ + logicFunctionId: logicFunction.id, + workspaceId: logicFunction.workspaceId, + payload: event, + ...(isDefined(userId) ? { userId } : {}), + ...(isDefined(userWorkspaceId) ? { userWorkspaceId } : {}), + }); + + if (!isDefined(result)) { + return { kind: 'response', response: buildRouteTriggerResponse(result) }; + } + + if (result.error) { + return { kind: 'userError', errorMessage: result.error.errorMessage }; + } + + return { + kind: 'response', + response: buildRouteTriggerResponse(result.data), + }; + } +} 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 index 582c1267b0..d1908d114a 100644 --- 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 @@ -1,6 +1,6 @@ import { LOGIC_FUNCTION_HTTP_RESPONSE_MARKER } from 'twenty-shared/types'; -import { buildRouteTriggerResponse } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service'; +import { buildRouteTriggerResponse } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util'; describe('buildRouteTriggerResponse', () => { it('wraps a plain body with status 200 and no headers', () => { 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 af7b5a8e45..4a4cc764d6 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, isLogicFunctionHttpResponse } from 'twenty-shared/types'; +import { HTTPMethod } from 'twenty-shared/types'; import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service'; import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; @@ -13,7 +13,7 @@ import { RouteTriggerException, RouteTriggerExceptionCode, } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger.exception'; -import { buildLogicFunctionEvent } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/build-logic-function-event.util'; +import { LogicFunctionTriggerService } from 'src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.service'; import { LogicFunctionException, LogicFunctionExceptionCode, @@ -22,37 +22,16 @@ import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/ import { LogicFunctionExecutionException, LogicFunctionExecutionExceptionCode, - LogicFunctionExecutorService, } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service'; import { CustomException } from 'src/utils/custom-exception'; -export type RouteTriggerResponse = { - statusCode: number; - headers: Record; - body: unknown; -}; - -export const buildRouteTriggerResponse = ( - data: unknown, -): RouteTriggerResponse => { - if (isLogicFunctionHttpResponse(data)) { - return { - statusCode: data.status ?? 200, - headers: data.headers ?? {}, - body: data.body, - }; - } - - return { statusCode: 200, headers: {}, body: data }; -}; - @Injectable() export class RouteTriggerService { private readonly logger = new Logger(RouteTriggerService.name); constructor( private readonly accessTokenService: AccessTokenService, - private readonly logicFunctionExecutorService: LogicFunctionExecutorService, + private readonly logicFunctionTriggerService: LogicFunctionTriggerService, private readonly workspaceDomainsService: WorkspaceDomainsService, @InjectRepository(LogicFunctionEntity) private readonly logicFunctionRepository: Repository, @@ -203,22 +182,17 @@ export class RouteTriggerService { userId = authContext.user?.id ?? null; } - const event = buildLogicFunctionEvent({ - request, - pathParameters: pathParams, - forwardedRequestHeaders: httpRouteSettings?.forwardedRequestHeaders ?? [], - userWorkspaceId, - }); - - let result; + let outcome; try { - result = await this.logicFunctionExecutorService.execute({ - logicFunctionId: logicFunction.id, - workspaceId: logicFunction.workspaceId, - payload: event, - ...(userId ? { userId } : {}), - ...(userWorkspaceId ? { userWorkspaceId } : {}), + outcome = await this.logicFunctionTriggerService.run({ + logicFunction, + request, + pathParameters: pathParams, + forwardedRequestHeaders: + httpRouteSettings?.forwardedRequestHeaders ?? [], + userId, + userWorkspaceId, }); } catch (error) { if (error instanceof RouteTriggerException) { @@ -244,17 +218,13 @@ export class RouteTriggerService { ); } - if (!isDefined(result)) { - return buildRouteTriggerResponse(result); - } - - if (result.error) { + if (outcome.kind === 'userError') { throw new RouteTriggerException( - result.error.errorMessage, + outcome.errorMessage, RouteTriggerExceptionCode.ROUTE_TRIGGER_USER_UNCAUGHT_ERROR, ); } - return buildRouteTriggerResponse(result.data); + return outcome.response; } } diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/build-logic-function-event.util.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/build-logic-function-event.util.ts index af641c3b81..7cd767632c 100644 --- a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/build-logic-function-event.util.ts +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/build-logic-function-event.util.ts @@ -2,6 +2,7 @@ import { type RawBodyRequest } from '@nestjs/common'; import { type Request } from 'express'; import { type LogicFunctionEvent } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; +import { isObject, isString } from '@sniptt/guards'; export const filterRequestHeaders = ({ requestHeaders, @@ -40,15 +41,15 @@ export const extractRawBody = (request: Request): string | undefined => { }; export const extractBody = (request: Request): object | null => { - if (request.body === undefined || request.body === null) { + if (!isDefined(request.body)) { return null; } - if (typeof request.body === 'object' && !Buffer.isBuffer(request.body)) { + if (isObject(request.body) && !Buffer.isBuffer(request.body)) { return request.body; } - if (typeof request.body === 'string') { + if (isString(request.body)) { try { return JSON.parse(request.body); } catch { diff --git a/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util.ts b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util.ts new file mode 100644 index 0000000000..7f6e725a5c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util.ts @@ -0,0 +1,70 @@ +import { type Response } from 'express'; +import { isLogicFunctionHttpResponse } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +export type RouteTriggerResponse = { + statusCode: number; + headers: Record; + body: unknown; +}; + +const ALLOWED_RESPONSE_HEADERS = new Set([ + 'content-type', + 'content-language', + 'content-disposition', + 'cache-control', + 'retry-after', +]); + +export const buildRouteTriggerResponse = ( + data: unknown, +): RouteTriggerResponse => { + if (isLogicFunctionHttpResponse(data)) { + return { + statusCode: data.status ?? 200, + headers: data.headers ?? {}, + body: data.body, + }; + } + + return { statusCode: 200, headers: {}, body: data }; +}; + +export const sendRouteTriggerResponse = ( + response: Response, + { statusCode, headers, body }: RouteTriggerResponse, +) => { + response.status(statusCode); + + for (const [key, value] of Object.entries(headers)) { + if (ALLOWED_RESPONSE_HEADERS.has(key.toLowerCase())) { + response.setHeader(key, value); + } + } + + if (!isDefined(body)) { + response.send(); + + return; + } + + const hasContentType = isDefined(response.getHeader('content-type')); + + if (typeof body === 'string') { + if (!hasContentType) { + response.setHeader('content-type', 'text/plain'); + } + + response.send(body); + + return; + } + + if (hasContentType) { + response.send(JSON.stringify(body)); + + return; + } + + response.json(body); +}; diff --git a/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/__tests__/server-webhook-trigger.service.spec.ts b/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/__tests__/server-webhook-trigger.service.spec.ts new file mode 100644 index 0000000000..7aadf845ba --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/__tests__/server-webhook-trigger.service.spec.ts @@ -0,0 +1,172 @@ +import { type Request } from 'express'; +import { type Repository } from 'typeorm'; + +import { ServerWebhookTriggerService } from 'src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.service'; +import { ServerWebhookTriggerExceptionCode } from 'src/engine/core-modules/server-webhook-trigger/exceptions/server-webhook-trigger.exception'; +import { type ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service'; +import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { type LogicFunctionTriggerService } from 'src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.service'; +import { type LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity'; + +const REGISTRATION_UID = 'reg-universal-id'; +const LOGIC_FUNCTION_UID = 'lf-universal-id'; +const WORKSPACE_ID = '123e4567-e89b-12d3-a456-426614174000'; + +const buildRequest = (body: object | null): Request => + ({ + method: 'POST', + path: `/webhooks/server/${REGISTRATION_UID}/${LOGIC_FUNCTION_UID}`, + query: {}, + headers: {}, + rawBody: Buffer.from(JSON.stringify(body ?? {}), 'utf-8'), + body, + }) as unknown as Request; + +type RegistrationResult = Awaited< + ReturnType +>; + +const asRegistration = (value: object): RegistrationResult => + value as unknown as RegistrationResult; + +const REGISTRATION_WITH_TRIGGER = asRegistration({ + id: 'reg-1', + manifest: { + logicFunctions: [ + { + universalIdentifier: LOGIC_FUNCTION_UID, + serverWebhookTriggerSettings: { + workspaceIdResolver: { + source: 'body', + path: 'metadata.twentyWorkspaceId', + }, + }, + }, + ], + }, +}); + +describe('ServerWebhookTriggerService', () => { + let service: ServerWebhookTriggerService; + let applicationRegistrationService: jest.Mocked< + Pick + >; + let logicFunctionTriggerService: jest.Mocked< + Pick + >; + let logicFunctionRepository: jest.Mocked< + Pick, 'findOne'> + >; + let applicationRepository: jest.Mocked< + Pick, 'findOne'> + >; + + const handle = ( + body: object | null = { metadata: { twentyWorkspaceId: WORKSPACE_ID } }, + ) => + service.handle({ + request: buildRequest(body), + applicationRegistrationUniversalIdentifier: REGISTRATION_UID, + logicFunctionUniversalIdentifier: LOGIC_FUNCTION_UID, + }); + + beforeEach(() => { + applicationRegistrationService = { + findOneByUniversalIdentifier: jest + .fn() + .mockResolvedValue(REGISTRATION_WITH_TRIGGER), + }; + logicFunctionTriggerService = { + run: jest.fn().mockResolvedValue({ + kind: 'response', + response: { statusCode: 200, headers: {}, body: { ok: true } }, + }), + }; + logicFunctionRepository = { + findOne: jest.fn().mockResolvedValue({ id: 'lf-1' }), + }; + applicationRepository = { + findOne: jest + .fn() + .mockResolvedValue({ id: 'app-1', workspaceId: WORKSPACE_ID }), + }; + + service = new ServerWebhookTriggerService( + applicationRegistrationService as unknown as ApplicationRegistrationService, + logicFunctionTriggerService as unknown as LogicFunctionTriggerService, + logicFunctionRepository as unknown as Repository, + applicationRepository as unknown as Repository, + ); + }); + + it('resolves the workspace and runs the function synchronously', async () => { + const result = await handle(); + + expect(applicationRepository.findOne).toHaveBeenCalledWith({ + where: { workspaceId: WORKSPACE_ID, applicationRegistrationId: 'reg-1' }, + }); + expect(logicFunctionTriggerService.run).toHaveBeenCalledWith( + expect.objectContaining({ logicFunction: { id: 'lf-1' } }), + ); + expect(result).toEqual({ + statusCode: 200, + headers: {}, + body: { ok: true }, + }); + }); + + it('throws when the application registration does not exist', async () => { + applicationRegistrationService.findOneByUniversalIdentifier.mockResolvedValue( + null, + ); + + await expect(handle()).rejects.toMatchObject({ + code: ServerWebhookTriggerExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND, + }); + }); + + it('throws when the logic function has no webhook trigger settings', async () => { + applicationRegistrationService.findOneByUniversalIdentifier.mockResolvedValue( + asRegistration({ id: 'reg-1', manifest: { logicFunctions: [] } }), + ); + + await expect(handle()).rejects.toMatchObject({ + code: ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_TRIGGER_NOT_CONFIGURED, + }); + }); + + it('throws when the resolved workspaceId is not a valid uuid', async () => { + await expect( + handle({ metadata: { twentyWorkspaceId: 'not-a-uuid' } }), + ).rejects.toMatchObject({ + code: ServerWebhookTriggerExceptionCode.WORKSPACE_ID_NOT_RESOLVED, + }); + }); + + it('throws when the app is not installed in the resolved workspace', async () => { + applicationRepository.findOne.mockResolvedValue(null); + + await expect(handle()).rejects.toMatchObject({ + code: ServerWebhookTriggerExceptionCode.APPLICATION_NOT_INSTALLED, + }); + }); + + it('throws when the function is not installed in the workspace', async () => { + logicFunctionRepository.findOne.mockResolvedValue(null); + + await expect(handle()).rejects.toMatchObject({ + code: ServerWebhookTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND, + }); + }); + + it('surfaces a user uncaught error from the function', async () => { + logicFunctionTriggerService.run.mockResolvedValue({ + kind: 'userError', + errorMessage: 'boom', + }); + + await expect(handle()).rejects.toMatchObject({ + code: ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_USER_UNCAUGHT_ERROR, + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/exceptions/server-webhook-trigger-rest-api-exception-filter.ts b/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/exceptions/server-webhook-trigger-rest-api-exception-filter.ts new file mode 100644 index 0000000000..1d2d3c57a3 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/exceptions/server-webhook-trigger-rest-api-exception-filter.ts @@ -0,0 +1,66 @@ +import { + type ArgumentsHost, + Catch, + type ExceptionFilter, +} from '@nestjs/common'; + +import type { Response } from 'express'; + +import { + ServerWebhookTriggerException, + ServerWebhookTriggerExceptionCode, +} from 'src/engine/core-modules/server-webhook-trigger/exceptions/server-webhook-trigger.exception'; +import type { CustomException } from 'src/utils/custom-exception'; +import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service'; + +@Catch(ServerWebhookTriggerException) +export class ServerWebhookTriggerRestApiExceptionFilter implements ExceptionFilter { + constructor( + private readonly httpExceptionHandlerService: HttpExceptionHandlerService, + ) {} + + catch(exception: ServerWebhookTriggerException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + + switch (exception.code) { + case ServerWebhookTriggerExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND: + case ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_TRIGGER_NOT_CONFIGURED: + case ServerWebhookTriggerExceptionCode.APPLICATION_NOT_INSTALLED: + case ServerWebhookTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND: + return this.httpExceptionHandlerService.handleError( + exception as CustomException, + response, + 404, + ); + case ServerWebhookTriggerExceptionCode.WORKSPACE_ID_NOT_RESOLVED: + return this.httpExceptionHandlerService.handleError( + exception as CustomException, + response, + 400, + ); + case ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_USER_UNCAUGHT_ERROR: + return this.httpExceptionHandlerService.handleError( + exception as CustomException, + response, + 500, + undefined, + undefined, + { shouldBeCapturedBySentry: false }, + ); + case ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_PLATFORM_ERROR: + return this.httpExceptionHandlerService.handleError( + exception as CustomException, + response, + 500, + ); + default: { + return this.httpExceptionHandlerService.handleError( + exception as CustomException, + response, + 400, + ); + } + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/exceptions/server-webhook-trigger.exception.ts b/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/exceptions/server-webhook-trigger.exception.ts new file mode 100644 index 0000000000..4c55e6d713 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/exceptions/server-webhook-trigger.exception.ts @@ -0,0 +1,52 @@ +import { type MessageDescriptor } from '@lingui/core'; +import { msg } from '@lingui/core/macro'; +import { assertUnreachable } from 'twenty-shared/utils'; + +import { CustomException } from 'src/utils/custom-exception'; + +export enum ServerWebhookTriggerExceptionCode { + APPLICATION_REGISTRATION_NOT_FOUND = 'APPLICATION_REGISTRATION_NOT_FOUND', + SERVER_WEBHOOK_TRIGGER_NOT_CONFIGURED = 'SERVER_WEBHOOK_TRIGGER_NOT_CONFIGURED', + WORKSPACE_ID_NOT_RESOLVED = 'WORKSPACE_ID_NOT_RESOLVED', + APPLICATION_NOT_INSTALLED = 'APPLICATION_NOT_INSTALLED', + LOGIC_FUNCTION_NOT_FOUND = 'LOGIC_FUNCTION_NOT_FOUND', + SERVER_WEBHOOK_USER_UNCAUGHT_ERROR = 'SERVER_WEBHOOK_USER_UNCAUGHT_ERROR', + SERVER_WEBHOOK_PLATFORM_ERROR = 'SERVER_WEBHOOK_PLATFORM_ERROR', +} + +const getServerWebhookTriggerExceptionUserFriendlyMessage = ( + code: ServerWebhookTriggerExceptionCode, +) => { + switch (code) { + case ServerWebhookTriggerExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND: + return msg`Application registration not found.`; + case ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_TRIGGER_NOT_CONFIGURED: + return msg`Server webhook trigger is not configured for this application registration.`; + case ServerWebhookTriggerExceptionCode.WORKSPACE_ID_NOT_RESOLVED: + return msg`Could not resolve a workspace from the webhook payload.`; + case ServerWebhookTriggerExceptionCode.APPLICATION_NOT_INSTALLED: + return msg`Application is not installed in this workspace.`; + case ServerWebhookTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND: + return msg`Logic function not found.`; + case ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_USER_UNCAUGHT_ERROR: + return msg`Logic function execution failed.`; + case ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_PLATFORM_ERROR: + return msg`An unexpected error occurred while handling the webhook.`; + default: + assertUnreachable(code); + } +}; + +export class ServerWebhookTriggerException extends CustomException { + constructor( + message: string, + code: ServerWebhookTriggerExceptionCode, + { userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {}, + ) { + super(message, code, { + userFriendlyMessage: + userFriendlyMessage ?? + getServerWebhookTriggerExceptionUserFriendlyMessage(code), + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.controller.ts b/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.controller.ts new file mode 100644 index 0000000000..a10e713527 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.controller.ts @@ -0,0 +1,47 @@ +import { + Controller, + Param, + Post, + Req, + Res, + UseFilters, + UseGuards, +} from '@nestjs/common'; + +import { Request, Response } from 'express'; + +import { ServerWebhookTriggerRestApiExceptionFilter } from 'src/engine/core-modules/server-webhook-trigger/exceptions/server-webhook-trigger-rest-api-exception-filter'; +import { ServerWebhookTriggerService } from 'src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.service'; +import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; +import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard'; +import { sendRouteTriggerResponse } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util'; + +@Controller('webhooks/server') +@UseGuards(PublicEndpointGuard, NoPermissionGuard) +@UseFilters(ServerWebhookTriggerRestApiExceptionFilter) +export class ServerWebhookTriggerController { + constructor( + private readonly serverWebhookTriggerService: ServerWebhookTriggerService, + ) {} + + @Post( + ':applicationRegistrationUniversalIdentifier/:logicFunctionUniversalIdentifier', + ) + async post( + @Param('applicationRegistrationUniversalIdentifier') + applicationRegistrationUniversalIdentifier: string, + @Param('logicFunctionUniversalIdentifier') + logicFunctionUniversalIdentifier: string, + @Req() request: Request, + @Res() response: Response, + ) { + sendRouteTriggerResponse( + response, + await this.serverWebhookTriggerService.handle({ + request, + applicationRegistrationUniversalIdentifier, + logicFunctionUniversalIdentifier, + }), + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.module.ts b/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.module.ts new file mode 100644 index 0000000000..b95700c68d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module'; +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { ServerWebhookTriggerController } from 'src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.controller'; +import { ServerWebhookTriggerService } from 'src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.service'; +import { LogicFunctionTriggerModule } from 'src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.module'; +import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([LogicFunctionEntity, ApplicationEntity]), + ApplicationRegistrationModule, + LogicFunctionTriggerModule, + ], + controllers: [ServerWebhookTriggerController], + providers: [ServerWebhookTriggerService], +}) +export class ServerWebhookTriggerModule {} diff --git a/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.service.ts b/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.service.ts new file mode 100644 index 0000000000..66703ff5bf --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.service.ts @@ -0,0 +1,184 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Request } from 'express'; +import { type ServerWebhookTriggerSettings } from 'twenty-shared/application'; +import { isDefined } from 'twenty-shared/utils'; +import { Repository } from 'typeorm'; +import { validate as uuidValidate } from 'uuid'; + +import { ApplicationRegistrationService } from 'src/engine/core-modules/application/application-registration/application-registration.service'; +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { + ServerWebhookTriggerException, + ServerWebhookTriggerExceptionCode, +} from 'src/engine/core-modules/server-webhook-trigger/exceptions/server-webhook-trigger.exception'; +import { resolveWorkspaceIdFromRequest } from 'src/engine/core-modules/server-webhook-trigger/utils/resolve-workspace-id-from-request.util'; +import { + LogicFunctionExecutionException, + LogicFunctionExecutionExceptionCode, +} from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service'; +import { LogicFunctionTriggerService } from 'src/engine/core-modules/logic-function/logic-function-trigger/logic-function-trigger.service'; +import { type RouteTriggerResponse } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util'; +import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity'; + +const WEBHOOK_WORKSPACE_ID_SOURCES = new Set(['body', 'query', 'header']); + +@Injectable() +export class ServerWebhookTriggerService { + private readonly logger = new Logger(ServerWebhookTriggerService.name); + + constructor( + private readonly applicationRegistrationService: ApplicationRegistrationService, + private readonly logicFunctionTriggerService: LogicFunctionTriggerService, + @InjectRepository(LogicFunctionEntity) + private readonly logicFunctionRepository: Repository, + @InjectRepository(ApplicationEntity) + private readonly applicationRepository: Repository, + ) {} + + private getServerWebhookTriggerSettingsOrThrow( + settings: ServerWebhookTriggerSettings | undefined, + ): ServerWebhookTriggerSettings { + const resolver = settings?.workspaceIdResolver; + + if ( + !isDefined(resolver) || + !WEBHOOK_WORKSPACE_ID_SOURCES.has(resolver.source) || + typeof resolver.path !== 'string' || + resolver.path.length === 0 + ) { + throw new ServerWebhookTriggerException( + 'Server webhook trigger is not configured for this logic function', + ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_TRIGGER_NOT_CONFIGURED, + ); + } + + return settings as ServerWebhookTriggerSettings; + } + + async handle({ + request, + applicationRegistrationUniversalIdentifier, + logicFunctionUniversalIdentifier, + }: { + request: Request; + applicationRegistrationUniversalIdentifier: string; + logicFunctionUniversalIdentifier: string; + }): Promise { + const applicationRegistration = + await this.applicationRegistrationService.findOneByUniversalIdentifier( + applicationRegistrationUniversalIdentifier, + ); + + if (!isDefined(applicationRegistration)) { + throw new ServerWebhookTriggerException( + `Application registration ${applicationRegistrationUniversalIdentifier} not found`, + ServerWebhookTriggerExceptionCode.APPLICATION_REGISTRATION_NOT_FOUND, + ); + } + + const logicFunctionManifest = + applicationRegistration.manifest?.logicFunctions?.find( + (candidate) => + candidate.universalIdentifier === logicFunctionUniversalIdentifier, + ); + + const serverWebhookTriggerSettings = + this.getServerWebhookTriggerSettingsOrThrow( + logicFunctionManifest?.serverWebhookTriggerSettings, + ); + + const workspaceId = resolveWorkspaceIdFromRequest({ + resolver: serverWebhookTriggerSettings.workspaceIdResolver, + request, + }); + + if (!isDefined(workspaceId) || !uuidValidate(workspaceId)) { + throw new ServerWebhookTriggerException( + 'Could not resolve a valid workspaceId from the webhook payload', + ServerWebhookTriggerExceptionCode.WORKSPACE_ID_NOT_RESOLVED, + ); + } + + const application = await this.applicationRepository.findOne({ + where: { + workspaceId, + applicationRegistrationId: applicationRegistration.id, + }, + }); + + if (!isDefined(application)) { + throw new ServerWebhookTriggerException( + `Application is not installed in workspace ${workspaceId} for this registration`, + ServerWebhookTriggerExceptionCode.APPLICATION_NOT_INSTALLED, + ); + } + + const logicFunction = await this.logicFunctionRepository.findOne({ + where: { + workspaceId, + applicationId: application.id, + universalIdentifier: logicFunctionUniversalIdentifier, + }, + }); + + if (!isDefined(logicFunction)) { + throw new ServerWebhookTriggerException( + `Logic function ${logicFunctionUniversalIdentifier} is not installed in workspace ${workspaceId}`, + ServerWebhookTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND, + ); + } + + let outcome; + + try { + outcome = await this.logicFunctionTriggerService.run({ + logicFunction, + request, + pathParameters: {}, + forwardedRequestHeaders: + serverWebhookTriggerSettings.forwardedRequestHeaders ?? [], + userId: null, + userWorkspaceId: null, + }); + } catch (error) { + if (error instanceof ServerWebhookTriggerException) { + throw error; + } + + this.logger.error( + `Unexpected error executing logic function ${logicFunction.id}: ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ); + + throw new ServerWebhookTriggerException( + `Logic function execution failed for ${logicFunction.id}`, + this.mapErrorToWebhookCode(error), + ); + } + + if (outcome.kind === 'userError') { + throw new ServerWebhookTriggerException( + outcome.errorMessage, + ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_USER_UNCAUGHT_ERROR, + ); + } + + return outcome.response; + } + + private mapErrorToWebhookCode( + error: unknown, + ): ServerWebhookTriggerExceptionCode { + if ( + error instanceof LogicFunctionExecutionException && + error.code === + LogicFunctionExecutionExceptionCode.LOGIC_FUNCTION_NOT_FOUND + ) { + return ServerWebhookTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND; + } + + return ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_PLATFORM_ERROR; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/utils/__tests__/resolve-workspace-id-from-request.util.spec.ts b/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/utils/__tests__/resolve-workspace-id-from-request.util.spec.ts new file mode 100644 index 0000000000..4fe16a2610 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/utils/__tests__/resolve-workspace-id-from-request.util.spec.ts @@ -0,0 +1,89 @@ +import { type Request } from 'express'; + +import { resolveWorkspaceIdFromRequest } from 'src/engine/core-modules/server-webhook-trigger/utils/resolve-workspace-id-from-request.util'; + +const buildRequest = (overrides: Partial): Request => + ({ query: {}, headers: {}, body: null, ...overrides }) as unknown as Request; + +describe('resolveWorkspaceIdFromRequest', () => { + it('resolves a nested value from the body', () => { + const request = buildRequest({ + body: { metadata: { twentyWorkspaceId: 'ws-1' } }, + }); + + expect( + resolveWorkspaceIdFromRequest({ + resolver: { source: 'body', path: 'metadata.twentyWorkspaceId' }, + request, + }), + ).toBe('ws-1'); + }); + + it('resolves from a query parameter', () => { + const request = buildRequest({ query: { twentyWorkspaceId: 'ws-2' } }); + + expect( + resolveWorkspaceIdFromRequest({ + resolver: { source: 'query', path: 'twentyWorkspaceId' }, + request, + }), + ).toBe('ws-2'); + }); + + it('resolves from a header (taking the first value of an array)', () => { + const request = buildRequest({ + headers: { 'x-workspace-id': ['ws-3', 'ws-other'] } as Request['headers'], + }); + + expect( + resolveWorkspaceIdFromRequest({ + resolver: { source: 'header', path: 'x-workspace-id' }, + request, + }), + ).toBe('ws-3'); + }); + + it('rejects prototype-pollution path segments', () => { + const request = buildRequest({ body: { metadata: { id: 'ws-4' } } }); + + expect( + resolveWorkspaceIdFromRequest({ + resolver: { source: 'body', path: '__proto__.id' }, + request, + }), + ).toBeUndefined(); + }); + + it('resolves kebab-case keys (e.g. header names)', () => { + const request = buildRequest({ body: { 'a-b': 'ws-5' } }); + + expect( + resolveWorkspaceIdFromRequest({ + resolver: { source: 'body', path: 'a-b' }, + request, + }), + ).toBe('ws-5'); + }); + + it('rejects path segments with unsafe characters', () => { + const request = buildRequest({ body: { 'a b': 'ws-6' } }); + + expect( + resolveWorkspaceIdFromRequest({ + resolver: { source: 'body', path: 'a b' }, + request, + }), + ).toBeUndefined(); + }); + + it('returns undefined when the value is absent', () => { + const request = buildRequest({ body: { metadata: {} } }); + + expect( + resolveWorkspaceIdFromRequest({ + resolver: { source: 'body', path: 'metadata.twentyWorkspaceId' }, + request, + }), + ).toBeUndefined(); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/utils/resolve-workspace-id-from-request.util.ts b/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/utils/resolve-workspace-id-from-request.util.ts new file mode 100644 index 0000000000..2c8ea0fadd --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/utils/resolve-workspace-id-from-request.util.ts @@ -0,0 +1,79 @@ +import { isArray, isObject, isString } from '@sniptt/guards'; +import { type Request } from 'express'; +import { type ServerWebhookTriggerSettings } from 'twenty-shared/application'; +import { isDefined } from 'twenty-shared/utils'; + +import { extractBody } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/build-logic-function-event.util'; + +const SAFE_PATH_SEGMENT = /^[A-Za-z0-9_-]+$/; +const FORBIDDEN_PATH_SEGMENTS = new Set([ + '__proto__', + 'prototype', + 'constructor', +]); + +const asRecord = (value: unknown): Record | undefined => + isObject(value) && !isArray(value) + ? (value as Record) + : undefined; + +const asNonEmptyString = (value: unknown): string | undefined => + isString(value) && value.length > 0 ? value : undefined; + +const asString = (value: unknown): string | undefined => { + if (isArray(value)) { + return asNonEmptyString(value[0]); + } + + return asNonEmptyString(value); +}; + +const getResolverRoot = ( + source: ServerWebhookTriggerSettings['workspaceIdResolver']['source'], + request: Request, +): Record | undefined => { + switch (source) { + case 'body': + return asRecord(extractBody(request)); + case 'query': + return asRecord(request.query); + case 'header': + return asRecord(request.headers); + default: + return undefined; + } +}; + +export const resolveWorkspaceIdFromRequest = ({ + resolver, + request, +}: { + resolver: ServerWebhookTriggerSettings['workspaceIdResolver']; + request: Request; +}): string | undefined => { + const segments = resolver.path.split('.'); + + if ( + segments.length === 0 || + segments.some( + (segment) => + !SAFE_PATH_SEGMENT.test(segment) || + FORBIDDEN_PATH_SEGMENTS.has(segment), + ) + ) { + return undefined; + } + + const root = getResolverRoot(resolver.source, request); + + if (!isDefined(root)) { + return undefined; + } + + const value = segments.reduce( + (current, key) => asRecord(current)?.[key], + root, + ); + + return asString(value); +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts b/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts index c2ec2f13c0..c4989e1ed3 100644 --- a/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts @@ -22,6 +22,7 @@ import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadat import { PermissionFlagModule } from 'src/engine/metadata-modules/permission-flag/permission-flag.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { MinimalMetadataModule } from 'src/engine/metadata-modules/minimal-metadata/minimal-metadata.module'; +import { ServerWebhookTriggerModule } from 'src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.module'; import { RoleModule } from 'src/engine/metadata-modules/role/role.module'; import { RouteTriggerModule } from 'src/engine/metadata-modules/route-trigger/route-trigger.module'; import { SearchFieldMetadataModule } from 'src/engine/metadata-modules/search-field-metadata/search-field-metadata.module'; @@ -54,6 +55,7 @@ import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/work PermissionsModule, PermissionFlagModule, RouteTriggerModule, + ServerWebhookTriggerModule, WebhookModule, ConnectedAccountMetadataModule, MessageChannelMetadataModule, 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 869fb6212a..446ea4fa87 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 @@ -13,23 +13,12 @@ import { import { Request, Response } from 'express'; import { HTTPMethod } from 'twenty-shared/types'; -import { isDefined } from 'twenty-shared/utils'; import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard'; import { RouteTriggerRestApiExceptionFilter } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger-rest-api-exception-filter'; -import { - RouteTriggerResponse, - RouteTriggerService, -} from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service'; - -const ALLOWED_RESPONSE_HEADERS = new Set([ - 'content-type', - 'content-language', - 'content-disposition', - 'cache-control', - 'retry-after', -]); +import { RouteTriggerService } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service'; +import { sendRouteTriggerResponse } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util'; @Controller('s') @UseGuards(PublicEndpointGuard, NoPermissionGuard) @@ -39,7 +28,7 @@ export class RouteTriggerController { @Get('*path') async get(@Req() request: Request, @Res() response: Response) { - this.sendResponse( + sendRouteTriggerResponse( response, await this.routeTriggerService.handle({ request, @@ -50,7 +39,7 @@ export class RouteTriggerController { @Post('*path') async post(@Req() request: Request, @Res() response: Response) { - this.sendResponse( + sendRouteTriggerResponse( response, await this.routeTriggerService.handle({ request, @@ -61,7 +50,7 @@ export class RouteTriggerController { @Put('*path') async put(@Req() request: Request, @Res() response: Response) { - this.sendResponse( + sendRouteTriggerResponse( response, await this.routeTriggerService.handle({ request, @@ -72,7 +61,7 @@ export class RouteTriggerController { @Patch('*path') async patch(@Req() request: Request, @Res() response: Response) { - this.sendResponse( + sendRouteTriggerResponse( response, await this.routeTriggerService.handle({ request, @@ -83,7 +72,7 @@ export class RouteTriggerController { @Delete('*path') async delete(@Req() request: Request, @Res() response: Response) { - this.sendResponse( + sendRouteTriggerResponse( response, await this.routeTriggerService.handle({ request, @@ -91,43 +80,4 @@ export class RouteTriggerController { }), ); } - - private sendResponse( - response: Response, - { statusCode, headers, body }: RouteTriggerResponse, - ) { - response.status(statusCode); - - for (const [key, value] of Object.entries(headers)) { - if (ALLOWED_RESPONSE_HEADERS.has(key.toLowerCase())) { - response.setHeader(key, value); - } - } - - if (!isDefined(body)) { - response.send(); - - return; - } - - const hasContentType = isDefined(response.getHeader('content-type')); - - if (typeof body === 'string') { - if (!hasContentType) { - response.setHeader('content-type', 'text/plain'); - } - - response.send(body); - - return; - } - - if (hasContentType) { - response.send(JSON.stringify(body)); - - return; - } - - response.json(body); - } } diff --git a/packages/twenty-shared/src/application/index.ts b/packages/twenty-shared/src/application/index.ts index b58faed2f2..5096398907 100644 --- a/packages/twenty-shared/src/application/index.ts +++ b/packages/twenty-shared/src/application/index.ts @@ -68,6 +68,10 @@ export type { } from './roleManifestType'; export type { RunAgentInput, RunAgentResult } from './runAgentType'; export type { ServerVariables } from './server-variables.type'; +export type { + WebhookWorkspaceIdSource, + ServerWebhookTriggerSettings, +} from './serverWebhookTriggerSettingsType'; export type { SkillManifest } from './skillManifestType'; export type { StoredOAuthConnectionProviderConfig } from './storedOAuthConnectionProviderConfigType'; export type { SyncableEntityOptions } from './syncableEntityOptionsType'; diff --git a/packages/twenty-shared/src/application/logicFunctionManifestType.ts b/packages/twenty-shared/src/application/logicFunctionManifestType.ts index 3350fe39b8..17b5163938 100644 --- a/packages/twenty-shared/src/application/logicFunctionManifestType.ts +++ b/packages/twenty-shared/src/application/logicFunctionManifestType.ts @@ -1,3 +1,4 @@ +import { type ServerWebhookTriggerSettings } from '@/application/serverWebhookTriggerSettingsType'; import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsType'; import { type ToolTriggerSettings } from '@/application/toolTriggerSettingsType'; import { type WorkflowActionTriggerSettings } from '@/application/workflowActionTriggerSettingsType'; @@ -10,6 +11,7 @@ export type LogicFunctionManifest = SyncableEntityOptions & { cronTriggerSettings?: CronTriggerSettings; databaseEventTriggerSettings?: DatabaseEventTriggerSettings; httpRouteTriggerSettings?: HttpRouteTriggerSettings; + serverWebhookTriggerSettings?: ServerWebhookTriggerSettings; toolTriggerSettings?: ToolTriggerSettings; workflowActionTriggerSettings?: WorkflowActionTriggerSettings; sourceHandlerPath: string; diff --git a/packages/twenty-shared/src/application/serverWebhookTriggerSettingsType.ts b/packages/twenty-shared/src/application/serverWebhookTriggerSettingsType.ts new file mode 100644 index 0000000000..521dbdb828 --- /dev/null +++ b/packages/twenty-shared/src/application/serverWebhookTriggerSettingsType.ts @@ -0,0 +1,9 @@ +export type WebhookWorkspaceIdSource = 'body' | 'query' | 'header'; + +export type ServerWebhookTriggerSettings = { + workspaceIdResolver: { + source: WebhookWorkspaceIdSource; + path: string; + }; + forwardedRequestHeaders?: string[]; +};