From b5a1aed24b4ca54ed5a487db810b2cc18bdec04d Mon Sep 17 00:00:00 2001 From: martmull Date: Wed, 24 Jun 2026 15:34:12 +0200 Subject: [PATCH] feat(server): run server-exposed logic functions in the owner workspace (#22002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Implements the server-level logic-function tier in the simplest shape: a logic function is "server-exposed" iff its manifest entry carries `serverWebhookTriggerSettings`. Execution delegates to the owner-workspace copy of that function — billing, throttling, env vars, and the existing executor all apply uniformly against that workspace. Supersedes #21971 with the simplified design from that discussion (no `applicationRegistrationLogicFunction` registry, no dedicated manifest type, no separate SDK helper, no special throttling). ## Design - **Manifest**: `LogicFunctionManifest` gains `serverWebhookTriggerSettings?`. The declarative `workspaceIdResolver` shape is dropped. - **Materialization**: those settings become two new jsonb columns on `LogicFunctionEntity`. The manifest → flat converter and the create-from-source DTO/util forward them; the property-config map and editable-properties list are extended. - **Lookup**: a single QB query joins `logicFunction → application → applicationRegistration` and filters on `lf.workspaceId = reg.workspaceId` to get only the owner workspace's copy. - **Webhook**: `POST /webhooks/server/:logicFunctionUniversalIdentifier` → `ServerWebhookTriggerService.handle` → join lookup → `LogicFunctionTriggerService.run`. No registry table, no `:applicationRegistrationUniversalIdentifier` segment, no resolver. - **Gate**: `IS_SERVER_LOGIC_FUNCTION_ENABLED` config var (disabled by default). ## Test plan - [x] `npx jest server-webhook-trigger` — 9 unit tests across the webhook service. - [x] `npx jest logic-function` — 88 existing tests stay green. - [x] `npx nx typecheck twenty-server`. - [x] `npx nx lint:diff-with-main twenty-server`. - [x] Reset DB → init → run `database:migrate:prod` → run `database:migrate:generate --name pending-migration-check` → no drift. - [ ] Manual: hit `/webhooks/server/` end-to-end against a manifest carrying `serverWebhookTriggerSettings`. https://claude.ai/code/session_01GgsnCGmYJ26xRirx8va1Yh --- _Generated by [Claude Code](https://claude.ai/code/session_01GgsnCGmYJ26xRirx8va1Yh)_ Review in cubic --- .../src/metadata/generated/schema.graphql | 101 ++-- .../src/metadata/generated/schema.ts | 86 ++-- .../src/metadata/generated/types.ts | 483 +++++++++--------- .../extend/apps/logic/logic-functions.mdx | 127 +++-- .../src/generated-metadata/graphql.ts | 1 + .../__tests__/define-logic-function.spec.ts | 42 ++ .../logic-functions/logic-function-config.ts | 40 +- ...rver-trigger-settings-to-logic-function.ts | 15 + .../instance-commands.constant.ts | 2 + ...t-to-universal-flat-logic-function.util.ts | 2 + .../server-route-trigger.service.spec.ts | 325 ++++++++++++ ...oute-trigger-rest-api-exception-filter.ts} | 47 +- .../server-route-trigger.exception.ts | 49 ++ .../server-route-trigger.controller.ts | 42 ++ .../server-route-trigger.module.ts | 17 + .../server-route-trigger.service.ts | 216 ++++++++ .../server-webhook-trigger.service.spec.ts | 172 ------- .../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 --- .../twenty-config/config-variables.ts | 8 + ...ompare-and-stringify.constant.spec.ts.snap | 2 + ...configuration-by-metadata-name.constant.ts | 5 + ...c-function-editable-properties.constant.ts | 1 + ...create-logic-function-from-source.input.ts | 6 + .../logic-function/logic-function.entity.ts | 4 + ...rsal-flat-logic-function-to-create.util.ts | 1 + ...rsal-flat-logic-function-to-create.util.ts | 2 + .../metadata-engine.module.ts | 4 +- .../twenty-shared/src/application/index.ts | 5 +- .../application/logicFunctionManifestType.ts | 4 +- .../serverRouteTriggerSettingsType.ts | 3 + .../serverWebhookTriggerSettingsType.ts | 9 - 36 files changed, 1231 insertions(+), 1061 deletions(-) create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-16/2-16-instance-command-fast-1782211913427-add-server-trigger-settings-to-logic-function.ts create mode 100644 packages/twenty-server/src/engine/core-modules/server-route-trigger/__tests__/server-route-trigger.service.spec.ts rename packages/twenty-server/src/engine/core-modules/{server-webhook-trigger/exceptions/server-webhook-trigger-rest-api-exception-filter.ts => server-route-trigger/exceptions/server-route-trigger-rest-api-exception-filter.ts} (52%) create mode 100644 packages/twenty-server/src/engine/core-modules/server-route-trigger/exceptions/server-route-trigger.exception.ts create mode 100644 packages/twenty-server/src/engine/core-modules/server-route-trigger/server-route-trigger.controller.ts create mode 100644 packages/twenty-server/src/engine/core-modules/server-route-trigger/server-route-trigger.module.ts create mode 100644 packages/twenty-server/src/engine/core-modules/server-route-trigger/server-route-trigger.service.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/server-webhook-trigger/__tests__/server-webhook-trigger.service.spec.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/server-webhook-trigger/exceptions/server-webhook-trigger.exception.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.controller.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.module.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.service.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/server-webhook-trigger/utils/__tests__/resolve-workspace-id-from-request.util.spec.ts delete 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/serverRouteTriggerSettingsType.ts delete 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 e541a2b0f6..6fd54f11c3 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -3114,13 +3114,6 @@ 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! @@ -3139,6 +3132,13 @@ 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! @@ -3381,15 +3381,6 @@ 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! @@ -3423,6 +3414,15 @@ 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! @@ -4023,6 +4023,7 @@ input CreateLogicFunctionFromSourceInput { cronTriggerSettings: JSON databaseEventTriggerSettings: JSON httpRouteTriggerSettings: JSON + serverRouteTriggerSettings: JSON toolTriggerSettings: JSON workflowActionTriggerSettings: JSON } @@ -4409,6 +4410,40 @@ 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 @@ -4447,40 +4482,6 @@ 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 ddb1a2332e..86e59d26c2 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -2710,13 +2710,6 @@ 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 @@ -2735,6 +2728,13 @@ 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 @@ -2898,15 +2898,6 @@ 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'] @@ -2940,6 +2931,15 @@ 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 @@ -5871,13 +5871,6 @@ 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']} }) @@ -5896,6 +5889,13 @@ 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)} }) @@ -6084,15 +6084,6 @@ 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']} } @@ -6126,6 +6117,15 @@ 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']} }) @@ -6370,7 +6370,7 @@ export interface DeleteOneIndexInput { /** The id of the custom index to delete. */ id: Scalars['UUID']} -export interface CreateLogicFunctionFromSourceInput {id?: (Scalars['UUID'] | null),universalIdentifier?: (Scalars['UUID'] | null),name: Scalars['String'],description?: (Scalars['String'] | null),timeoutSeconds?: (Scalars['Float'] | null),source?: (Scalars['JSON'] | null),cronTriggerSettings?: (Scalars['JSON'] | null),databaseEventTriggerSettings?: (Scalars['JSON'] | null),httpRouteTriggerSettings?: (Scalars['JSON'] | null),toolTriggerSettings?: (Scalars['JSON'] | null),workflowActionTriggerSettings?: (Scalars['JSON'] | null)} +export interface CreateLogicFunctionFromSourceInput {id?: (Scalars['UUID'] | null),universalIdentifier?: (Scalars['UUID'] | null),name: Scalars['String'],description?: (Scalars['String'] | null),timeoutSeconds?: (Scalars['Float'] | null),source?: (Scalars['JSON'] | null),cronTriggerSettings?: (Scalars['JSON'] | null),databaseEventTriggerSettings?: (Scalars['JSON'] | null),httpRouteTriggerSettings?: (Scalars['JSON'] | null),serverRouteTriggerSettings?: (Scalars['JSON'] | null),toolTriggerSettings?: (Scalars['JSON'] | null),workflowActionTriggerSettings?: (Scalars['JSON'] | null)} export interface ExecuteOneLogicFunctionInput { /** Id of the logic function to execute */ @@ -6498,6 +6498,16 @@ 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} @@ -6510,16 +6520,6 @@ 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 20201d4b12..5a904fc228 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -6424,63 +6424,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!" - ] - } - ], "minimalMetadata": [ 336 ], @@ -6609,6 +6552,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!" + ] + } + ], "currentUser": [ 79 ], @@ -8163,94 +8163,6 @@ 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 ], @@ -8276,7 +8188,7 @@ export default { 1 ], "fileAttachments": [ - 471, + 465, "[FileAttachmentInput!]" ] } @@ -8343,7 +8255,7 @@ export default { 314, { "input": [ - 472, + 466, "CreateSkillInput!" ] } @@ -8352,7 +8264,7 @@ export default { 314, { "input": [ - 473, + 467, "UpdateSkillInput!" ] } @@ -8410,7 +8322,7 @@ export default { 247, { "input": [ - 474, + 468, "GetAuthorizationUrlForSSOInput!" ] } @@ -8576,7 +8488,7 @@ export default { 250, { "input": [ - 475 + 469 ] } ], @@ -8681,6 +8593,94 @@ 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, { @@ -10335,6 +10335,9 @@ export default { "httpRouteTriggerSettings": [ 15 ], + "serverRouteTriggerSettings": [ + 15 + ], "toolTriggerSettings": [ 15 ], @@ -11235,96 +11238,6 @@ 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 @@ -11407,6 +11320,96 @@ 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 31e4e1d22e..2562f335bd 100644 --- a/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx +++ b/packages/twenty-docs/developers/extend/apps/logic/logic-functions.mdx @@ -60,7 +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). +- **serverRoute**: Exposes a single registration-scoped HTTP route. A **resolver** function (declared with `serverRouteTriggerSettings`) runs in the owner workspace and returns the target workspace AND the target logic function to dispatch to; the platform then runs that **target** function and returns its response. See [Server route trigger](#server-route-trigger). You can also manually execute a function using the CLI: @@ -173,54 +173,108 @@ 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 +#### Server route 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. +`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** URL. For that case, use `serverRouteTriggerSettings`. -```ts src/logic-functions/handle-provider-webhook.logic-function.ts +The trigger has two parts: + +1. A **resolver** logic function — declared with `serverRouteTriggerSettings` — runs in your **owner workspace** (the workspace that owns the application registration). It inspects the incoming request and returns `{ workspaceId, targetLogicFunctionUniversalIdentifier, payload? }`, picking *both* the target workspace and the target function. The resolver is the single point of authorization — the URL only carries the resolver's identifier. **This is the preferred place to verify request signatures**: the resolver runs before any side effect, has access to the original `rawBody` and forwarded headers, and can reject without ever touching the target. +2. A **target** logic function — a regular per-workspace logic function — then runs in the resolved workspace with the payload returned by the resolver (or the original request payload if the resolver didn't transform it). Its return value becomes the HTTP response. + +```ts src/logic-functions/resolve-server-route.logic-function.ts +import { createHmac, timingSafeEqual } from 'crypto'; import { defineLogicFunction } from 'twenty-sdk/define'; import type { RoutePayload } from 'twenty-sdk/logic-function'; -import { Response } from 'twenty-sdk/logic-function'; +// Runs in the owner workspace. Verifies the request signature, picks +// which target function should handle the event, and returns the +// workspace + target the platform should dispatch to. 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 }; + // Fail closed if the secret isn't configured — never fall back to an + // empty key, which would let any caller forge a matching signature. + const secret = process.env.GITHUB_WEBHOOK_SECRET; + + if (!secret) { + throw new Error('GITHUB_WEBHOOK_SECRET is not configured'); + } + + const signature = event.headers['x-hub-signature-256'] ?? ''; + const expected = + 'sha256=' + + createHmac('sha256', secret).update(event.rawBody ?? '').digest('hex'); + + const a = Buffer.from(signature); + const b = Buffer.from(expected); + + if (a.length !== b.length || !timingSafeEqual(a, b)) { + throw new Error('invalid signature'); + } + + const body = (event.body ?? {}) as { + metadata?: { twentyWorkspaceId?: string }; + type?: string; + }; + + return { + workspaceId: body.metadata?.twentyWorkspaceId ?? '', + // Route different event types to different target functions. + targetLogicFunctionUniversalIdentifier: + body.type === 'invoice.paid' + ? 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10' // handle-invoice-paid + : 'd5f3b0c2-8e5f-5d0b-a0c3-3f2e7b5d9f21', // handle-other-event + }; }; export default defineLogicFunction({ universalIdentifier: 'b3c2f0a1-7d4e-4c9a-9f2b-2e1d6a4c8e10', - name: 'handle-provider-webhook', + name: 'resolve-server-route', handler, - serverWebhookTriggerSettings: { - workspaceIdResolver: { source: 'body', path: 'metadata.twentyWorkspaceId' }, - forwardedRequestHeaders: ['webhook-id', 'webhook-timestamp', 'webhook-signature'], + serverRouteTriggerSettings: { + forwardedRequestHeaders: ['x-hub-signature-256'], }, }); ``` -The function is reachable at: +```ts src/logic-functions/handle-invoice-paid.logic-function.ts +import { defineLogicFunction } from 'twenty-sdk/define'; +import type { RoutePayload } from 'twenty-sdk/logic-function'; -``` -POST https://your-twenty-server.com/webhooks/server/:applicationRegistrationUniversalIdentifier/:logicFunctionUniversalIdentifier +// Runs in the resolved workspace. The resolver has already authenticated +// the request, so this handler can focus on the actual work. +const handler = async (event: RoutePayload) => { + // ...handle the verified event + return { received: true }; +}; + +export default defineLogicFunction({ + universalIdentifier: 'c4e2a9b1-7d4e-4c9a-9f2b-2e1d6a4c8e10', + name: 'handle-invoice-paid', + handler, +}); ``` -Both identifiers are the `universalIdentifier`s from your manifest — the application registration's and this logic function's. Register that URL with the provider. +The endpoint is reachable at: -**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: +``` +POST https://your-twenty-server.com/webhooks/server/:resolverLogicFunctionUniversalIdentifier +``` -| 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 identifier is the resolver's `universalIdentifier` from your manifest. Register that URL with the provider. -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. +**Resolver contract.** The SDK's `LogicFunctionConfig` type enforces this at compile time: as soon as you set `serverRouteTriggerSettings`, your handler is constrained to return `{ workspaceId: string; targetLogicFunctionUniversalIdentifier: string; payload?: object }` (or a `Promise` of it). The `workspaceId` must be a workspace where the target function is installed, otherwise the request is rejected with `404`. + +| Field | Type | Notes | +|-------|------|-------| +| `workspaceId` | `string` | Workspace UUID where the target will run. | +| `targetLogicFunctionUniversalIdentifier` | `string` | `universalIdentifier` of the logic function to invoke in that workspace. | +| `payload` | `object` (optional) | If set, replaces the request body sent to the target. | -**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. +**Signature verification is your responsibility — verify in the resolver.** The platform does not verify request signatures. The resolver is the recommended place to do it: it runs first, with access to `event.rawBody` and the headers you listed in `forwardedRequestHeaders`, and a thrown error (or any non-matching `workspaceId`) stops the dispatch before the target is invoked. If you instead push verification down into the target, the target must be careful not to lose `rawBody` and headers — i.e. the resolver must not return a `payload`. 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: +For request signatures, 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 | |----------|--------------------|---------------|--------| @@ -230,31 +284,10 @@ Most providers sign with HMAC-SHA256; the parts that differ are the header name, | 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 resolver example above already shows the GitHub HMAC-SHA256 flow — adapt the header name, digest encoding, and signed-payload string per the provider you're integrating. -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. +The target runs **synchronously** and its returned value becomes the HTTP response, so callers see your status code and can retry on non-2xx. Keep both handlers fast — some providers (e.g. Slack) time out in a few seconds. Because the resolver is reachable as a public endpoint, protect it with rate limiting at your edge. #### Database event trigger payload diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 26b247a135..ce4f905622 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1139,6 +1139,7 @@ export type CreateLogicFunctionFromSourceInput = { httpRouteTriggerSettings?: InputMaybe; id?: InputMaybe; name: Scalars['String']['input']; + serverRouteTriggerSettings?: InputMaybe; source?: InputMaybe; timeoutSeconds?: InputMaybe; toolTriggerSettings?: InputMaybe; diff --git a/packages/twenty-sdk/src/sdk/define/logic-functions/__tests__/define-logic-function.spec.ts b/packages/twenty-sdk/src/sdk/define/logic-functions/__tests__/define-logic-function.spec.ts index 441f5de1d0..73141e9e49 100644 --- a/packages/twenty-sdk/src/sdk/define/logic-functions/__tests__/define-logic-function.spec.ts +++ b/packages/twenty-sdk/src/sdk/define/logic-functions/__tests__/define-logic-function.spec.ts @@ -198,4 +198,46 @@ describe('defineLogicFunction', () => { 'Database event trigger must have an eventName', ); }); + + it('accepts a serverRouteTriggerSettings resolver returning { workspaceId, targetLogicFunctionUniversalIdentifier }', () => { + const result = defineLogicFunction({ + universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', + name: 'Resolve workspace from request', + serverRouteTriggerSettings: { forwardedRequestHeaders: ['x-tenant'] }, + handler: async () => ({ + workspaceId: 'ws-1', + targetLogicFunctionUniversalIdentifier: 'target-uid', + }), + }); + + expect(result.success).toBe(true); + expect(result.config.serverRouteTriggerSettings).toBeDefined(); + }); + + it('compile-time rejects a serverRouteTriggerSettings resolver returning the wrong shape', () => { + // @ts-expect-error — handler must return { workspaceId: string; + // targetLogicFunctionUniversalIdentifier: string } when + // `serverRouteTriggerSettings` is set. + const result = defineLogicFunction({ + universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', + name: 'Bad resolver', + serverRouteTriggerSettings: { forwardedRequestHeaders: [] }, + handler: async () => ({ notAWorkspaceId: 'oops' }), + }); + + expect(result.success).toBe(true); + }); + + it('compile-time rejects a serverRouteTriggerSettings resolver returning only workspaceId', () => { + // @ts-expect-error — handler must also return + // `targetLogicFunctionUniversalIdentifier`. + const result = defineLogicFunction({ + universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf', + name: 'Resolver missing target', + serverRouteTriggerSettings: { forwardedRequestHeaders: [] }, + handler: async () => ({ workspaceId: 'ws-1' }), + }); + + expect(result.success).toBe(true); + }); }); diff --git a/packages/twenty-sdk/src/sdk/define/logic-functions/logic-function-config.ts b/packages/twenty-sdk/src/sdk/define/logic-functions/logic-function-config.ts index cfb0ccaa59..e28b92495a 100644 --- a/packages/twenty-sdk/src/sdk/define/logic-functions/logic-function-config.ts +++ b/packages/twenty-sdk/src/sdk/define/logic-functions/logic-function-config.ts @@ -1,13 +1,43 @@ -import { type LogicFunctionManifest } from 'twenty-shared/application'; +import { + type LogicFunctionManifest, + type ServerRouteTriggerSettings, +} from 'twenty-shared/application'; export type LogicFunctionHandler = (...args: any[]) => any | Promise; -export type LogicFunctionConfig = Omit< +// A resolver function attached to `serverRouteTriggerSettings` runs in the +// owner workspace and must return BOTH the target workspace and the target +// logic function to dispatch to. The server contract is +// `{ workspaceId: string; targetLogicFunctionUniversalIdentifier: string; +// payload?: object }`. The resolver is the single point of authorization — +// the URL only carries the resolver's universalIdentifier. +export type ServerRouteResolverResult = { + workspaceId: string; + targetLogicFunctionUniversalIdentifier: string; + payload?: object; +}; + +export type ServerRouteResolverHandler = ( + ...args: any[] +) => ServerRouteResolverResult | Promise; + +type LogicFunctionConfigBase = Omit< LogicFunctionManifest, | 'sourceHandlerPath' | 'builtHandlerPath' | 'builtHandlerChecksum' | 'handlerName' -> & { - handler: LogicFunctionHandler; -}; + | 'serverRouteTriggerSettings' +>; + +export type LogicFunctionConfig = LogicFunctionConfigBase & + ( + | { + serverRouteTriggerSettings?: undefined; + handler: LogicFunctionHandler; + } + | { + serverRouteTriggerSettings: ServerRouteTriggerSettings; + handler: ServerRouteResolverHandler; + } + ); diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/2-16-instance-command-fast-1782211913427-add-server-trigger-settings-to-logic-function.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/2-16-instance-command-fast-1782211913427-add-server-trigger-settings-to-logic-function.ts new file mode 100644 index 0000000000..043b9e454c --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-16/2-16-instance-command-fast-1782211913427-add-server-trigger-settings-to-logic-function.ts @@ -0,0 +1,15 @@ +import { QueryRunner } from 'typeorm'; + +import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; +import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface'; + +@RegisteredInstanceCommand('2.16.0', 1782211913427) +export class AddServerTriggerSettingsToLogicFunctionFastInstanceCommand implements FastInstanceCommand { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE "core"."logicFunction" ADD "serverRouteTriggerSettings" jsonb'); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE "core"."logicFunction" DROP COLUMN "serverRouteTriggerSettings"'); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts index b9217f0835..c06416f73e 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts @@ -77,6 +77,7 @@ import { EncryptNonSecretApplicationVariableSlowInstanceCommand } from 'src/data import { MigrateAiModelPreferencesSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-instance-command-slow-1799000010000-migrate-ai-model-preferences'; import { AddFolderImportToMessageFolderPendingSyncActionFastInstanceCommand } from './2-15/2-15-instance-command-fast-1781714499016-add-folder-import-to-message-folder-pending-sync-action'; import { AddViewKanbanColumnWidthFastInstanceCommand } from './2-15/2-15-instance-command-fast-1781900000000-add-view-kanban-column-width'; +import { AddServerTriggerSettingsToLogicFunctionFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-16/2-16-instance-command-fast-1782211913427-add-server-trigger-settings-to-logic-function'; export const INSTANCE_COMMANDS = [ AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand, @@ -155,5 +156,6 @@ export const INSTANCE_COMMANDS = [ AddFolderImportToMessageFolderPendingSyncActionFastInstanceCommand, AddViewKanbanColumnWidthFastInstanceCommand, AddChannelWebhookSubscriptionFieldsFastInstanceCommand, + AddServerTriggerSettingsToLogicFunctionFastInstanceCommand, AddUniversalIdentifierAndApplicationIdToSearchFieldMetadataFastInstanceCommand, ]; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-logic-function-manifest-to-universal-flat-logic-function.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-logic-function-manifest-to-universal-flat-logic-function.util.ts index 1685e4a90b..9a0e36caf1 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-logic-function-manifest-to-universal-flat-logic-function.util.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-logic-function-manifest-to-universal-flat-logic-function.util.ts @@ -36,6 +36,8 @@ export const fromLogicFunctionManifestToUniversalFlatLogicFunction = ({ logicFunctionManifest.databaseEventTriggerSettings ?? null, httpRouteTriggerSettings: logicFunctionManifest.httpRouteTriggerSettings ?? null, + serverRouteTriggerSettings: + logicFunctionManifest.serverRouteTriggerSettings ?? null, toolTriggerSettings: logicFunctionManifest.toolTriggerSettings ?? null, workflowActionTriggerSettings: logicFunctionManifest.workflowActionTriggerSettings ?? null, diff --git a/packages/twenty-server/src/engine/core-modules/server-route-trigger/__tests__/server-route-trigger.service.spec.ts b/packages/twenty-server/src/engine/core-modules/server-route-trigger/__tests__/server-route-trigger.service.spec.ts new file mode 100644 index 0000000000..d8b5cb6f09 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/server-route-trigger/__tests__/server-route-trigger.service.spec.ts @@ -0,0 +1,325 @@ +import { type Request } from 'express'; +import { type Repository } from 'typeorm'; + +import { type LogicFunctionExecuteResult } from 'src/engine/core-modules/logic-function/logic-function-drivers/interfaces/logic-function-driver.interface'; +import { + LogicFunctionExecutionException, + LogicFunctionExecutionExceptionCode, + type LogicFunctionExecutorService, +} from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service'; +import { ServerRouteTriggerExceptionCode } from 'src/engine/core-modules/server-route-trigger/exceptions/server-route-trigger.exception'; +import { ServerRouteTriggerService } from 'src/engine/core-modules/server-route-trigger/server-route-trigger.service'; +import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { type LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity'; +import { LogicFunctionExecutionStatus } from 'src/engine/metadata-modules/logic-function/dtos/logic-function-execution-result.dto'; + +const RESOLVER_UID = 'resolver-uid'; +const TARGET_UID = 'target-uid'; + +const buildExecuteResult = ( + data: object | null, + error?: { errorMessage: string }, +): LogicFunctionExecuteResult => ({ + data, + duration: 1, + logs: '', + status: error + ? LogicFunctionExecutionStatus.ERROR + : LogicFunctionExecutionStatus.SUCCESS, + ...(error + ? { + error: { + errorType: 'Error', + errorMessage: error.errorMessage, + stackTrace: '', + }, + } + : {}), +}); + +const buildRequest = (body: object | null = {}): Request => + ({ + method: 'POST', + path: `/webhooks/server/${RESOLVER_UID}`, + query: {}, + headers: {}, + rawBody: Buffer.from(JSON.stringify(body ?? {}), 'utf-8'), + body, + }) as unknown as Request; + +describe('ServerRouteTriggerService', () => { + let service: ServerRouteTriggerService; + let logicFunctionRepository: jest.Mocked< + Pick, 'find' | 'findOne'> + >; + let logicFunctionExecutorService: jest.Mocked< + Pick + >; + let twentyConfigService: jest.Mocked>; + + const handle = () => + service.handle({ + request: buildRequest(), + resolverLogicFunctionUniversalIdentifier: RESOLVER_UID, + }); + + const buildResolverRow = (overrides: Record = {}) => ({ + id: 'resolver-id', + universalIdentifier: RESOLVER_UID, + workspaceId: 'owner-ws', + serverRouteTriggerSettings: { forwardedRequestHeaders: ['x-test'] }, + application: { + applicationRegistration: { ownerWorkspaceId: 'owner-ws' }, + }, + ...overrides, + }); + + beforeEach(() => { + logicFunctionRepository = { + find: jest.fn().mockResolvedValue([buildResolverRow()]), + findOne: jest + .fn() + // resolver lookup inside runFunction + .mockResolvedValueOnce({ id: 'resolver-id' }) + // target lookup inside runFunction + .mockResolvedValueOnce({ id: 'target-id' }), + }; + logicFunctionExecutorService = { + execute: jest + .fn() + // resolver returns { workspaceId, targetLogicFunctionUniversalIdentifier, payload } + .mockResolvedValueOnce( + buildExecuteResult({ + workspaceId: 'target-ws', + targetLogicFunctionUniversalIdentifier: TARGET_UID, + payload: { from: 'resolver' }, + }), + ) + // target returns the final response body + .mockResolvedValueOnce(buildExecuteResult({ ok: true })), + }; + twentyConfigService = { get: jest.fn().mockReturnValue(true) }; + + service = new ServerRouteTriggerService( + logicFunctionRepository as unknown as Repository, + logicFunctionExecutorService as unknown as LogicFunctionExecutorService, + twentyConfigService as unknown as TwentyConfigService, + ); + }); + + it('runs the resolver in the owner workspace then the resolver-named target in the resolved workspace', async () => { + const result = await handle(); + + expect(logicFunctionExecutorService.execute).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + logicFunctionId: 'resolver-id', + workspaceId: 'owner-ws', + }), + ); + expect(logicFunctionExecutorService.execute).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + logicFunctionId: 'target-id', + workspaceId: 'target-ws', + payload: { from: 'resolver' }, + }), + ); + expect(result).toEqual( + expect.objectContaining({ + statusCode: 200, + body: { ok: true }, + }), + ); + }); + + it('refuses when the feature is disabled', async () => { + twentyConfigService.get.mockReturnValue(false); + + await expect(handle()).rejects.toMatchObject({ + code: ServerRouteTriggerExceptionCode.FEATURE_DISABLED, + }); + }); + + it('throws LOGIC_FUNCTION_NOT_FOUND when no row matches the universalIdentifier', async () => { + logicFunctionRepository.find.mockResolvedValue([]); + + await expect(handle()).rejects.toMatchObject({ + code: ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND, + }); + }); + + it('throws LOGIC_FUNCTION_NOT_FOUND when only non-owner-workspace copies exist', async () => { + logicFunctionRepository.find.mockResolvedValue([ + buildResolverRow({ + workspaceId: 'other-ws', + application: { + applicationRegistration: { ownerWorkspaceId: 'owner-ws' }, + }, + }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any); + + await expect(handle()).rejects.toMatchObject({ + code: ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND, + }); + }); + + it('picks the owner-workspace copy when multiple workspaces installed the app', async () => { + logicFunctionRepository.find.mockResolvedValue([ + buildResolverRow({ + id: 'tenant-copy', + workspaceId: 'tenant-ws', + application: { + applicationRegistration: { ownerWorkspaceId: 'owner-ws' }, + }, + }), + buildResolverRow({ + id: 'owner-copy', + workspaceId: 'owner-ws', + application: { + applicationRegistration: { ownerWorkspaceId: 'owner-ws' }, + }, + }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any); + + await handle(); + + // runFunction's internal findOne is called with the + // (universalIdentifier, workspaceId) of the owner-workspace copy. + expect(logicFunctionRepository.findOne).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + where: expect.objectContaining({ + universalIdentifier: RESOLVER_UID, + workspaceId: 'owner-ws', + }), + }), + ); + }); + + it('throws RESOLVER_INVALID_RESULT when the resolver does not return a workspaceId', async () => { + logicFunctionExecutorService.execute.mockReset(); + logicFunctionExecutorService.execute.mockResolvedValueOnce( + buildExecuteResult({ + targetLogicFunctionUniversalIdentifier: TARGET_UID, + }), + ); + + await expect(handle()).rejects.toMatchObject({ + code: ServerRouteTriggerExceptionCode.RESOLVER_INVALID_RESULT, + }); + }); + + it('throws RESOLVER_INVALID_RESULT when the resolver does not return a targetLogicFunctionUniversalIdentifier', async () => { + logicFunctionExecutorService.execute.mockReset(); + logicFunctionExecutorService.execute.mockResolvedValueOnce( + buildExecuteResult({ workspaceId: 'target-ws' }), + ); + + await expect(handle()).rejects.toMatchObject({ + code: ServerRouteTriggerExceptionCode.RESOLVER_INVALID_RESULT, + }); + }); + + it('throws USER_UNCAUGHT_ERROR when the resolver returns an error', async () => { + logicFunctionExecutorService.execute.mockReset(); + logicFunctionExecutorService.execute.mockResolvedValueOnce( + buildExecuteResult(null, { errorMessage: 'boom' }), + ); + + await expect(handle()).rejects.toMatchObject({ + code: ServerRouteTriggerExceptionCode.SERVER_ROUTE_USER_UNCAUGHT_ERROR, + }); + }); + + it('throws LOGIC_FUNCTION_NOT_FOUND when the target named by the resolver is missing in the resolved workspace', async () => { + logicFunctionRepository.findOne.mockReset(); + logicFunctionRepository.findOne + // resolver lookup succeeds + .mockResolvedValueOnce({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + id: 'resolver-id', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any) + // target lookup returns null + .mockResolvedValueOnce(null); + + await expect(handle()).rejects.toMatchObject({ + code: ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND, + }); + }); + + it('surfaces a target userError as a server-route exception', async () => { + logicFunctionExecutorService.execute.mockReset(); + logicFunctionExecutorService.execute + .mockResolvedValueOnce( + buildExecuteResult({ + workspaceId: 'target-ws', + targetLogicFunctionUniversalIdentifier: TARGET_UID, + }), + ) + .mockResolvedValueOnce( + buildExecuteResult(null, { errorMessage: 'boom' }), + ); + + await expect(handle()).rejects.toMatchObject({ + code: ServerRouteTriggerExceptionCode.SERVER_ROUTE_USER_UNCAUGHT_ERROR, + }); + }); + + it('maps a LogicFunctionExecutionException(LOGIC_FUNCTION_NOT_FOUND) to the server-route not-found code', async () => { + logicFunctionExecutorService.execute.mockReset(); + logicFunctionExecutorService.execute.mockRejectedValue( + new LogicFunctionExecutionException( + 'not found', + LogicFunctionExecutionExceptionCode.LOGIC_FUNCTION_NOT_FOUND, + ), + ); + + await expect(handle()).rejects.toMatchObject({ + code: ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND, + }); + }); + + it('falls back to PLATFORM_ERROR for any other thrown executor error', async () => { + logicFunctionExecutorService.execute.mockReset(); + logicFunctionExecutorService.execute.mockRejectedValue(new Error('boom')); + + await expect(handle()).rejects.toMatchObject({ + code: ServerRouteTriggerExceptionCode.SERVER_ROUTE_PLATFORM_ERROR, + }); + }); + + it('looks up the resolver by universalIdentifier and loads the application registration chain', async () => { + await handle(); + + const findArgs = logicFunctionRepository.find.mock.calls[0][0]; + + expect(findArgs?.where).toEqual( + expect.objectContaining({ universalIdentifier: RESOLVER_UID }), + ); + expect(findArgs?.relations).toEqual( + expect.objectContaining({ + application: expect.objectContaining({ + applicationRegistration: true, + }), + }), + ); + }); + + it('maps a LogicFunctionExecutionException(RATE_LIMIT_EXCEEDED) to the server-route rate-limit code', async () => { + logicFunctionExecutorService.execute.mockReset(); + logicFunctionExecutorService.execute.mockRejectedValue( + new LogicFunctionExecutionException( + 'too many requests', + LogicFunctionExecutionExceptionCode.RATE_LIMIT_EXCEEDED, + ), + ); + + await expect(handle()).rejects.toMatchObject({ + code: ServerRouteTriggerExceptionCode.RATE_LIMIT_EXCEEDED, + }); + }); +}); 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-route-trigger/exceptions/server-route-trigger-rest-api-exception-filter.ts similarity index 52% rename from packages/twenty-server/src/engine/core-modules/server-webhook-trigger/exceptions/server-webhook-trigger-rest-api-exception-filter.ts rename to packages/twenty-server/src/engine/core-modules/server-route-trigger/exceptions/server-route-trigger-rest-api-exception-filter.ts index 1d2d3c57a3..794d7bc319 100644 --- 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-route-trigger/exceptions/server-route-trigger-rest-api-exception-filter.ts @@ -6,40 +6,46 @@ import { 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'; +import { + ServerRouteTriggerException, + ServerRouteTriggerExceptionCode, +} from 'src/engine/core-modules/server-route-trigger/exceptions/server-route-trigger.exception'; +import type { CustomException } from 'src/utils/custom-exception'; -@Catch(ServerWebhookTriggerException) -export class ServerWebhookTriggerRestApiExceptionFilter implements ExceptionFilter { +@Catch(ServerRouteTriggerException) +export class ServerRouteTriggerRestApiExceptionFilter implements ExceptionFilter { constructor( private readonly httpExceptionHandlerService: HttpExceptionHandlerService, ) {} - catch(exception: ServerWebhookTriggerException, host: ArgumentsHost) { + catch(exception: ServerRouteTriggerException, 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: + case ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND: return this.httpExceptionHandlerService.handleError( exception as CustomException, response, 404, ); - case ServerWebhookTriggerExceptionCode.WORKSPACE_ID_NOT_RESOLVED: + case ServerRouteTriggerExceptionCode.FEATURE_DISABLED: return this.httpExceptionHandlerService.handleError( exception as CustomException, response, - 400, + 503, ); - case ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_USER_UNCAUGHT_ERROR: + case ServerRouteTriggerExceptionCode.RATE_LIMIT_EXCEEDED: + return this.httpExceptionHandlerService.handleError( + exception as CustomException, + response, + 429, + undefined, + undefined, + { shouldBeCapturedBySentry: false }, + ); + case ServerRouteTriggerExceptionCode.SERVER_ROUTE_USER_UNCAUGHT_ERROR: return this.httpExceptionHandlerService.handleError( exception as CustomException, response, @@ -48,12 +54,21 @@ export class ServerWebhookTriggerRestApiExceptionFilter implements ExceptionFilt undefined, { shouldBeCapturedBySentry: false }, ); - case ServerWebhookTriggerExceptionCode.SERVER_WEBHOOK_PLATFORM_ERROR: + case ServerRouteTriggerExceptionCode.SERVER_ROUTE_PLATFORM_ERROR: return this.httpExceptionHandlerService.handleError( exception as CustomException, response, 500, ); + case ServerRouteTriggerExceptionCode.RESOLVER_INVALID_RESULT: + return this.httpExceptionHandlerService.handleError( + exception as CustomException, + response, + 502, + undefined, + undefined, + { shouldBeCapturedBySentry: false }, + ); default: { return this.httpExceptionHandlerService.handleError( exception as CustomException, diff --git a/packages/twenty-server/src/engine/core-modules/server-route-trigger/exceptions/server-route-trigger.exception.ts b/packages/twenty-server/src/engine/core-modules/server-route-trigger/exceptions/server-route-trigger.exception.ts new file mode 100644 index 0000000000..e821dd8cb8 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/server-route-trigger/exceptions/server-route-trigger.exception.ts @@ -0,0 +1,49 @@ +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 ServerRouteTriggerExceptionCode { + FEATURE_DISABLED = 'FEATURE_DISABLED', + LOGIC_FUNCTION_NOT_FOUND = 'LOGIC_FUNCTION_NOT_FOUND', + RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED', + SERVER_ROUTE_USER_UNCAUGHT_ERROR = 'SERVER_ROUTE_USER_UNCAUGHT_ERROR', + SERVER_ROUTE_PLATFORM_ERROR = 'SERVER_ROUTE_PLATFORM_ERROR', + RESOLVER_INVALID_RESULT = 'RESOLVER_INVALID_RESULT', +} + +const getServerRouteTriggerExceptionUserFriendlyMessage = ( + code: ServerRouteTriggerExceptionCode, +) => { + switch (code) { + case ServerRouteTriggerExceptionCode.FEATURE_DISABLED: + return msg`Server logic functions are disabled on this instance.`; + case ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND: + return msg`Server logic function not found.`; + case ServerRouteTriggerExceptionCode.RATE_LIMIT_EXCEEDED: + return msg`Rate limit exceeded.`; + case ServerRouteTriggerExceptionCode.SERVER_ROUTE_USER_UNCAUGHT_ERROR: + return msg`Logic function execution failed.`; + case ServerRouteTriggerExceptionCode.SERVER_ROUTE_PLATFORM_ERROR: + return msg`An unexpected error occurred while handling the server route.`; + case ServerRouteTriggerExceptionCode.RESOLVER_INVALID_RESULT: + return msg`Resolver logic function returned an invalid result.`; + default: + assertUnreachable(code); + } +}; + +export class ServerRouteTriggerException extends CustomException { + constructor( + message: string, + code: ServerRouteTriggerExceptionCode, + { userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {}, + ) { + super(message, code, { + userFriendlyMessage: + userFriendlyMessage ?? + getServerRouteTriggerExceptionUserFriendlyMessage(code), + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/server-route-trigger/server-route-trigger.controller.ts b/packages/twenty-server/src/engine/core-modules/server-route-trigger/server-route-trigger.controller.ts new file mode 100644 index 0000000000..3b62bc9f5c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/server-route-trigger/server-route-trigger.controller.ts @@ -0,0 +1,42 @@ +import { + Controller, + Param, + Post, + Req, + Res, + UseFilters, + UseGuards, +} from '@nestjs/common'; + +import { Request, Response } from 'express'; + +import { sendRouteTriggerResponse } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util'; +import { ServerRouteTriggerRestApiExceptionFilter } from 'src/engine/core-modules/server-route-trigger/exceptions/server-route-trigger-rest-api-exception-filter'; +import { ServerRouteTriggerService } from 'src/engine/core-modules/server-route-trigger/server-route-trigger.service'; +import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard'; +import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard'; + +@Controller('webhooks/server') +@UseGuards(PublicEndpointGuard, NoPermissionGuard) +@UseFilters(ServerRouteTriggerRestApiExceptionFilter) +export class ServerRouteTriggerController { + constructor( + private readonly serverRouteTriggerService: ServerRouteTriggerService, + ) {} + + @Post(':resolverLogicFunctionUniversalIdentifier') + async post( + @Param('resolverLogicFunctionUniversalIdentifier') + resolverLogicFunctionUniversalIdentifier: string, + @Req() request: Request, + @Res() response: Response, + ) { + sendRouteTriggerResponse( + response, + await this.serverRouteTriggerService.handle({ + request, + resolverLogicFunctionUniversalIdentifier, + }), + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/server-route-trigger/server-route-trigger.module.ts b/packages/twenty-server/src/engine/core-modules/server-route-trigger/server-route-trigger.module.ts new file mode 100644 index 0000000000..e0a045d53b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/server-route-trigger/server-route-trigger.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { LogicFunctionExecutorModule } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.module'; +import { ServerRouteTriggerController } from 'src/engine/core-modules/server-route-trigger/server-route-trigger.controller'; +import { ServerRouteTriggerService } from 'src/engine/core-modules/server-route-trigger/server-route-trigger.service'; +import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([LogicFunctionEntity]), + LogicFunctionExecutorModule, + ], + controllers: [ServerRouteTriggerController], + providers: [ServerRouteTriggerService], +}) +export class ServerRouteTriggerModule {} diff --git a/packages/twenty-server/src/engine/core-modules/server-route-trigger/server-route-trigger.service.ts b/packages/twenty-server/src/engine/core-modules/server-route-trigger/server-route-trigger.service.ts new file mode 100644 index 0000000000..60d8e23ee7 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/server-route-trigger/server-route-trigger.service.ts @@ -0,0 +1,216 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { isString } from '@sniptt/guards'; +import { Request } from 'express'; +import { isDefined } from 'twenty-shared/utils'; +import { Repository } from 'typeorm'; + +import { + LogicFunctionExecutionException, + LogicFunctionExecutionExceptionCode, + 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 { + type RouteTriggerResponse, + buildRouteTriggerResponse, +} from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/utils/route-trigger-response.util'; +import { + ServerRouteTriggerException, + ServerRouteTriggerExceptionCode, +} from 'src/engine/core-modules/server-route-trigger/exceptions/server-route-trigger.exception'; +import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity'; + +type ResolverResult = { + workspaceId: string; + targetLogicFunctionUniversalIdentifier: string; + payload?: object; +}; + +@Injectable() +export class ServerRouteTriggerService { + private readonly logger = new Logger(ServerRouteTriggerService.name); + + constructor( + @InjectRepository(LogicFunctionEntity) + private readonly logicFunctionRepository: Repository, + private readonly logicFunctionExecutorService: LogicFunctionExecutorService, + private readonly twentyConfigService: TwentyConfigService, + ) {} + + async handle({ + request, + resolverLogicFunctionUniversalIdentifier, + }: { + request: Request; + resolverLogicFunctionUniversalIdentifier: string; + }): Promise { + if (!this.twentyConfigService.get('IS_SERVER_LOGIC_FUNCTION_ENABLED')) { + throw new ServerRouteTriggerException( + 'Server logic functions are disabled on this instance', + ServerRouteTriggerExceptionCode.FEATURE_DISABLED, + ); + } + + const resolver = await this.findResolver({ + logicFunctionUniversalIdentifier: + resolverLogicFunctionUniversalIdentifier, + }); + + if (!isDefined(resolver)) { + throw new ServerRouteTriggerException( + `Server resolver function ${resolverLogicFunctionUniversalIdentifier} not found`, + ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND, + ); + } + + const event = buildLogicFunctionEvent({ + request, + pathParameters: {}, + forwardedRequestHeaders: + resolver.serverRouteTriggerSettings?.forwardedRequestHeaders ?? [], + userWorkspaceId: null, + }); + + const resolverResult = await this.runFunction({ + logicFunctionUniversalIdentifier: resolver.universalIdentifier, + workspaceId: resolver.workspaceId, + payload: event, + }); + const resolved = this.parseResolverResult(resolverResult); + + const targetResult = await this.runFunction({ + logicFunctionUniversalIdentifier: + resolved.targetLogicFunctionUniversalIdentifier, + workspaceId: resolved.workspaceId, + payload: resolved.payload ?? event, + }); + + if (isDefined(targetResult.error)) { + throw new ServerRouteTriggerException( + targetResult.error.errorMessage, + ServerRouteTriggerExceptionCode.SERVER_ROUTE_USER_UNCAUGHT_ERROR, + ); + } + + return buildRouteTriggerResponse(targetResult.data); + } + + private async findResolver({ + logicFunctionUniversalIdentifier, + }: { + logicFunctionUniversalIdentifier: string; + }): Promise { + const candidates = await this.logicFunctionRepository.find({ + where: { universalIdentifier: logicFunctionUniversalIdentifier }, + relations: { application: { applicationRegistration: true } }, + }); + + return ( + candidates.find( + (candidate) => + isDefined(candidate.application?.applicationRegistration) && + candidate.workspaceId === + candidate.application.applicationRegistration.ownerWorkspaceId, + ) ?? null + ); + } + + private parseResolverResult(result: { + data: object | null; + error?: { errorMessage: string }; + }): ResolverResult { + if (isDefined(result.error)) { + throw new ServerRouteTriggerException( + result.error.errorMessage, + ServerRouteTriggerExceptionCode.SERVER_ROUTE_USER_UNCAUGHT_ERROR, + ); + } + + const data = result.data as { + workspaceId?: unknown; + targetLogicFunctionUniversalIdentifier?: unknown; + payload?: unknown; + }; + + if ( + !isString(data?.workspaceId) || + !isString(data?.targetLogicFunctionUniversalIdentifier) + ) { + throw new ServerRouteTriggerException( + 'Resolver logic function must return { workspaceId: string; targetLogicFunctionUniversalIdentifier: string; payload?: object }', + ServerRouteTriggerExceptionCode.RESOLVER_INVALID_RESULT, + ); + } + + return { + workspaceId: data.workspaceId, + targetLogicFunctionUniversalIdentifier: + data.targetLogicFunctionUniversalIdentifier, + payload: + typeof data.payload === 'object' && data.payload !== null + ? (data.payload as object) + : undefined, + }; + } + + private async runFunction({ + logicFunctionUniversalIdentifier, + workspaceId, + payload, + }: { + logicFunctionUniversalIdentifier: string; + workspaceId: string; + payload: object; + }): Promise<{ data: object | null; error?: { errorMessage: string } }> { + const logicFunction = await this.logicFunctionRepository.findOne({ + where: { + universalIdentifier: logicFunctionUniversalIdentifier, + workspaceId, + }, + }); + + if (!isDefined(logicFunction)) { + throw new ServerRouteTriggerException( + `Logic function ${logicFunctionUniversalIdentifier} not found in workspace ${workspaceId}`, + ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND, + ); + } + + try { + return await this.logicFunctionExecutorService.execute({ + logicFunctionId: logicFunction.id, + workspaceId, + payload, + }); + } catch (error) { + this.logger.error( + `Server logic function ${logicFunction.id} failed in workspace ${workspaceId}: ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + ); + throw new ServerRouteTriggerException( + error instanceof Error ? error.message : String(error), + this.mapExecutorErrorToServerRouteCode(error), + ); + } + } + + private mapExecutorErrorToServerRouteCode( + error: unknown, + ): ServerRouteTriggerExceptionCode { + if (!(error instanceof LogicFunctionExecutionException)) { + return ServerRouteTriggerExceptionCode.SERVER_ROUTE_PLATFORM_ERROR; + } + + switch (error.code) { + case LogicFunctionExecutionExceptionCode.LOGIC_FUNCTION_NOT_FOUND: + return ServerRouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND; + case LogicFunctionExecutionExceptionCode.RATE_LIMIT_EXCEEDED: + return ServerRouteTriggerExceptionCode.RATE_LIMIT_EXCEEDED; + default: + return ServerRouteTriggerExceptionCode.SERVER_ROUTE_PLATFORM_ERROR; + } + } +} 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 deleted file mode 100644 index 7aadf845ba..0000000000 --- a/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/__tests__/server-webhook-trigger.service.spec.ts +++ /dev/null @@ -1,172 +0,0 @@ -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.exception.ts b/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/exceptions/server-webhook-trigger.exception.ts deleted file mode 100644 index 4c55e6d713..0000000000 --- a/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/exceptions/server-webhook-trigger.exception.ts +++ /dev/null @@ -1,52 +0,0 @@ -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 deleted file mode 100644 index a10e713527..0000000000 --- a/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.controller.ts +++ /dev/null @@ -1,47 +0,0 @@ -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 deleted file mode 100644 index b95700c68d..0000000000 --- a/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.module.ts +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 66703ff5bf..0000000000 --- a/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/server-webhook-trigger.service.ts +++ /dev/null @@ -1,184 +0,0 @@ -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 deleted file mode 100644 index 4fe16a2610..0000000000 --- a/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/utils/__tests__/resolve-workspace-id-from-request.util.spec.ts +++ /dev/null @@ -1,89 +0,0 @@ -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 deleted file mode 100644 index 2c8ea0fadd..0000000000 --- a/packages/twenty-server/src/engine/core-modules/server-webhook-trigger/utils/resolve-workspace-id-from-request.util.ts +++ /dev/null @@ -1,79 +0,0 @@ -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/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index 8f8c92a8cb..5daecb10ff 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -694,6 +694,14 @@ export class ConfigVariables { @IsAWSRegion() LOGIC_FUNCTION_LAMBDA_LAYER_BUCKET_REGION?: AwsRegion; + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.LOGIC_FUNCTION_CONFIG, + description: 'Enable instance-level (server) logic functions', + type: ConfigVariableType.BOOLEAN, + }) + @IsOptional() + IS_SERVER_LOGIC_FUNCTION_ENABLED = false; + @ConfigVariablesMetadata({ group: ConfigVariablesGroup.CODE_INTERPRETER_CONFIG, description: diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec.ts.snap b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec.ts.snap index 045481a14c..dd16e025d4 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec.ts.snap +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec.ts.snap @@ -132,6 +132,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma "cronTriggerSettings", "databaseEventTriggerSettings", "httpRouteTriggerSettings", + "serverRouteTriggerSettings", "toolTriggerSettings", "workflowActionTriggerSettings", "builtHandlerPath", @@ -140,6 +141,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma "cronTriggerSettings", "databaseEventTriggerSettings", "httpRouteTriggerSettings", + "serverRouteTriggerSettings", "toolTriggerSettings", "workflowActionTriggerSettings", ], diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts index 0c270cf2ec..b2bafcbe6e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts @@ -675,6 +675,11 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = { toStringify: true, universalProperty: undefined, }, + serverRouteTriggerSettings: { + toCompare: true, + toStringify: true, + universalProperty: undefined, + }, toolTriggerSettings: { toCompare: true, toStringify: true, diff --git a/packages/twenty-server/src/engine/metadata-modules/logic-function/constants/flat-logic-function-editable-properties.constant.ts b/packages/twenty-server/src/engine/metadata-modules/logic-function/constants/flat-logic-function-editable-properties.constant.ts index 5282b71ffe..f277734243 100644 --- a/packages/twenty-server/src/engine/metadata-modules/logic-function/constants/flat-logic-function-editable-properties.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/logic-function/constants/flat-logic-function-editable-properties.constant.ts @@ -10,6 +10,7 @@ export const FLAT_LOGIC_FUNCTION_EDITABLE_PROPERTIES = [ 'cronTriggerSettings', 'databaseEventTriggerSettings', 'httpRouteTriggerSettings', + 'serverRouteTriggerSettings', 'toolTriggerSettings', 'workflowActionTriggerSettings', 'isBuildUpToDate', diff --git a/packages/twenty-server/src/engine/metadata-modules/logic-function/dtos/create-logic-function-from-source.input.ts b/packages/twenty-server/src/engine/metadata-modules/logic-function/dtos/create-logic-function-from-source.input.ts index f44e219f9c..b4cf5ffdea 100644 --- a/packages/twenty-server/src/engine/metadata-modules/logic-function/dtos/create-logic-function-from-source.input.ts +++ b/packages/twenty-server/src/engine/metadata-modules/logic-function/dtos/create-logic-function-from-source.input.ts @@ -15,6 +15,7 @@ import { CronTriggerSettings, DatabaseEventTriggerSettings, HttpRouteTriggerSettings, + ServerRouteTriggerSettings, ToolTriggerSettings, WorkflowActionTriggerSettings, } from 'twenty-shared/application'; @@ -72,6 +73,11 @@ export class CreateLogicFunctionFromSourceInput { @IsOptional() httpRouteTriggerSettings?: JsonbProperty; + @IsObject() + @Field(() => graphqlTypeJson, { nullable: true }) + @IsOptional() + serverRouteTriggerSettings?: JsonbProperty; + @IsObject() @Field(() => graphqlTypeJson, { nullable: true }) @IsOptional() diff --git a/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.entity.ts b/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.entity.ts index 26e25ea5bd..ad55d07213 100644 --- a/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/logic-function/logic-function.entity.ts @@ -12,6 +12,7 @@ import { CronTriggerSettings, DatabaseEventTriggerSettings, HttpRouteTriggerSettings, + ServerRouteTriggerSettings, ToolTriggerSettings, WorkflowActionTriggerSettings, } from 'twenty-shared/application'; @@ -85,6 +86,9 @@ export class LogicFunctionEntity @Column({ nullable: true, type: 'jsonb' }) httpRouteTriggerSettings: JsonbProperty | null; + @Column({ nullable: true, type: 'jsonb' }) + serverRouteTriggerSettings: JsonbProperty | null; + @Column({ nullable: true, type: 'jsonb' }) toolTriggerSettings: JsonbProperty | null; diff --git a/packages/twenty-server/src/engine/metadata-modules/logic-function/utils/build-universal-flat-logic-function-to-create.util.ts b/packages/twenty-server/src/engine/metadata-modules/logic-function/utils/build-universal-flat-logic-function-to-create.util.ts index 571c7fd2f5..8e35dcc04e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/logic-function/utils/build-universal-flat-logic-function-to-create.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/logic-function/utils/build-universal-flat-logic-function-to-create.util.ts @@ -50,6 +50,7 @@ export const buildUniversalFlatLogicFunctionToCreate = ( cronTriggerSettings: input.cronTriggerSettings ?? null, databaseEventTriggerSettings: input.databaseEventTriggerSettings ?? null, httpRouteTriggerSettings: input.httpRouteTriggerSettings ?? null, + serverRouteTriggerSettings: input.serverRouteTriggerSettings ?? null, toolTriggerSettings: input.toolTriggerSettings ?? null, workflowActionTriggerSettings: input.workflowActionTriggerSettings ?? null, createdAt: now, diff --git a/packages/twenty-server/src/engine/metadata-modules/logic-function/utils/from-create-logic-function-from-source-input-to-universal-flat-logic-function-to-create.util.ts b/packages/twenty-server/src/engine/metadata-modules/logic-function/utils/from-create-logic-function-from-source-input-to-universal-flat-logic-function-to-create.util.ts index d0d6b1a6aa..41d97eba71 100644 --- a/packages/twenty-server/src/engine/metadata-modules/logic-function/utils/from-create-logic-function-from-source-input-to-universal-flat-logic-function-to-create.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/logic-function/utils/from-create-logic-function-from-source-input-to-universal-flat-logic-function-to-create.util.ts @@ -56,6 +56,8 @@ export const fromCreateLogicFunctionFromSourceInputToUniversalFlatLogicFunctionT createLogicFunctionFromSourceInput.databaseEventTriggerSettings ?? null, httpRouteTriggerSettings: createLogicFunctionFromSourceInput.httpRouteTriggerSettings ?? null, + serverRouteTriggerSettings: + createLogicFunctionFromSourceInput.serverRouteTriggerSettings ?? null, toolTriggerSettings: createLogicFunctionFromSourceInput.toolTriggerSettings ?? null, workflowActionTriggerSettings: 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 c4989e1ed3..71b8155ccb 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,7 +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 { ServerRouteTriggerModule } from 'src/engine/core-modules/server-route-trigger/server-route-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'; @@ -55,7 +55,7 @@ import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/work PermissionsModule, PermissionFlagModule, RouteTriggerModule, - ServerWebhookTriggerModule, + ServerRouteTriggerModule, WebhookModule, ConnectedAccountMetadataModule, MessageChannelMetadataModule, diff --git a/packages/twenty-shared/src/application/index.ts b/packages/twenty-shared/src/application/index.ts index ffbf169438..c6c723a475 100644 --- a/packages/twenty-shared/src/application/index.ts +++ b/packages/twenty-shared/src/application/index.ts @@ -107,10 +107,7 @@ 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 { ServerRouteTriggerSettings } from './serverRouteTriggerSettingsType'; 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 17b5163938..679c0eb14f 100644 --- a/packages/twenty-shared/src/application/logicFunctionManifestType.ts +++ b/packages/twenty-shared/src/application/logicFunctionManifestType.ts @@ -1,4 +1,4 @@ -import { type ServerWebhookTriggerSettings } from '@/application/serverWebhookTriggerSettingsType'; +import { type ServerRouteTriggerSettings } from '@/application/serverRouteTriggerSettingsType'; import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsType'; import { type ToolTriggerSettings } from '@/application/toolTriggerSettingsType'; import { type WorkflowActionTriggerSettings } from '@/application/workflowActionTriggerSettingsType'; @@ -11,7 +11,7 @@ export type LogicFunctionManifest = SyncableEntityOptions & { cronTriggerSettings?: CronTriggerSettings; databaseEventTriggerSettings?: DatabaseEventTriggerSettings; httpRouteTriggerSettings?: HttpRouteTriggerSettings; - serverWebhookTriggerSettings?: ServerWebhookTriggerSettings; + serverRouteTriggerSettings?: ServerRouteTriggerSettings; toolTriggerSettings?: ToolTriggerSettings; workflowActionTriggerSettings?: WorkflowActionTriggerSettings; sourceHandlerPath: string; diff --git a/packages/twenty-shared/src/application/serverRouteTriggerSettingsType.ts b/packages/twenty-shared/src/application/serverRouteTriggerSettingsType.ts new file mode 100644 index 0000000000..7a4f4911c4 --- /dev/null +++ b/packages/twenty-shared/src/application/serverRouteTriggerSettingsType.ts @@ -0,0 +1,3 @@ +export type ServerRouteTriggerSettings = { + forwardedRequestHeaders?: string[]; +}; diff --git a/packages/twenty-shared/src/application/serverWebhookTriggerSettingsType.ts b/packages/twenty-shared/src/application/serverWebhookTriggerSettingsType.ts deleted file mode 100644 index 521dbdb828..0000000000 --- a/packages/twenty-shared/src/application/serverWebhookTriggerSettingsType.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type WebhookWorkspaceIdSource = 'body' | 'query' | 'header'; - -export type ServerWebhookTriggerSettings = { - workspaceIdResolver: { - source: WebhookWorkspaceIdSource; - path: string; - }; - forwardedRequestHeaders?: string[]; -};