feat(connections): run a logic function on connection provider connect (#23167)

## What

Adds an optional `onConnectLogicFunctionUniversalIdentifier` field to
the connection provider manifest. When set, the referenced logic
function is dispatched right after an OAuth connection is successfully
established for that provider.

This gives apps a first-class "on connect" hook — e.g. the Slack app can
resolve the workspace's `team_id` via `auth.test` and claim the `team_id
-> workspaceId` mapping in the SERVER key-value store immediately on
connect, instead of racing against later events.

Follow-up to the app key-value store PR (#23089).

## How

- **twenty-shared**: add `onConnectLogicFunctionUniversalIdentifier` to
`ConnectionProviderManifest`.
- **twenty-sdk**: expose the field in `defineConnectionProvider` and
validate it is a UUID `universalIdentifier`.
- **twenty-server**:
- add a nullable `onConnectLogicFunctionUniversalIdentifier` column to
`ConnectionProviderEntity` (+ fast instance command / migration).
  - map the field through the

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23167?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:
Abdul Rahman
2026-07-22 21:08:01 +05:30
committed by GitHub
parent 50ec77daf8
commit 04d1c2035c
23 changed files with 958 additions and 579 deletions
@@ -1389,44 +1389,6 @@ type ApplicationConnectionProvider {
oauth: ApplicationConnectionProviderOAuthConfig
}
type EnterpriseLicenseInfoDTO {
isValid: Boolean!
licensee: String
expiresAt: DateTime
subscriptionId: String
}
type EnterpriseSubscriptionStatusDTO {
status: String!
licensee: String
expiresAt: DateTime
cancelAt: DateTime
currentPeriodEnd: DateTime
isCancellationScheduled: Boolean!
}
type ApprovedAccessDomain {
id: UUID!
domain: String!
isValidated: Boolean!
createdAt: DateTime!
}
type FileWithSignedUrl {
id: UUID!
path: String!
size: Float!
createdAt: DateTime!
url: String!
}
type FileUploadTarget {
fileId: UUID!
uploadUrl: String!
contentType: String!
expiresAt: DateTime!
}
type BillingSubscriptionSchedulePhaseItem {
price: String!
quantity: Float
@@ -1518,11 +1480,6 @@ type BillingSubscriptionItem {
billingProduct: BillingProductDTO!
}
type BillingCustomer {
id: UUID!
hasPaymentMethod: Boolean
}
type BillingSubscription {
id: UUID!
status: SubscriptionStatus!
@@ -1545,6 +1502,73 @@ enum SubscriptionStatus {
Unpaid
}
type BillingCustomer {
id: UUID!
hasPaymentMethod: Boolean
}
type LogicFunctionExecutionResult {
"""Execution result in JSON format"""
data: JSON
"""Execution Logs"""
logs: String!
"""Execution duration in milliseconds"""
duration: Float!
"""Execution status"""
status: LogicFunctionExecutionStatus!
"""Execution error in JSON format"""
error: JSON
}
"""Status of the logic function execution"""
enum LogicFunctionExecutionStatus {
IDLE
SUCCESS
ERROR
}
type EnterpriseLicenseInfoDTO {
isValid: Boolean!
licensee: String
expiresAt: DateTime
subscriptionId: String
}
type EnterpriseSubscriptionStatusDTO {
status: String!
licensee: String
expiresAt: DateTime
cancelAt: DateTime
currentPeriodEnd: DateTime
isCancellationScheduled: Boolean!
}
type ApprovedAccessDomain {
id: UUID!
domain: String!
isValidated: Boolean!
createdAt: DateTime!
}
type FileWithSignedUrl {
id: UUID!
path: String!
size: Float!
createdAt: DateTime!
url: String!
}
type FileUploadTarget {
fileId: UUID!
uploadUrl: String!
contentType: String!
expiresAt: DateTime!
}
type BillingEndTrialPeriod {
"""Updated subscription status"""
status: SubscriptionStatus
@@ -1704,30 +1728,6 @@ type EventSubscription {
metadataEvents: [MetadataEvent!]!
}
type LogicFunctionExecutionResult {
"""Execution result in JSON format"""
data: JSON
"""Execution Logs"""
logs: String!
"""Execution duration in milliseconds"""
duration: Float!
"""Execution status"""
status: LogicFunctionExecutionStatus!
"""Execution error in JSON format"""
error: JSON
}
"""Status of the logic function execution"""
enum LogicFunctionExecutionStatus {
IDLE
SUCCESS
ERROR
}
type FeatureFlag {
key: FeatureFlagKey!
value: Boolean!
@@ -1063,49 +1063,6 @@ export interface ApplicationConnectionProvider {
__typename: 'ApplicationConnectionProvider'
}
export interface EnterpriseLicenseInfoDTO {
isValid: Scalars['Boolean']
licensee?: Scalars['String']
expiresAt?: Scalars['DateTime']
subscriptionId?: Scalars['String']
__typename: 'EnterpriseLicenseInfoDTO'
}
export interface EnterpriseSubscriptionStatusDTO {
status: Scalars['String']
licensee?: Scalars['String']
expiresAt?: Scalars['DateTime']
cancelAt?: Scalars['DateTime']
currentPeriodEnd?: Scalars['DateTime']
isCancellationScheduled: Scalars['Boolean']
__typename: 'EnterpriseSubscriptionStatusDTO'
}
export interface ApprovedAccessDomain {
id: Scalars['UUID']
domain: Scalars['String']
isValidated: Scalars['Boolean']
createdAt: Scalars['DateTime']
__typename: 'ApprovedAccessDomain'
}
export interface FileWithSignedUrl {
id: Scalars['UUID']
path: Scalars['String']
size: Scalars['Float']
createdAt: Scalars['DateTime']
url: Scalars['String']
__typename: 'FileWithSignedUrl'
}
export interface FileUploadTarget {
fileId: Scalars['UUID']
uploadUrl: Scalars['String']
contentType: Scalars['String']
expiresAt: Scalars['DateTime']
__typename: 'FileUploadTarget'
}
export interface BillingSubscriptionSchedulePhaseItem {
price: Scalars['String']
quantity?: Scalars['Float']
@@ -1197,12 +1154,6 @@ export interface BillingSubscriptionItem {
__typename: 'BillingSubscriptionItem'
}
export interface BillingCustomer {
id: Scalars['UUID']
hasPaymentMethod?: Scalars['Boolean']
__typename: 'BillingCustomer'
}
export interface BillingSubscription {
id: Scalars['UUID']
status: SubscriptionStatus
@@ -1217,6 +1168,73 @@ export interface BillingSubscription {
export type SubscriptionStatus = 'Active' | 'Canceled' | 'Incomplete' | 'IncompleteExpired' | 'PastDue' | 'Paused' | 'Trialing' | 'Unpaid'
export interface BillingCustomer {
id: Scalars['UUID']
hasPaymentMethod?: Scalars['Boolean']
__typename: 'BillingCustomer'
}
export interface LogicFunctionExecutionResult {
/** Execution result in JSON format */
data?: Scalars['JSON']
/** Execution Logs */
logs: Scalars['String']
/** Execution duration in milliseconds */
duration: Scalars['Float']
/** Execution status */
status: LogicFunctionExecutionStatus
/** Execution error in JSON format */
error?: Scalars['JSON']
__typename: 'LogicFunctionExecutionResult'
}
/** Status of the logic function execution */
export type LogicFunctionExecutionStatus = 'IDLE' | 'SUCCESS' | 'ERROR'
export interface EnterpriseLicenseInfoDTO {
isValid: Scalars['Boolean']
licensee?: Scalars['String']
expiresAt?: Scalars['DateTime']
subscriptionId?: Scalars['String']
__typename: 'EnterpriseLicenseInfoDTO'
}
export interface EnterpriseSubscriptionStatusDTO {
status: Scalars['String']
licensee?: Scalars['String']
expiresAt?: Scalars['DateTime']
cancelAt?: Scalars['DateTime']
currentPeriodEnd?: Scalars['DateTime']
isCancellationScheduled: Scalars['Boolean']
__typename: 'EnterpriseSubscriptionStatusDTO'
}
export interface ApprovedAccessDomain {
id: Scalars['UUID']
domain: Scalars['String']
isValidated: Scalars['Boolean']
createdAt: Scalars['DateTime']
__typename: 'ApprovedAccessDomain'
}
export interface FileWithSignedUrl {
id: Scalars['UUID']
path: Scalars['String']
size: Scalars['Float']
createdAt: Scalars['DateTime']
url: Scalars['String']
__typename: 'FileWithSignedUrl'
}
export interface FileUploadTarget {
fileId: Scalars['UUID']
uploadUrl: Scalars['String']
contentType: Scalars['String']
expiresAt: Scalars['DateTime']
__typename: 'FileUploadTarget'
}
export interface BillingEndTrialPeriod {
/** Updated subscription status */
status?: SubscriptionStatus
@@ -1372,24 +1390,6 @@ export interface EventSubscription {
__typename: 'EventSubscription'
}
export interface LogicFunctionExecutionResult {
/** Execution result in JSON format */
data?: Scalars['JSON']
/** Execution Logs */
logs: Scalars['String']
/** Execution duration in milliseconds */
duration: Scalars['Float']
/** Execution status */
status: LogicFunctionExecutionStatus
/** Execution error in JSON format */
error?: Scalars['JSON']
__typename: 'LogicFunctionExecutionResult'
}
/** Status of the logic function execution */
export type LogicFunctionExecutionStatus = 'IDLE' | 'SUCCESS' | 'ERROR'
export interface FeatureFlag {
key: FeatureFlagKey
value: Scalars['Boolean']
@@ -4177,54 +4177,6 @@ export interface ApplicationConnectionProviderGenqlSelection{
__scalar?: boolean | number
}
export interface EnterpriseLicenseInfoDTOGenqlSelection{
isValid?: boolean | number
licensee?: boolean | number
expiresAt?: boolean | number
subscriptionId?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface EnterpriseSubscriptionStatusDTOGenqlSelection{
status?: boolean | number
licensee?: boolean | number
expiresAt?: boolean | number
cancelAt?: boolean | number
currentPeriodEnd?: boolean | number
isCancellationScheduled?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ApprovedAccessDomainGenqlSelection{
id?: boolean | number
domain?: boolean | number
isValidated?: boolean | number
createdAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface FileWithSignedUrlGenqlSelection{
id?: boolean | number
path?: boolean | number
size?: boolean | number
createdAt?: boolean | number
url?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface FileUploadTargetGenqlSelection{
fileId?: boolean | number
uploadUrl?: boolean | number
contentType?: boolean | number
expiresAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface BillingSubscriptionSchedulePhaseItemGenqlSelection{
price?: boolean | number
quantity?: boolean | number
@@ -4314,13 +4266,6 @@ export interface BillingSubscriptionItemGenqlSelection{
__scalar?: boolean | number
}
export interface BillingCustomerGenqlSelection{
id?: boolean | number
hasPaymentMethod?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface BillingSubscriptionGenqlSelection{
id?: boolean | number
status?: boolean | number
@@ -4334,6 +4279,76 @@ export interface BillingSubscriptionGenqlSelection{
__scalar?: boolean | number
}
export interface BillingCustomerGenqlSelection{
id?: boolean | number
hasPaymentMethod?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface LogicFunctionExecutionResultGenqlSelection{
/** Execution result in JSON format */
data?: boolean | number
/** Execution Logs */
logs?: boolean | number
/** Execution duration in milliseconds */
duration?: boolean | number
/** Execution status */
status?: boolean | number
/** Execution error in JSON format */
error?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface EnterpriseLicenseInfoDTOGenqlSelection{
isValid?: boolean | number
licensee?: boolean | number
expiresAt?: boolean | number
subscriptionId?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface EnterpriseSubscriptionStatusDTOGenqlSelection{
status?: boolean | number
licensee?: boolean | number
expiresAt?: boolean | number
cancelAt?: boolean | number
currentPeriodEnd?: boolean | number
isCancellationScheduled?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface ApprovedAccessDomainGenqlSelection{
id?: boolean | number
domain?: boolean | number
isValidated?: boolean | number
createdAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface FileWithSignedUrlGenqlSelection{
id?: boolean | number
path?: boolean | number
size?: boolean | number
createdAt?: boolean | number
url?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface FileUploadTargetGenqlSelection{
fileId?: boolean | number
uploadUrl?: boolean | number
contentType?: boolean | number
expiresAt?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface BillingEndTrialPeriodGenqlSelection{
/** Updated subscription status */
status?: boolean | number
@@ -4496,21 +4511,6 @@ export interface EventSubscriptionGenqlSelection{
__scalar?: boolean | number
}
export interface LogicFunctionExecutionResultGenqlSelection{
/** Execution result in JSON format */
data?: boolean | number
/** Execution Logs */
logs?: boolean | number
/** Execution duration in milliseconds */
duration?: boolean | number
/** Execution status */
status?: boolean | number
/** Execution error in JSON format */
error?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
export interface FeatureFlagGenqlSelection{
key?: boolean | number
value?: boolean | number
@@ -7325,46 +7325,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const EnterpriseLicenseInfoDTO_possibleTypes: string[] = ['EnterpriseLicenseInfoDTO']
export const isEnterpriseLicenseInfoDTO = (obj?: { __typename?: any } | null): obj is EnterpriseLicenseInfoDTO => {
if (!obj?.__typename) throw new Error('__typename is missing in "isEnterpriseLicenseInfoDTO"')
return EnterpriseLicenseInfoDTO_possibleTypes.includes(obj.__typename)
}
const EnterpriseSubscriptionStatusDTO_possibleTypes: string[] = ['EnterpriseSubscriptionStatusDTO']
export const isEnterpriseSubscriptionStatusDTO = (obj?: { __typename?: any } | null): obj is EnterpriseSubscriptionStatusDTO => {
if (!obj?.__typename) throw new Error('__typename is missing in "isEnterpriseSubscriptionStatusDTO"')
return EnterpriseSubscriptionStatusDTO_possibleTypes.includes(obj.__typename)
}
const ApprovedAccessDomain_possibleTypes: string[] = ['ApprovedAccessDomain']
export const isApprovedAccessDomain = (obj?: { __typename?: any } | null): obj is ApprovedAccessDomain => {
if (!obj?.__typename) throw new Error('__typename is missing in "isApprovedAccessDomain"')
return ApprovedAccessDomain_possibleTypes.includes(obj.__typename)
}
const FileWithSignedUrl_possibleTypes: string[] = ['FileWithSignedUrl']
export const isFileWithSignedUrl = (obj?: { __typename?: any } | null): obj is FileWithSignedUrl => {
if (!obj?.__typename) throw new Error('__typename is missing in "isFileWithSignedUrl"')
return FileWithSignedUrl_possibleTypes.includes(obj.__typename)
}
const FileUploadTarget_possibleTypes: string[] = ['FileUploadTarget']
export const isFileUploadTarget = (obj?: { __typename?: any } | null): obj is FileUploadTarget => {
if (!obj?.__typename) throw new Error('__typename is missing in "isFileUploadTarget"')
return FileUploadTarget_possibleTypes.includes(obj.__typename)
}
const BillingSubscriptionSchedulePhaseItem_possibleTypes: string[] = ['BillingSubscriptionSchedulePhaseItem']
export const isBillingSubscriptionSchedulePhaseItem = (obj?: { __typename?: any } | null): obj is BillingSubscriptionSchedulePhaseItem => {
if (!obj?.__typename) throw new Error('__typename is missing in "isBillingSubscriptionSchedulePhaseItem"')
@@ -7445,6 +7405,14 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const BillingSubscription_possibleTypes: string[] = ['BillingSubscription']
export const isBillingSubscription = (obj?: { __typename?: any } | null): obj is BillingSubscription => {
if (!obj?.__typename) throw new Error('__typename is missing in "isBillingSubscription"')
return BillingSubscription_possibleTypes.includes(obj.__typename)
}
const BillingCustomer_possibleTypes: string[] = ['BillingCustomer']
export const isBillingCustomer = (obj?: { __typename?: any } | null): obj is BillingCustomer => {
if (!obj?.__typename) throw new Error('__typename is missing in "isBillingCustomer"')
@@ -7453,10 +7421,50 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const BillingSubscription_possibleTypes: string[] = ['BillingSubscription']
export const isBillingSubscription = (obj?: { __typename?: any } | null): obj is BillingSubscription => {
if (!obj?.__typename) throw new Error('__typename is missing in "isBillingSubscription"')
return BillingSubscription_possibleTypes.includes(obj.__typename)
const LogicFunctionExecutionResult_possibleTypes: string[] = ['LogicFunctionExecutionResult']
export const isLogicFunctionExecutionResult = (obj?: { __typename?: any } | null): obj is LogicFunctionExecutionResult => {
if (!obj?.__typename) throw new Error('__typename is missing in "isLogicFunctionExecutionResult"')
return LogicFunctionExecutionResult_possibleTypes.includes(obj.__typename)
}
const EnterpriseLicenseInfoDTO_possibleTypes: string[] = ['EnterpriseLicenseInfoDTO']
export const isEnterpriseLicenseInfoDTO = (obj?: { __typename?: any } | null): obj is EnterpriseLicenseInfoDTO => {
if (!obj?.__typename) throw new Error('__typename is missing in "isEnterpriseLicenseInfoDTO"')
return EnterpriseLicenseInfoDTO_possibleTypes.includes(obj.__typename)
}
const EnterpriseSubscriptionStatusDTO_possibleTypes: string[] = ['EnterpriseSubscriptionStatusDTO']
export const isEnterpriseSubscriptionStatusDTO = (obj?: { __typename?: any } | null): obj is EnterpriseSubscriptionStatusDTO => {
if (!obj?.__typename) throw new Error('__typename is missing in "isEnterpriseSubscriptionStatusDTO"')
return EnterpriseSubscriptionStatusDTO_possibleTypes.includes(obj.__typename)
}
const ApprovedAccessDomain_possibleTypes: string[] = ['ApprovedAccessDomain']
export const isApprovedAccessDomain = (obj?: { __typename?: any } | null): obj is ApprovedAccessDomain => {
if (!obj?.__typename) throw new Error('__typename is missing in "isApprovedAccessDomain"')
return ApprovedAccessDomain_possibleTypes.includes(obj.__typename)
}
const FileWithSignedUrl_possibleTypes: string[] = ['FileWithSignedUrl']
export const isFileWithSignedUrl = (obj?: { __typename?: any } | null): obj is FileWithSignedUrl => {
if (!obj?.__typename) throw new Error('__typename is missing in "isFileWithSignedUrl"')
return FileWithSignedUrl_possibleTypes.includes(obj.__typename)
}
const FileUploadTarget_possibleTypes: string[] = ['FileUploadTarget']
export const isFileUploadTarget = (obj?: { __typename?: any } | null): obj is FileUploadTarget => {
if (!obj?.__typename) throw new Error('__typename is missing in "isFileUploadTarget"')
return FileUploadTarget_possibleTypes.includes(obj.__typename)
}
@@ -7597,14 +7605,6 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
const LogicFunctionExecutionResult_possibleTypes: string[] = ['LogicFunctionExecutionResult']
export const isLogicFunctionExecutionResult = (obj?: { __typename?: any } | null): obj is LogicFunctionExecutionResult => {
if (!obj?.__typename) throw new Error('__typename is missing in "isLogicFunctionExecutionResult"')
return LogicFunctionExecutionResult_possibleTypes.includes(obj.__typename)
}
const FeatureFlag_possibleTypes: string[] = ['FeatureFlag']
export const isFeatureFlag = (obj?: { __typename?: any } | null): obj is FeatureFlag => {
if (!obj?.__typename) throw new Error('__typename is missing in "isFeatureFlag"')
@@ -9236,6 +9236,12 @@ export const enumSubscriptionStatus = {
Unpaid: 'Unpaid' as const
}
export const enumLogicFunctionExecutionStatus = {
IDLE: 'IDLE' as const,
SUCCESS: 'SUCCESS' as const,
ERROR: 'ERROR' as const
}
export const enumNavigationMenuItemType = {
VIEW: 'VIEW' as const,
FOLDER: 'FOLDER' as const,
@@ -9260,12 +9266,6 @@ export const enumDatabaseEventAction = {
UPSERTED: 'UPSERTED' as const
}
export const enumLogicFunctionExecutionStatus = {
IDLE: 'IDLE' as const,
SUCCESS: 'SUCCESS' as const,
ERROR: 'ERROR' as const
}
export const enumFeatureFlagKey = {
IS_APP_CLAIMING_ENABLED: 'IS_APP_CLAIMING_ENABLED' as const,
IS_UNIQUE_INDEXES_ENABLED: 'IS_UNIQUE_INDEXES_ENABLED' as const,
@@ -44,15 +44,15 @@ export default {
101,
107,
121,
132,
133,
134,
136,
145,
158,
161,
127,
128,
129,
131,
139,
142,
160,
163,
167,
165,
169,
176,
177,
@@ -97,13 +97,13 @@ export default {
1
],
"metadata": [
131
126
],
"on_BillingLicensedProduct": [
140
135
],
"on_BillingMeteredProduct": [
141
136
],
"__typename": [
1
@@ -1731,16 +1731,16 @@ export default {
168
],
"billingSubscriptions": [
144
138
],
"installedApplications": [
50
],
"currentBillingSubscription": [
144
138
],
"billingCustomer": [
143
140
],
"billingEntitlements": [
231
@@ -2707,6 +2707,239 @@ export default {
1
]
},
"BillingSubscriptionSchedulePhaseItem": {
"price": [
1
],
"quantity": [
16
],
"__typename": [
1
]
},
"BillingSubscriptionSchedulePhase": {
"start_date": [
16
],
"end_date": [
16
],
"items": [
124
],
"__typename": [
1
]
},
"BillingProductMetadata": {
"planKey": [
127
],
"priceUsageBased": [
128
],
"productKey": [
129
],
"__typename": [
1
]
},
"BillingPlanKey": {},
"BillingUsageType": {},
"BillingProductKey": {},
"BillingPriceLicensed": {
"recurringInterval": [
131
],
"unitAmount": [
16
],
"stripePriceId": [
1
],
"priceUsageType": [
128
],
"creditAmount": [
16
],
"__typename": [
1
]
},
"SubscriptionInterval": {},
"BillingPriceTier": {
"upTo": [
16
],
"flatAmount": [
16
],
"unitAmount": [
16
],
"__typename": [
1
]
},
"BillingPriceMetered": {
"tiers": [
132
],
"recurringInterval": [
131
],
"stripePriceId": [
1
],
"priceUsageType": [
128
],
"__typename": [
1
]
},
"BillingProduct": {
"name": [
1
],
"description": [
1
],
"images": [
1
],
"metadata": [
126
],
"__typename": [
1
]
},
"BillingLicensedProduct": {
"name": [
1
],
"description": [
1
],
"images": [
1
],
"metadata": [
126
],
"prices": [
130
],
"__typename": [
1
]
},
"BillingMeteredProduct": {
"name": [
1
],
"description": [
1
],
"images": [
1
],
"metadata": [
126
],
"prices": [
133
],
"__typename": [
1
]
},
"BillingSubscriptionItem": {
"id": [
4
],
"hasReachedCurrentPeriodCap": [
3
],
"quantity": [
16
],
"stripePriceId": [
1
],
"billingProduct": [
0
],
"__typename": [
1
]
},
"BillingSubscription": {
"id": [
4
],
"status": [
139
],
"interval": [
131
],
"billingSubscriptionItems": [
137
],
"currentPeriodEnd": [
6
],
"metadata": [
5
],
"phases": [
125
],
"cancelAt": [
6
],
"__typename": [
1
]
},
"SubscriptionStatus": {},
"BillingCustomer": {
"id": [
4
],
"hasPaymentMethod": [
3
],
"__typename": [
1
]
},
"LogicFunctionExecutionResult": {
"data": [
5
],
"logs": [
1
],
"duration": [
16
],
"status": [
142
],
"error": [
5
],
"__typename": [
1
]
},
"LogicFunctionExecutionStatus": {},
"EnterpriseLicenseInfoDTO": {
"isValid": [
3
@@ -2801,221 +3034,9 @@ export default {
1
]
},
"BillingSubscriptionSchedulePhaseItem": {
"price": [
1
],
"quantity": [
16
],
"__typename": [
1
]
},
"BillingSubscriptionSchedulePhase": {
"start_date": [
16
],
"end_date": [
16
],
"items": [
129
],
"__typename": [
1
]
},
"BillingProductMetadata": {
"planKey": [
132
],
"priceUsageBased": [
133
],
"productKey": [
134
],
"__typename": [
1
]
},
"BillingPlanKey": {},
"BillingUsageType": {},
"BillingProductKey": {},
"BillingPriceLicensed": {
"recurringInterval": [
136
],
"unitAmount": [
16
],
"stripePriceId": [
1
],
"priceUsageType": [
133
],
"creditAmount": [
16
],
"__typename": [
1
]
},
"SubscriptionInterval": {},
"BillingPriceTier": {
"upTo": [
16
],
"flatAmount": [
16
],
"unitAmount": [
16
],
"__typename": [
1
]
},
"BillingPriceMetered": {
"tiers": [
137
],
"recurringInterval": [
136
],
"stripePriceId": [
1
],
"priceUsageType": [
133
],
"__typename": [
1
]
},
"BillingProduct": {
"name": [
1
],
"description": [
1
],
"images": [
1
],
"metadata": [
131
],
"__typename": [
1
]
},
"BillingLicensedProduct": {
"name": [
1
],
"description": [
1
],
"images": [
1
],
"metadata": [
131
],
"prices": [
135
],
"__typename": [
1
]
},
"BillingMeteredProduct": {
"name": [
1
],
"description": [
1
],
"images": [
1
],
"metadata": [
131
],
"prices": [
138
],
"__typename": [
1
]
},
"BillingSubscriptionItem": {
"id": [
4
],
"hasReachedCurrentPeriodCap": [
3
],
"quantity": [
16
],
"stripePriceId": [
1
],
"billingProduct": [
0
],
"__typename": [
1
]
},
"BillingCustomer": {
"id": [
4
],
"hasPaymentMethod": [
3
],
"__typename": [
1
]
},
"BillingSubscription": {
"id": [
4
],
"status": [
145
],
"interval": [
136
],
"billingSubscriptionItems": [
142
],
"currentPeriodEnd": [
6
],
"metadata": [
5
],
"phases": [
130
],
"cancelAt": [
6
],
"__typename": [
1
]
},
"SubscriptionStatus": {},
"BillingEndTrialPeriod": {
"status": [
145
139
],
"hasPaymentMethod": [
3
@@ -3029,7 +3050,7 @@ export default {
},
"BillingResourceCreditUsage": {
"productKey": [
134
129
],
"periodStart": [
6
@@ -3058,16 +3079,16 @@ export default {
},
"BillingPlan": {
"planKey": [
132
127
],
"baseProducts": [
140
135
],
"resourceCreditProducts": [
140
135
],
"meteredProducts": [
141
136
],
"__typename": [
1
@@ -3094,10 +3115,10 @@ export default {
},
"BillingUpdate": {
"currentBillingSubscription": [
144
138
],
"billingSubscriptions": [
144
138
],
"__typename": [
1
@@ -3147,7 +3168,7 @@ export default {
1
],
"result": [
154
156
],
"__typename": [
1
@@ -3184,7 +3205,7 @@ export default {
4
],
"type": [
158
160
],
"name": [
1
@@ -3217,7 +3238,7 @@ export default {
6
],
"targetRecordIdentifier": [
156
158
],
"__typename": [
1
@@ -3243,7 +3264,7 @@ export default {
},
"MetadataEvent": {
"type": [
161
163
],
"metadataName": [
1
@@ -3252,7 +3273,7 @@ export default {
1
],
"properties": [
159
161
],
"updatedCollectionHash": [
1
@@ -3264,7 +3285,7 @@ export default {
"MetadataEventAction": {},
"ObjectRecordEvent": {
"action": [
163
165
],
"objectNameSingular": [
1
@@ -3279,7 +3300,7 @@ export default {
1
],
"properties": [
159
161
],
"__typename": [
1
@@ -3291,7 +3312,7 @@ export default {
1
],
"objectRecordEvent": [
162
164
],
"__typename": [
1
@@ -3302,36 +3323,15 @@ export default {
1
],
"objectRecordEventsWithQueryIds": [
164
166
],
"metadataEvents": [
160
162
],
"__typename": [
1
]
},
"LogicFunctionExecutionResult": {
"data": [
5
],
"logs": [
1
],
"duration": [
16
],
"status": [
167
],
"error": [
5
],
"__typename": [
1
]
},
"LogicFunctionExecutionStatus": {},
"FeatureFlag": {
"key": [
169
@@ -6085,10 +6085,10 @@ export default {
},
"Query": {
"navigationMenuItems": [
157
159
],
"navigationMenuItem": [
157,
159,
{
"id": [
4,
@@ -6122,7 +6122,7 @@ export default {
}
],
"enterpriseSubscriptionStatus": [
125
144
],
"getViewFilterGroups": [
56,
@@ -6248,7 +6248,7 @@ export default {
}
],
"getInviteSuggestions": [
152
154
],
"applicationConnectionProviders": [
123,
@@ -6260,7 +6260,7 @@ export default {
}
],
"billingPortalSession": [
150,
152,
{
"returnUrlPath": [
1
@@ -6271,16 +6271,16 @@ export default {
}
],
"listPlans": [
148
150
],
"getResourceCreditUsage": [
147
149
],
"findWorkspaceInvitations": [
154
156
],
"getApprovedAccessDomains": [
126
145
],
"getPageLayoutTabs": [
119,
@@ -7138,7 +7138,7 @@ export default {
}
],
"createManyNavigationMenuItems": [
157,
159,
{
"inputs": [
359,
@@ -7147,7 +7147,7 @@ export default {
}
],
"createNavigationMenuItem": [
157,
159,
{
"input": [
359,
@@ -7156,7 +7156,7 @@ export default {
}
],
"updateManyNavigationMenuItems": [
157,
159,
{
"inputs": [
360,
@@ -7165,7 +7165,7 @@ export default {
}
],
"updateNavigationMenuItem": [
157,
159,
{
"input": [
360,
@@ -7174,7 +7174,7 @@ export default {
}
],
"deleteManyNavigationMenuItems": [
157,
159,
{
"ids": [
4,
@@ -7183,7 +7183,7 @@ export default {
}
],
"deleteNavigationMenuItem": [
157,
159,
{
"id": [
4,
@@ -7192,7 +7192,7 @@ export default {
}
],
"createFileUpload": [
128,
147,
{
"filename": [
1,
@@ -7215,7 +7215,7 @@ export default {
}
],
"completeFileUpload": [
127,
146,
{
"fileId": [
1,
@@ -7227,10 +7227,10 @@ export default {
3
],
"releaseEnterpriseServerBinding": [
124
143
],
"setEnterpriseKey": [
124,
143,
{
"enterpriseKey": [
1,
@@ -7239,7 +7239,7 @@ export default {
}
],
"uploadEmailAttachmentFile": [
127,
146,
{
"file": [
363,
@@ -7248,7 +7248,7 @@ export default {
}
],
"uploadAiChatFile": [
127,
146,
{
"file": [
363,
@@ -7257,7 +7257,7 @@ export default {
}
],
"uploadWorkflowFile": [
127,
146,
{
"file": [
363,
@@ -7266,7 +7266,7 @@ export default {
}
],
"uploadWorkspaceLogo": [
127,
146,
{
"file": [
363,
@@ -7275,7 +7275,7 @@ export default {
}
],
"uploadWorkspaceMemberProfilePicture": [
127,
146,
{
"file": [
363,
@@ -7284,7 +7284,7 @@ export default {
}
],
"uploadFilesFieldFile": [
127,
146,
{
"file": [
363,
@@ -7297,7 +7297,7 @@ export default {
}
],
"uploadFilesFieldFileByUniversalIdentifier": [
127,
146,
{
"file": [
363,
@@ -7610,10 +7610,10 @@ export default {
}
],
"skipSyncEmailOnboardingStep": [
153
155
],
"triggerInstallAppsOnboardingStep": [
153,
155,
{
"universalIdentifiers": [
1,
@@ -7639,14 +7639,14 @@ export default {
}
],
"checkoutSession": [
150,
152,
{
"recurringInterval": [
136,
131,
"SubscriptionInterval!"
],
"plan": [
132,
127,
"BillingPlanKey!"
],
"requirePaymentMethod": [
@@ -7659,14 +7659,14 @@ export default {
}
],
"createSubscriptionPaymentIntent": [
149,
151,
{
"recurringInterval": [
136,
131,
"SubscriptionInterval!"
],
"plan": [
132,
127,
"BillingPlanKey!"
],
"requirePaymentMethod": [
@@ -7683,22 +7683,22 @@ export default {
}
],
"createBillingPaymentMethodSetupIntent": [
149
151
],
"switchSubscriptionInterval": [
151
153
],
"switchBillingPlan": [
151
153
],
"cancelSwitchBillingPlan": [
151
153
],
"cancelSwitchBillingInterval": [
151
153
],
"setResourceCreditSubscriptionPrice": [
151,
153,
{
"priceId": [
1,
@@ -7707,10 +7707,10 @@ export default {
}
],
"endSubscriptionTrialPeriod": [
146
148
],
"cancelSwitchResourceCreditPrice": [
151
153
],
"deleteWorkspaceInvitation": [
1,
@@ -7722,7 +7722,7 @@ export default {
}
],
"resendWorkspaceInvitation": [
155,
157,
{
"appTokenId": [
1,
@@ -7731,7 +7731,7 @@ export default {
}
],
"sendInvitations": [
155,
157,
{
"emails": [
1,
@@ -7743,7 +7743,7 @@ export default {
}
],
"createApprovedAccessDomain": [
126,
145,
{
"input": [
400,
@@ -7761,7 +7761,7 @@ export default {
}
],
"validateApprovedAccessDomain": [
126,
145,
{
"input": [
402,
@@ -7993,7 +7993,7 @@ export default {
}
],
"executeOneLogicFunction": [
166,
141,
{
"input": [
425,
@@ -8989,7 +8989,7 @@ export default {
}
],
"uploadNewWorkspaceLogo": [
127,
146,
{
"workspaceId": [
1,
@@ -9458,7 +9458,7 @@ export default {
4
],
"type": [
158
160
],
"name": [
1
@@ -12102,7 +12102,7 @@ export default {
},
"Subscription": {
"onEventSubscription": [
165,
167,
{
"eventStreamId": [
1,
@@ -42,6 +42,9 @@ export default defineConnectionProvider({
// Optional: provider's RFC 7009 token revocation endpoint, called on disconnect.
// revokeEndpoint: 'https://example.com/oauth/revoke',
},
// Optional: a logic function in this app to run right after a connection is
// established. See "Run a logic function on connect".
// onConnectLogicFunction: { universalIdentifier: '3a2b1c0d-...-...' },
});
```
@@ -87,6 +90,40 @@ https://<your-twenty-server>/auth/apps/callback
</Accordion>
<Accordion title="Run a logic function on connect" description="React the moment a connection is established">
Some providers hand you data at connect time that you need to persist before the connection is usable — the classic example is Slack, where the OAuth response identifies the workspace's `team_id` that inbound events will be keyed by. Set `onConnectLogicFunction` to reference a logic function in the same app (by its `universalIdentifier`), and it runs right after the `ConnectedAccount` is created.
```ts src/connection-providers/slack-connection.ts
export default defineConnectionProvider({
universalIdentifier: '...',
name: 'slack',
displayName: 'Slack',
type: 'oauth',
oauth: {
/* ... */
},
// Runs claimSlackTeam after every successful Slack connection.
onConnectLogicFunction: {
universalIdentifier: '3a2b1c0d-1111-4222-8333-444455556666',
},
});
```
The hook runs **asynchronously in the connecting workspace** (it is enqueued, not awaited), so a slow or failing hook never blocks or breaks the OAuth callback — make it idempotent and handle its own retries. The handler receives:
```ts
type OnConnectPayload = {
connectionProviderId: string;
connectionProviderName: string; // e.g. 'slack'
connectedAccountId: string;
};
```
From there use `getConnection(connectedAccountId)` to read the fresh access token and call the provider's API (e.g. Slack `auth.test`) or persist a mapping with the [key-value store](/developers/extend/apps/logic/key-value-store).
</Accordion>
<Accordion title="listConnections / getConnection" description="Use connections from a logic function">
Inside a logic function handler, `listConnections({ providerName })` returns this app's `ConnectedAccount` rows for the given provider, with refreshed access tokens.
@@ -196,6 +196,37 @@ describe('manifestValidate', () => {
'Duplicate universal identifiers: 550e8400-e29b-41d4-a716-446655440001',
);
});
it('should not flag a connection provider referencing a logic function via onConnectLogicFunction as a duplicate', () => {
const logicFunctionId = '550e8400-e29b-41d4-a716-446655440040';
const logicFunction = {
universalIdentifier: logicFunctionId,
name: 'onConnect',
sourceHandlerPath: 'src/logic-functions/on-connect.ts',
builtHandlerPath: 'dist/on-connect.js',
builtHandlerChecksum: '00000000-0000-4000-8000-000000000000',
handlerName: 'handler',
} as unknown as Manifest['logicFunctions'][number];
const connectionProvider = {
universalIdentifier: '550e8400-e29b-41d4-a716-446655440041',
name: 'slack',
displayName: 'Slack',
type: 'oauth',
oauth: {},
onConnectLogicFunction: { universalIdentifier: logicFunctionId },
} as unknown as NonNullable<Manifest['connectionProviders']>[number];
const result = manifestValidate({
...validManifest,
logicFunctions: [logicFunction],
connectionProviders: [connectionProvider],
});
expect(result.isValid).toBe(true);
expect(result.errors).toHaveLength(0);
});
});
describe('relation field validation', () => {
@@ -72,7 +72,8 @@ const findUniversalIdentifiers = (obj: object): string[] => {
if (
key === 'postInstallLogicFunction' ||
key === 'preInstallLogicFunction'
key === 'preInstallLogicFunction' ||
key === 'onConnectLogicFunction'
) {
continue;
}
@@ -82,4 +82,28 @@ describe('defineConnectionProvider', () => {
expect(result.success).toBe(false);
});
it('accepts a valid onConnectLogicFunction', () => {
const result = defineConnectionProvider({
...baseValidConfig,
onConnectLogicFunction: {
universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
},
});
expect(result.success).toBe(true);
expect(result.errors).toEqual([]);
});
it('rejects a non-UUID onConnectLogicFunction universalIdentifier', () => {
const result = defineConnectionProvider({
...baseValidConfig,
onConnectLogicFunction: { universalIdentifier: 'claim-team-id' },
});
expect(result.success).toBe(false);
expect(
result.errors.some((error) => error.includes('onConnectLogicFunction')),
).toBe(true);
});
});
@@ -36,6 +36,15 @@ export const defineConnectionProvider: DefineEntity<
errors.push('Connection provider must have a displayName');
}
if (
config.onConnectLogicFunction &&
!UUID_PATTERN.test(config.onConnectLogicFunction.universalIdentifier)
) {
errors.push(
`Connection provider onConnectLogicFunction.universalIdentifier "${config.onConnectLogicFunction.universalIdentifier}" must be the UUID universalIdentifier of a logic function in this app.`,
);
}
if (!config.type) {
errors.push("Connection provider must declare a `type` (e.g. 'oauth')");
} else if (!(SUPPORTED_TYPES as readonly string[]).includes(config.type)) {
@@ -0,0 +1,21 @@
import { type QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { type FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.24.0', 1784712843602)
export class AddOnConnectLogicFunctionToConnectionProviderFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "core"."connectionProvider" ADD COLUMN IF NOT EXISTS "onConnectLogicFunctionUniversalIdentifier" uuid',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "core"."connectionProvider" DROP COLUMN IF EXISTS "onConnectLogicFunctionUniversalIdentifier"',
);
}
}
@@ -120,6 +120,7 @@ import { AddApplicationIdToKeyValuePairFastInstanceCommand } from './2-23/2-23-i
import { BackfillCreatedWorkspaceActivationStatusSlowInstanceCommand } from './2-23/2-23-instance-command-slow-1784286705000-backfill-created-workspace-activation-status';
import { UnlistUnclaimedNpmApplicationRegistrationsSlowInstanceCommand } from './2-23/2-23-instance-command-slow-1784322591746-unlist-unclaimed-npm-application-registrations';
import { AddStatusesToBillingSubscriptionIndexSlowInstanceCommand } from './2-23/2-23-instance-command-slow-1784650048045-add-statuses-to-billing-subscription-index';
import { AddOnConnectLogicFunctionToConnectionProviderFastInstanceCommand } from './2-24/2-24-instance-command-fast-1784712843602-add-on-connect-logic-function-to-connection-provider';
export const INSTANCE_COMMANDS = [
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
@@ -242,4 +243,5 @@ export const INSTANCE_COMMANDS = [
AddApplicationIdToKeyValuePairFastInstanceCommand,
AddSdkClientCoreChecksumToApplicationFastInstanceCommand,
AddStatusesToBillingSubscriptionIndexSlowInstanceCommand,
AddOnConnectLogicFunctionToConnectionProviderFastInstanceCommand,
];
@@ -50,11 +50,32 @@ describe('fromConnectionProviderManifestToUniversalFlatConnectionProvider', () =
tokenRequestContentType: 'json',
usePkce: true,
},
onConnectLogicFunctionUniversalIdentifier: null,
createdAt: NOW,
updatedAt: NOW,
});
});
it('resolves the onConnectLogicFunction universalIdentifier into the flat field when provided', () => {
const onConnectLogicFunctionUniversalIdentifier =
'c1c1c1c1-c1c1-4c1c-c1c1-c1c1c1c1c1c1';
const result =
fromConnectionProviderManifestToUniversalFlatConnectionProvider({
connectionProviderManifest: buildManifest({
onConnectLogicFunction: {
universalIdentifier: onConnectLogicFunctionUniversalIdentifier,
},
}),
applicationUniversalIdentifier: APP_UID,
now: NOW,
});
expect(result.onConnectLogicFunctionUniversalIdentifier).toBe(
onConnectLogicFunctionUniversalIdentifier,
);
});
it('passes through optional oauth config when provided', () => {
const result =
fromConnectionProviderManifestToUniversalFlatConnectionProvider({
@@ -43,6 +43,9 @@ export const fromConnectionProviderManifestToUniversalFlatConnectionProvider =
displayName: connectionProviderManifest.displayName,
type: connectionProviderManifest.type,
oauthConfig,
onConnectLogicFunctionUniversalIdentifier:
connectionProviderManifest.onConnectLogicFunction
?.universalIdentifier ?? null,
createdAt: now,
updatedAt: now,
};
@@ -19,12 +19,16 @@ import { type ConnectionProviderEntity } from 'src/engine/core-modules/applicati
import { ConnectionProviderOAuthFlowService } from 'src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service';
import { ConnectionProviderService } from 'src/engine/core-modules/application/connection-provider/connection-provider.service';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { getQueueToken } from 'src/engine/core-modules/message-queue/utils/get-queue-token.util';
import { SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX } from 'src/engine/core-modules/secret-encryption/constants/secret-encryption.constant';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
const FAKE_CIPHER_PREFIX = `${SECRET_ENCRYPTION_ENVELOPE_V2_PREFIX}keyid:`;
@@ -47,6 +51,9 @@ describe('ConnectionProviderOAuthFlowService', () => {
findOne: jest.Mock;
findOneByOrFail: jest.Mock;
};
let workspaceCacheService: { getOrRecompute: jest.Mock };
let messageQueueService: { add: jest.Mock };
let exceptionHandlerService: { captureExceptions: jest.Mock };
const baseProvider: ConnectionProviderEntity = {
id: 'provider-1',
@@ -95,6 +102,13 @@ describe('ConnectionProviderOAuthFlowService', () => {
provider: ConnectedAccountProvider.APP,
})),
};
workspaceCacheService = {
getOrRecompute: jest.fn(async () => ({
flatLogicFunctionMaps: { byUniversalIdentifier: {} },
})),
};
messageQueueService = { add: jest.fn() };
exceptionHandlerService = { captureExceptions: jest.fn() };
const module: TestingModule = await Test.createTestingModule({
providers: [
@@ -113,6 +127,18 @@ describe('ConnectionProviderOAuthFlowService', () => {
provide: getRepositoryToken(ConnectedAccountEntity),
useValue: connectedAccountRepository,
},
{
provide: WorkspaceCacheService,
useValue: workspaceCacheService,
},
{
provide: getQueueToken(MessageQueue.logicFunctionQueue),
useValue: messageQueueService,
},
{
provide: ExceptionHandlerService,
useValue: exceptionHandlerService,
},
{
// Real prefix/round-trip behavior is asserted in
// connected-account-token-encryption.service.spec.ts; here we use a
@@ -437,5 +463,107 @@ describe('ConnectionProviderOAuthFlowService', () => {
}),
).rejects.toThrow(/state/);
});
describe('on-connect hook', () => {
const ON_CONNECT_UID = 'c1c1c1c1-c1c1-4c1c-c1c1-c1c1c1c1c1c1';
it('does not dispatch a hook when the provider declares none', async () => {
await service.completeAuthorizationFlow({
code: 'auth_code',
state: 'signed-state',
});
expect(workspaceCacheService.getOrRecompute).not.toHaveBeenCalled();
expect(messageQueueService.add).not.toHaveBeenCalled();
});
it('enqueues the declared on-connect logic function in the connecting workspace', async () => {
connectionProviderService.findOneByIdOrThrow.mockResolvedValue({
...baseProvider,
onConnectLogicFunctionUniversalIdentifier: ON_CONNECT_UID,
});
workspaceCacheService.getOrRecompute.mockResolvedValue({
flatLogicFunctionMaps: {
byUniversalIdentifier: {
[ON_CONNECT_UID]: { id: 'logic-function-1' },
},
},
});
const result = await service.completeAuthorizationFlow({
code: 'auth_code',
state: 'signed-state',
});
expect(workspaceCacheService.getOrRecompute).toHaveBeenCalledWith(
'workspace-1',
['flatLogicFunctionMaps'],
);
expect(messageQueueService.add).toHaveBeenCalledWith(
'LogicFunctionTriggerJob',
[
{
logicFunctionId: 'logic-function-1',
workspaceId: 'workspace-1',
payload: {
connectionProviderId: 'provider-1',
connectionProviderName: 'linear',
connectedAccountId: result.connectedAccountId,
},
},
],
{ retryLimit: 3 },
);
});
it('reports to Sentry without failing the connection when the hook function is missing', async () => {
connectionProviderService.findOneByIdOrThrow.mockResolvedValue({
...baseProvider,
onConnectLogicFunctionUniversalIdentifier: ON_CONNECT_UID,
});
workspaceCacheService.getOrRecompute.mockResolvedValue({
flatLogicFunctionMaps: { byUniversalIdentifier: {} },
});
const result = await service.completeAuthorizationFlow({
code: 'auth_code',
state: 'signed-state',
});
expect(result.connectedAccountId).toBe('new-account-id');
expect(messageQueueService.add).not.toHaveBeenCalled();
expect(exceptionHandlerService.captureExceptions).toHaveBeenCalledTimes(
1,
);
});
it('treats a soft-deleted hook function as missing instead of enqueuing it', async () => {
connectionProviderService.findOneByIdOrThrow.mockResolvedValue({
...baseProvider,
onConnectLogicFunctionUniversalIdentifier: ON_CONNECT_UID,
});
workspaceCacheService.getOrRecompute.mockResolvedValue({
flatLogicFunctionMaps: {
byUniversalIdentifier: {
[ON_CONNECT_UID]: {
id: 'logic-function-1',
deletedAt: new Date().toISOString(),
},
},
},
});
const result = await service.completeAuthorizationFlow({
code: 'auth_code',
state: 'signed-state',
});
expect(result.connectedAccountId).toBe('new-account-id');
expect(messageQueueService.add).not.toHaveBeenCalled();
expect(exceptionHandlerService.captureExceptions).toHaveBeenCalledTimes(
1,
);
});
});
});
});
@@ -9,4 +9,5 @@ export enum ConnectionProviderExceptionCode {
INVALID_CONNECTION_PROVIDER_INPUT = 'INVALID_CONNECTION_PROVIDER_INPUT',
CONNECTION_PROVIDER_NOT_FOUND = 'CONNECTION_PROVIDER_NOT_FOUND',
CONNECTION_PROVIDER_NAME_ALREADY_EXISTS = 'CONNECTION_PROVIDER_NAME_ALREADY_EXISTS',
ON_CONNECT_LOGIC_FUNCTION_NOT_FOUND = 'ON_CONNECT_LOGIC_FUNCTION_NOT_FOUND',
}
@@ -21,11 +21,20 @@ import { exchangeCodeForToken } from 'src/engine/core-modules/application/connec
import { generatePkceVerifier } from 'src/engine/core-modules/application/connection-provider/utils/generate-pkce-verifier.util';
import { type AppOAuthStateJwtPayload } from 'src/engine/core-modules/auth/types/app-oauth-state-jwt-payload.type';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/jwt-token-type.enum';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import {
LogicFunctionTriggerJob,
type LogicFunctionTriggerJobData,
} from 'src/engine/core-modules/logic-function/logic-function-trigger/jobs/logic-function-trigger.job';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { ConnectedAccountTokenEncryptionService } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
const STATE_JWT_EXPIRES_IN = '10m';
@@ -63,6 +72,10 @@ export class ConnectionProviderOAuthFlowService {
private readonly connectedAccountTokenEncryptionService: ConnectedAccountTokenEncryptionService,
@InjectRepository(ConnectedAccountEntity)
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
@InjectMessageQueue(MessageQueue.logicFunctionQueue)
private readonly messageQueueService: MessageQueueService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly exceptionHandlerService: ExceptionHandlerService,
) {}
async startAuthorizationFlow(
@@ -186,6 +199,12 @@ export class ConnectionProviderOAuthFlowService {
statePayload.reconnectingConnectedAccountId,
});
await this.dispatchOnConnectHook({
provider,
workspaceId: statePayload.workspaceId,
connectedAccountId: connectedAccount.id,
});
return {
connectedAccountId: connectedAccount.id,
workspaceId: statePayload.workspaceId,
@@ -194,6 +213,67 @@ export class ConnectionProviderOAuthFlowService {
};
}
private async dispatchOnConnectHook({
provider,
workspaceId,
connectedAccountId,
}: {
provider: OAuthConnectionProvider;
workspaceId: string;
connectedAccountId: string;
}): Promise<void> {
const { onConnectLogicFunctionUniversalIdentifier } = provider;
if (!isDefined(onConnectLogicFunctionUniversalIdentifier)) {
return;
}
// The on-connect hook is best-effort: the ConnectedAccount is already
// persisted, so a misconfigured or failing hook must not break the OAuth
// callback. We still report failures to Sentry so they don't go unnoticed.
try {
const { flatLogicFunctionMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatLogicFunctionMaps',
]);
const flatLogicFunction =
flatLogicFunctionMaps.byUniversalIdentifier[
onConnectLogicFunctionUniversalIdentifier
];
if (
!isDefined(flatLogicFunction) ||
isDefined(flatLogicFunction.deletedAt)
) {
throw new ConnectionProviderException(
`Connection provider ${provider.id} references on-connect logic function ${onConnectLogicFunctionUniversalIdentifier}, which was not found in workspace ${workspaceId}.`,
ConnectionProviderExceptionCode.ON_CONNECT_LOGIC_FUNCTION_NOT_FOUND,
);
}
await this.messageQueueService.add<LogicFunctionTriggerJobData[]>(
LogicFunctionTriggerJob.name,
[
{
logicFunctionId: flatLogicFunction.id,
workspaceId,
payload: {
connectionProviderId: provider.id,
connectionProviderName: provider.name,
connectedAccountId,
},
},
],
{ retryLimit: 3 },
);
} catch (error) {
this.exceptionHandlerService.captureExceptions([error], {
workspace: { id: workspaceId },
});
}
}
private async signState(payload: AppOAuthStateJwtPayload): Promise<string> {
return this.jwtWrapperService.signAsyncOrThrow(payload, {
expiresIn: STATE_JWT_EXPIRES_IN,
@@ -12,6 +12,7 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
@Entity({ name: 'connectionProvider', schema: 'core' })
@@ -39,6 +40,13 @@ export class ConnectionProviderEntity
@Column({ nullable: true, type: 'jsonb' })
oauthConfig: StoredOAuthConnectionProviderConfig | null;
@Column({ nullable: true, type: 'uuid' })
@WasIntroducedInUpgrade({
upgradeCommandName:
'2.24.0_AddOnConnectLogicFunctionToConnectionProviderFastInstanceCommand_1784712843602',
})
onConnectLogicFunctionUniversalIdentifier: string | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@@ -29,6 +29,8 @@ const getConnectionProviderExceptionUserFriendlyMessage = (
return msg`Connection provider not found.`;
case ConnectionProviderExceptionCode.CONNECTION_PROVIDER_NAME_ALREADY_EXISTS:
return msg`A connection provider with this name already exists for this application.`;
case ConnectionProviderExceptionCode.ON_CONNECT_LOGIC_FUNCTION_NOT_FOUND:
return msg`The logic function to run on connect was not found.`;
default:
assertUnreachable(code);
}
@@ -14,6 +14,7 @@ import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { ConnectedAccountTokenEncryptionModule } from 'src/engine/metadata-modules/connected-account/services/connected-account-token-encryption.module';
import { FlatConnectionProviderModule } from 'src/engine/metadata-modules/flat-connection-provider/flat-connection-provider.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@Module({
imports: [
@@ -29,6 +30,7 @@ import { FlatConnectionProviderModule } from 'src/engine/metadata-modules/flat-c
TwentyConfigModule,
FlatConnectionProviderModule,
ConnectedAccountTokenEncryptionModule,
WorkspaceCacheModule,
],
providers: [
ConnectionProviderService,
@@ -60,6 +60,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
"displayName",
"type",
"oauthConfig",
"onConnectLogicFunctionUniversalIdentifier",
],
"propertiesToStringify": [
"oauthConfig",
@@ -1802,6 +1802,11 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
toStringify: true,
universalProperty: undefined,
},
onConnectLogicFunctionUniversalIdentifier: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
},
createdAt: {
toCompare: false,
toStringify: false,
@@ -83,6 +83,7 @@ describe('Manifest update - connection providers', () => {
tokenRequestContentType: 'json',
usePkce: true,
},
onConnectLogicFunctionUniversalIdentifier: null,
});
}, 60000);
@@ -9,6 +9,7 @@ type ConnectionProviderRow = {
displayName: string;
type: string;
oauthConfig: StoredOAuthConnectionProviderConfig | null;
onConnectLogicFunctionUniversalIdentifier: string | null;
};
export const findConnectionProvidersByApplication = async (
@@ -17,7 +18,7 @@ export const findConnectionProvidersByApplication = async (
return globalThis.testDataSource.query(
`SELECT cp.id, cp."universalIdentifier", cp."applicationId",
cp."workspaceId", cp.name, cp."displayName", cp.type,
cp."oauthConfig"
cp."oauthConfig", cp."onConnectLogicFunctionUniversalIdentifier"
FROM core."connectionProvider" cp
JOIN core."application" app ON app.id = cp."applicationId"
WHERE app."universalIdentifier" = $1
@@ -6,4 +6,5 @@ export type ConnectionProviderManifest = SyncableEntityOptions & {
displayName: string;
type: 'oauth';
oauth: OAuthConnectionProviderConfig;
onConnectLogicFunction?: SyncableEntityOptions;
};