feat(server): run server-exposed logic functions in the owner workspace (#22002)

## 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/<uid>` end-to-end against a manifest
carrying `serverWebhookTriggerSettings`.

https://claude.ai/code/session_01GgsnCGmYJ26xRirx8va1Yh

---
_Generated by [Claude
Code](https://claude.ai/code/session_01GgsnCGmYJ26xRirx8va1Yh)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22002?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
martmull
2026-06-24 15:34:12 +02:00
committed by GitHub
parent 20ac0a52bf
commit b5a1aed24b
36 changed files with 1231 additions and 1061 deletions
@@ -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!
@@ -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 {
@@ -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
@@ -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).
<Note>
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.
</Note>
#### 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. |
<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.
**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.
</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:
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.
<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.
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.
</Note>
#### Database event trigger payload
@@ -1139,6 +1139,7 @@ export type CreateLogicFunctionFromSourceInput = {
httpRouteTriggerSettings?: InputMaybe<Scalars['JSON']['input']>;
id?: InputMaybe<Scalars['UUID']['input']>;
name: Scalars['String']['input'];
serverRouteTriggerSettings?: InputMaybe<Scalars['JSON']['input']>;
source?: InputMaybe<Scalars['JSON']['input']>;
timeoutSeconds?: InputMaybe<Scalars['Float']['input']>;
toolTriggerSettings?: InputMaybe<Scalars['JSON']['input']>;
@@ -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);
});
});
@@ -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<any>;
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<ServerRouteResolverResult>;
type LogicFunctionConfigBase = Omit<
LogicFunctionManifest,
| 'sourceHandlerPath'
| 'builtHandlerPath'
| 'builtHandlerChecksum'
| 'handlerName'
> & {
handler: LogicFunctionHandler;
};
| 'serverRouteTriggerSettings'
>;
export type LogicFunctionConfig = LogicFunctionConfigBase &
(
| {
serverRouteTriggerSettings?: undefined;
handler: LogicFunctionHandler;
}
| {
serverRouteTriggerSettings: ServerRouteTriggerSettings;
handler: ServerRouteResolverHandler;
}
);
@@ -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<void> {
await queryRunner.query('ALTER TABLE "core"."logicFunction" ADD "serverRouteTriggerSettings" jsonb');
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE "core"."logicFunction" DROP COLUMN "serverRouteTriggerSettings"');
}
}
@@ -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,
];
@@ -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,
@@ -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<Repository<LogicFunctionEntity>, 'find' | 'findOne'>
>;
let logicFunctionExecutorService: jest.Mocked<
Pick<LogicFunctionExecutorService, 'execute'>
>;
let twentyConfigService: jest.Mocked<Pick<TwentyConfigService, 'get'>>;
const handle = () =>
service.handle({
request: buildRequest(),
resolverLogicFunctionUniversalIdentifier: RESOLVER_UID,
});
const buildResolverRow = (overrides: Record<string, unknown> = {}) => ({
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<LogicFunctionEntity>,
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,
});
});
});
@@ -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<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:
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,
@@ -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<ServerRouteTriggerExceptionCode> {
constructor(
message: string,
code: ServerRouteTriggerExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ??
getServerRouteTriggerExceptionUserFriendlyMessage(code),
});
}
}
@@ -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,
}),
);
}
}
@@ -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 {}
@@ -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<LogicFunctionEntity>,
private readonly logicFunctionExecutorService: LogicFunctionExecutorService,
private readonly twentyConfigService: TwentyConfigService,
) {}
async handle({
request,
resolverLogicFunctionUniversalIdentifier,
}: {
request: Request;
resolverLogicFunctionUniversalIdentifier: string;
}): Promise<RouteTriggerResponse> {
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<LogicFunctionEntity | null> {
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;
}
}
}
@@ -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<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,
});
});
});
@@ -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<ServerWebhookTriggerExceptionCode> {
constructor(
message: string,
code: ServerWebhookTriggerExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ??
getServerWebhookTriggerExceptionUserFriendlyMessage(code),
});
}
}
@@ -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,
}),
);
}
}
@@ -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 {}
@@ -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<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;
}
}
@@ -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>): 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();
});
});
@@ -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<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);
};
@@ -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:
@@ -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",
],
@@ -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,
@@ -10,6 +10,7 @@ export const FLAT_LOGIC_FUNCTION_EDITABLE_PROPERTIES = [
'cronTriggerSettings',
'databaseEventTriggerSettings',
'httpRouteTriggerSettings',
'serverRouteTriggerSettings',
'toolTriggerSettings',
'workflowActionTriggerSettings',
'isBuildUpToDate',
@@ -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<HttpRouteTriggerSettings>;
@IsObject()
@Field(() => graphqlTypeJson, { nullable: true })
@IsOptional()
serverRouteTriggerSettings?: JsonbProperty<ServerRouteTriggerSettings>;
@IsObject()
@Field(() => graphqlTypeJson, { nullable: true })
@IsOptional()
@@ -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<HttpRouteTriggerSettings> | null;
@Column({ nullable: true, type: 'jsonb' })
serverRouteTriggerSettings: JsonbProperty<ServerRouteTriggerSettings> | null;
@Column({ nullable: true, type: 'jsonb' })
toolTriggerSettings: JsonbProperty<ToolTriggerSettings> | null;
@@ -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,
@@ -56,6 +56,8 @@ export const fromCreateLogicFunctionFromSourceInputToUniversalFlatLogicFunctionT
createLogicFunctionFromSourceInput.databaseEventTriggerSettings ?? null,
httpRouteTriggerSettings:
createLogicFunctionFromSourceInput.httpRouteTriggerSettings ?? null,
serverRouteTriggerSettings:
createLogicFunctionFromSourceInput.serverRouteTriggerSettings ?? null,
toolTriggerSettings:
createLogicFunctionFromSourceInput.toolTriggerSettings ?? null,
workflowActionTriggerSettings:
@@ -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,
@@ -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';
@@ -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;
@@ -0,0 +1,3 @@
export type ServerRouteTriggerSettings = {
forwardedRequestHeaders?: string[];
};
@@ -1,9 +0,0 @@
export type WebhookWorkspaceIdSource = 'body' | 'query' | 'header';
export type ServerWebhookTriggerSettings = {
workspaceIdResolver: {
source: WebhookWorkspaceIdSource;
path: string;
};
forwardedRequestHeaders?: string[];
};