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