feat(connections): add an onDisconnect lifecycle hook to connection providers (#23538)

Platform half of the follow-up to
https://github.com/twentyhq/twenty/pull/22984#discussion_r3673946334.
The Slack app claims a `team_id` on connect and had no way to release
it, because connection providers only had an on-connect hook. Nothing
here is Slack-specific, so it targets `main`. The app side is #23540, on
top of `feat/slack-bot`, and waits on this plus an SDK release.

## What changes

`defineConnectionProvider` accepts `onDisconnectLogicFunction` alongside
`onConnectLogicFunction`. It is stored on
`connectionProvider.onDisconnectLogicFunctionUniversalIdentifier` (fast
instance command `2.26.0_...1785350000000`) and enqueued right after the
`ConnectedAccount` row is deleted, in the disconnecting workspace, with
the same payload as on-connect:

```ts
type OnDisconnectPayload = {
  connectionProviderId: string;
  connectionProviderName: string;
  connectedAccountId: string;
};
```

The `ConnectedAccount` is gone by the time the hook runs, so
`getConnection` no longer resolves. Anything the cleanup needs has to be
in the key-value store, written at connect time and keyed by
`connectedAccountId`. The docs section spells that out, along with the
fact that uninstalling an app drops its connections through a cascade
that never reaches this hook, where `uninstallLogicFunction` is the
right tool instead.

Both dispatches moved into a new
`ConnectionProviderLifecycleHookService`, so
`ConnectionProviderOAuthFlowService` no longer owns hook plumbing and
`ConnectedAccountMetadataService.delete` can reuse it. On-connect
behaviour is unchanged: best effort, never blocks the caller, failures
go to Sentry.

## Tests

- `connection-provider-lifecycle-hook.service.spec.ts`: the on-connect
cases moved over, plus on-disconnect dispatch, no-hook, and
missing-provider cases
- `connection-provider-oauth-flow.service.spec.ts`: now asserts
delegation to the lifecycle hook service
- SDK validation, manifest duplicate-identifier, and manifest to flat
converter specs extended

Server unit tests and typecheck for shared, sdk and server pass locally.
This commit is contained in:
Abdul Rahman
2026-08-04 08:17:23 +05:30
committed by GitHub
parent 5ffa121e59
commit 3a646ffcb0
24 changed files with 594 additions and 244 deletions
@@ -45,6 +45,9 @@ export default defineConnectionProvider({
// 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-...-...' },
// Optional: a logic function in this app to run right after a connection is
// removed. See "Run a logic function on disconnect".
// onDisconnectLogicFunction: { universalIdentifier: '4d5e6f70-...-...' },
});
```
@@ -124,6 +127,42 @@ From there use `getConnection(connectedAccountId)` to read the fresh access toke
</Accordion>
<Accordion title="Run a logic function on disconnect" description="Clean up when a connection is removed">
Anything an app claims at connect time has to be released when the connection goes away. A Slack integration that claims a `team_id` on connect, for instance, has to release that claim so another workspace can connect the same Slack team. Set `onDisconnectLogicFunction` to reference a logic function in the same app, and it runs right after the `ConnectedAccount` is deleted.
```ts src/connection-providers/slack-connection.ts
export default defineConnectionProvider({
universalIdentifier: '...',
name: 'slack',
displayName: 'Slack',
type: 'oauth',
oauth: {
/* ... */
},
// Runs releaseSlackTeam after every Slack disconnection.
onDisconnectLogicFunction: {
universalIdentifier: '4470aba8-5ff5-4800-88db-2a427cd8677c',
},
});
```
Like the on-connect hook it runs **asynchronously in the disconnecting workspace** and never blocks the disconnect. The handler receives the same payload shape:
```ts
type OnDisconnectPayload = {
connectionProviderId: string;
connectionProviderName: string; // e.g. 'slack'
connectedAccountId: string;
};
```
The `ConnectedAccount` is already gone when the hook runs, so `getConnection(connectedAccountId)` no longer resolves. Anything the cleanup needs (a `team_id`, an external subscription id) must have been written to the [key-value store](/developers/extend/apps/logic/key-value-store) at connect time, keyed by `connectedAccountId`.
The hook fires when a connection is removed on its own. Uninstalling the app drops its connections through a database cascade instead, so the hook does not run there. Declare an `uninstallLogicFunction` on `defineApplication` for that path: it runs before the app's metadata is deleted, so it can still call `listConnections` and clean up whatever is left.
</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.
@@ -197,36 +197,39 @@ describe('manifestValidate', () => {
);
});
it('should not flag a connection provider referencing a logic function via onConnectLogicFunction as a duplicate', () => {
const logicFunctionId = '550e8400-e29b-41d4-a716-446655440040';
it.each(['onConnectLogicFunction', 'onDisconnectLogicFunction'] as const)(
'should not flag a connection provider referencing a logic function via %s as a duplicate',
(lifecycleHookKey) => {
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 logicFunction = {
universalIdentifier: logicFunctionId,
name: lifecycleHookKey,
sourceHandlerPath: 'src/logic-functions/lifecycle-hook.ts',
builtHandlerPath: 'dist/lifecycle-hook.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 connectionProvider = {
universalIdentifier: '550e8400-e29b-41d4-a716-446655440041',
name: 'slack',
displayName: 'Slack',
type: 'oauth',
oauth: {},
[lifecycleHookKey]: { universalIdentifier: logicFunctionId },
} as unknown as NonNullable<Manifest['connectionProviders']>[number];
const result = manifestValidate({
...validManifest,
logicFunctions: [logicFunction],
connectionProviders: [connectionProvider],
});
const result = manifestValidate({
...validManifest,
logicFunctions: [logicFunction],
connectionProviders: [connectionProvider],
});
expect(result.isValid).toBe(true);
expect(result.errors).toHaveLength(0);
});
expect(result.isValid).toBe(true);
expect(result.errors).toHaveLength(0);
},
);
it('should not flag a front component referenced via settingsFrontComponent as a duplicate', () => {
const frontComponentId = '550e8400-e29b-41d4-a716-446655440050';
@@ -626,18 +629,14 @@ describe('manifestValidate', () => {
expect(result.isValid).toBe(false);
expect(result.errors).toContainEqual(
expect.stringContaining(
'not "aggregateFieldMetadataId"',
),
expect.stringContaining('not "aggregateFieldMetadataId"'),
);
});
it('should ignore non-graph widgets that have no aggregate field', () => {
const result = manifestValidate({
...validManifest,
pageLayoutTabs: [
makeGraphWidgetTab({ configurationType: 'TIMELINE' }),
],
pageLayoutTabs: [makeGraphWidgetTab({ configurationType: 'TIMELINE' })],
});
expect(result.isValid).toBe(true);
@@ -75,6 +75,7 @@ const findUniversalIdentifiers = (obj: object): string[] => {
key === 'preInstallLogicFunction' ||
key === 'uninstallLogicFunction' ||
key === 'onConnectLogicFunction' ||
key === 'onDisconnectLogicFunction' ||
key === 'settingsFrontComponent'
) {
continue;
@@ -83,10 +83,15 @@ describe('defineConnectionProvider', () => {
expect(result.success).toBe(false);
});
it('accepts a valid onConnectLogicFunction', () => {
const lifecycleHookKeys = [
'onConnectLogicFunction',
'onDisconnectLogicFunction',
] as const;
it.each(lifecycleHookKeys)('accepts a valid %s', (lifecycleHookKey) => {
const result = defineConnectionProvider({
...baseValidConfig,
onConnectLogicFunction: {
[lifecycleHookKey]: {
universalIdentifier: 'b648f87b-1d26-4961-b974-0908fd991061',
},
});
@@ -95,15 +100,18 @@ describe('defineConnectionProvider', () => {
expect(result.errors).toEqual([]);
});
it('rejects a non-UUID onConnectLogicFunction universalIdentifier', () => {
const result = defineConnectionProvider({
...baseValidConfig,
onConnectLogicFunction: { universalIdentifier: 'claim-team-id' },
});
it.each(lifecycleHookKeys)(
'rejects a non-UUID %s universalIdentifier',
(lifecycleHookKey) => {
const result = defineConnectionProvider({
...baseValidConfig,
[lifecycleHookKey]: { universalIdentifier: 'not-a-uuid' },
});
expect(result.success).toBe(false);
expect(
result.errors.some((error) => error.includes('onConnectLogicFunction')),
).toBe(true);
});
expect(result.success).toBe(false);
expect(
result.errors.some((error) => error.includes(lifecycleHookKey)),
).toBe(true);
},
);
});
@@ -1,6 +1,7 @@
import { type DefineEntity } from '@/sdk/define/common/types/define-entity.type';
import { createValidationResult } from '@/sdk/define/common/utils/create-validation-result';
import { type ConnectionProviderManifest } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
const PROVIDER_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
// Matches UUID v1v5 (and the `00000000-…` Nil UUID). Mirrors the server-side
@@ -11,6 +12,16 @@ const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const SUPPORTED_TYPES = ['oauth'] as const;
type ConnectionProviderLifecycleHookKey = Extract<
keyof ConnectionProviderManifest,
`on${string}LogicFunction`
>;
const LIFECYCLE_HOOK_KEYS = [
'onConnectLogicFunction',
'onDisconnectLogicFunction',
] as const satisfies readonly ConnectionProviderLifecycleHookKey[];
export const defineConnectionProvider: DefineEntity<
ConnectionProviderManifest
> = (config) => {
@@ -36,13 +47,14 @@ 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.`,
);
for (const hookKey of LIFECYCLE_HOOK_KEYS) {
const hook = config[hookKey];
if (isDefined(hook) && !UUID_PATTERN.test(hook.universalIdentifier)) {
errors.push(
`Connection provider ${hookKey}.universalIdentifier "${hook.universalIdentifier}" must be the UUID universalIdentifier of a logic function in this app.`,
);
}
}
if (!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.27.0', 1785810340935)
export class AddOnDisconnectLogicFunctionToConnectionProviderFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "core"."connectionProvider" ADD COLUMN IF NOT EXISTS "onDisconnectLogicFunctionUniversalIdentifier" uuid',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "core"."connectionProvider" DROP COLUMN IF EXISTS "onDisconnectLogicFunctionUniversalIdentifier"',
);
}
}
@@ -130,6 +130,7 @@ import { AddAppTokenSsoExchangeIndexFastInstanceCommand } from './2-25/2-25-inst
import { AddPageLayoutCascadeDeleteIndexesFastInstanceCommand } from './2-25/2-25-instance-command-fast-1784904030251-add-page-layout-cascade-delete-indexes';
import { AddChannelWebhookSubscriptionExternalIdIndexesFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-25/2-25-instance-command-fast-1785173910915-add-channel-webhook-subscription-external-id-indexes';
import { AddIsHiddenToAgentMessageFastInstanceCommand } from './2-25/2-25-instance-command-fast-1785230296000-add-is-hidden-to-agent-message';
import { AddOnDisconnectLogicFunctionToConnectionProviderFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-27/2-27-instance-command-fast-1785810340935-add-on-disconnect-logic-function-to-connection-provider';
import { AddConnectedAccountHandleProviderIndexFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-26/2-26-instance-command-fast-1785420705255-add-connected-account-handle-provider-index';
import { AddOpenRecordInToObjectMetadataFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-27/2-27-instance-command-fast-1785504900000-add-open-record-in-to-object-metadata';
import { CreateUserSessionCoreTableFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-27/2-27-instance-command-fast-1785518325511-create-user-session-core-table';
@@ -266,6 +267,7 @@ export const INSTANCE_COMMANDS = [
AddPageLayoutCascadeDeleteIndexesFastInstanceCommand,
AddChannelWebhookSubscriptionExternalIdIndexesFastInstanceCommand,
AddIsHiddenToAgentMessageFastInstanceCommand,
AddOnDisconnectLogicFunctionToConnectionProviderFastInstanceCommand,
AddConnectedAccountHandleProviderIndexFastInstanceCommand,
AddOpenRecordInToObjectMetadataFastInstanceCommand,
CreateUserSessionCoreTableFastInstanceCommand,
@@ -51,6 +51,7 @@ describe('fromConnectionProviderManifestToUniversalFlatConnectionProvider', () =
usePkce: true,
},
onConnectLogicFunctionUniversalIdentifier: null,
onDisconnectLogicFunctionUniversalIdentifier: null,
createdAt: NOW,
updatedAt: NOW,
});
@@ -76,6 +77,26 @@ describe('fromConnectionProviderManifestToUniversalFlatConnectionProvider', () =
);
});
it('resolves the onDisconnectLogicFunction universalIdentifier into the flat field when provided', () => {
const onDisconnectLogicFunctionUniversalIdentifier =
'd2d2d2d2-d2d2-4d2d-d2d2-d2d2d2d2d2d2';
const result =
fromConnectionProviderManifestToUniversalFlatConnectionProvider({
connectionProviderManifest: buildManifest({
onDisconnectLogicFunction: {
universalIdentifier: onDisconnectLogicFunctionUniversalIdentifier,
},
}),
applicationUniversalIdentifier: APP_UID,
now: NOW,
});
expect(result.onDisconnectLogicFunctionUniversalIdentifier).toBe(
onDisconnectLogicFunctionUniversalIdentifier,
);
});
it('passes through optional oauth config when provided', () => {
const result =
fromConnectionProviderManifestToUniversalFlatConnectionProvider({
@@ -46,6 +46,9 @@ export const fromConnectionProviderManifestToUniversalFlatConnectionProvider =
onConnectLogicFunctionUniversalIdentifier:
connectionProviderManifest.onConnectLogicFunction
?.universalIdentifier ?? null,
onDisconnectLogicFunctionUniversalIdentifier:
connectionProviderManifest.onDisconnectLogicFunction
?.universalIdentifier ?? null,
createdAt: now,
updatedAt: now,
};
@@ -0,0 +1,219 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { ConnectionProviderLifecycleHookService } from 'src/engine/core-modules/application/connection-provider/connection-provider-lifecycle-hook.service';
import { type ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
import { ConnectionProviderService } from 'src/engine/core-modules/application/connection-provider/connection-provider.service';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.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 { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
const ON_CONNECT_UID = 'c1c1c1c1-c1c1-4c1c-c1c1-c1c1c1c1c1c1';
const ON_DISCONNECT_UID = 'd2d2d2d2-d2d2-4d2d-d2d2-d2d2d2d2d2d2';
describe('ConnectionProviderLifecycleHookService', () => {
let service: ConnectionProviderLifecycleHookService;
let connectionProviderService: { findOneByIdOrThrow: jest.Mock };
let workspaceCacheService: { getOrRecompute: jest.Mock };
let messageQueueService: { add: jest.Mock };
let exceptionHandlerService: { captureExceptions: jest.Mock };
const baseProvider: Pick<
ConnectionProviderEntity,
| 'id'
| 'name'
| 'onConnectLogicFunctionUniversalIdentifier'
| 'onDisconnectLogicFunctionUniversalIdentifier'
> = {
id: 'provider-1',
name: 'linear',
onConnectLogicFunctionUniversalIdentifier: null,
onDisconnectLogicFunctionUniversalIdentifier: null,
};
beforeEach(async () => {
connectionProviderService = {
findOneByIdOrThrow: jest.fn(async () => baseProvider),
};
workspaceCacheService = {
getOrRecompute: jest.fn(async () => ({
flatLogicFunctionMaps: { byUniversalIdentifier: {} },
})),
};
messageQueueService = { add: jest.fn() };
exceptionHandlerService = { captureExceptions: jest.fn() };
const module: TestingModule = await Test.createTestingModule({
providers: [
ConnectionProviderLifecycleHookService,
{
provide: ConnectionProviderService,
useValue: connectionProviderService,
},
{ provide: WorkspaceCacheService, useValue: workspaceCacheService },
{
provide: getQueueToken(MessageQueue.logicFunctionQueue),
useValue: messageQueueService,
},
{ provide: ExceptionHandlerService, useValue: exceptionHandlerService },
],
}).compile();
service = module.get(ConnectionProviderLifecycleHookService);
});
afterEach(() => jest.clearAllMocks());
describe('dispatchOnConnect', () => {
it('does not dispatch a hook when the provider declares none', async () => {
await service.dispatchOnConnect({
provider: baseProvider,
workspaceId: 'workspace-1',
connectedAccountId: 'account-1',
});
expect(workspaceCacheService.getOrRecompute).not.toHaveBeenCalled();
expect(messageQueueService.add).not.toHaveBeenCalled();
});
it('enqueues the declared on-connect logic function in the connecting workspace', async () => {
workspaceCacheService.getOrRecompute.mockResolvedValue({
flatLogicFunctionMaps: {
byUniversalIdentifier: { [ON_CONNECT_UID]: { id: 'function-1' } },
},
});
await service.dispatchOnConnect({
provider: {
...baseProvider,
onConnectLogicFunctionUniversalIdentifier: ON_CONNECT_UID,
},
workspaceId: 'workspace-1',
connectedAccountId: 'account-1',
});
expect(workspaceCacheService.getOrRecompute).toHaveBeenCalledWith(
'workspace-1',
['flatLogicFunctionMaps'],
);
expect(messageQueueService.add).toHaveBeenCalledWith(
'LogicFunctionTriggerJob',
{
logicFunctionId: 'function-1',
workspaceId: 'workspace-1',
payload: {
connectionProviderId: 'provider-1',
connectionProviderName: 'linear',
connectedAccountId: 'account-1',
},
},
{ retryLimit: 3 },
);
});
it('reports to Sentry without throwing when the hook function is missing', async () => {
await service.dispatchOnConnect({
provider: {
...baseProvider,
onConnectLogicFunctionUniversalIdentifier: ON_CONNECT_UID,
},
workspaceId: 'workspace-1',
connectedAccountId: 'account-1',
});
expect(messageQueueService.add).not.toHaveBeenCalled();
expect(exceptionHandlerService.captureExceptions).toHaveBeenCalledTimes(
1,
);
});
it('treats a soft-deleted hook function as missing instead of enqueuing it', async () => {
workspaceCacheService.getOrRecompute.mockResolvedValue({
flatLogicFunctionMaps: {
byUniversalIdentifier: {
[ON_CONNECT_UID]: {
id: 'function-1',
deletedAt: new Date().toISOString(),
},
},
},
});
await service.dispatchOnConnect({
provider: {
...baseProvider,
onConnectLogicFunctionUniversalIdentifier: ON_CONNECT_UID,
},
workspaceId: 'workspace-1',
connectedAccountId: 'account-1',
});
expect(messageQueueService.add).not.toHaveBeenCalled();
expect(exceptionHandlerService.captureExceptions).toHaveBeenCalledTimes(
1,
);
});
});
describe('dispatchOnDisconnect', () => {
it('does not dispatch a hook when the provider declares none', async () => {
await service.dispatchOnDisconnect({
connectionProviderId: 'provider-1',
workspaceId: 'workspace-1',
connectedAccountId: 'account-1',
});
expect(messageQueueService.add).not.toHaveBeenCalled();
expect(exceptionHandlerService.captureExceptions).not.toHaveBeenCalled();
});
it('enqueues the declared on-disconnect logic function in the disconnecting workspace', async () => {
connectionProviderService.findOneByIdOrThrow.mockResolvedValue({
...baseProvider,
onDisconnectLogicFunctionUniversalIdentifier: ON_DISCONNECT_UID,
});
workspaceCacheService.getOrRecompute.mockResolvedValue({
flatLogicFunctionMaps: {
byUniversalIdentifier: { [ON_DISCONNECT_UID]: { id: 'function-2' } },
},
});
await service.dispatchOnDisconnect({
connectionProviderId: 'provider-1',
workspaceId: 'workspace-1',
connectedAccountId: 'account-1',
});
expect(messageQueueService.add).toHaveBeenCalledWith(
'LogicFunctionTriggerJob',
{
logicFunctionId: 'function-2',
workspaceId: 'workspace-1',
payload: {
connectionProviderId: 'provider-1',
connectionProviderName: 'linear',
connectedAccountId: 'account-1',
},
},
{ retryLimit: 3 },
);
});
it('reports to Sentry without throwing when the provider no longer exists', async () => {
connectionProviderService.findOneByIdOrThrow.mockRejectedValue(
new Error('Provider not found'),
);
await service.dispatchOnDisconnect({
connectionProviderId: 'provider-1',
workspaceId: 'workspace-1',
connectedAccountId: 'account-1',
});
expect(messageQueueService.add).not.toHaveBeenCalled();
expect(exceptionHandlerService.captureExceptions).toHaveBeenCalledTimes(
1,
);
});
});
});
@@ -16,19 +16,16 @@ import { ConnectedAccountProvider } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
import { ConnectionProviderLifecycleHookService } from 'src/engine/core-modules/application/connection-provider/connection-provider-lifecycle-hook.service';
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:`;
@@ -51,9 +48,7 @@ describe('ConnectionProviderOAuthFlowService', () => {
findOne: jest.Mock;
findOneByOrFail: jest.Mock;
};
let workspaceCacheService: { getOrRecompute: jest.Mock };
let messageQueueService: { add: jest.Mock };
let exceptionHandlerService: { captureExceptions: jest.Mock };
let connectionProviderLifecycleHookService: { dispatchOnConnect: jest.Mock };
const baseProvider: ConnectionProviderEntity = {
id: 'provider-1',
@@ -102,13 +97,9 @@ describe('ConnectionProviderOAuthFlowService', () => {
provider: ConnectedAccountProvider.APP,
})),
};
workspaceCacheService = {
getOrRecompute: jest.fn(async () => ({
flatLogicFunctionMaps: { byUniversalIdentifier: {} },
})),
connectionProviderLifecycleHookService = {
dispatchOnConnect: jest.fn(),
};
messageQueueService = { add: jest.fn() };
exceptionHandlerService = { captureExceptions: jest.fn() };
const module: TestingModule = await Test.createTestingModule({
providers: [
@@ -128,16 +119,8 @@ describe('ConnectionProviderOAuthFlowService', () => {
useValue: connectedAccountRepository,
},
{
provide: WorkspaceCacheService,
useValue: workspaceCacheService,
},
{
provide: getQueueToken(MessageQueue.logicFunctionQueue),
useValue: messageQueueService,
},
{
provide: ExceptionHandlerService,
useValue: exceptionHandlerService,
provide: ConnectionProviderLifecycleHookService,
useValue: connectionProviderLifecycleHookService,
},
{
// Real prefix/round-trip behavior is asserted in
@@ -464,103 +447,18 @@ 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('hands the created connection to the on-connect lifecycle hook', async () => {
const result = await service.completeAuthorizationFlow({
code: 'auth_code',
state: 'signed-state',
});
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,
);
expect(
connectionProviderLifecycleHookService.dispatchOnConnect,
).toHaveBeenCalledWith({
provider: baseProvider,
workspaceId: 'workspace-1',
connectedAccountId: result.connectedAccountId,
});
});
});
@@ -10,4 +10,5 @@ export enum ConnectionProviderExceptionCode {
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',
ON_DISCONNECT_LOGIC_FUNCTION_NOT_FOUND = 'ON_DISCONNECT_LOGIC_FUNCTION_NOT_FOUND',
}
@@ -0,0 +1,157 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { ConnectionProviderExceptionCode } from 'src/engine/core-modules/application/connection-provider/connection-provider-exception-code.enum';
import { type ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
import { ConnectionProviderException } from 'src/engine/core-modules/application/connection-provider/connection-provider.exception';
import { ConnectionProviderService } from 'src/engine/core-modules/application/connection-provider/connection-provider.service';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.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 { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
type ConnectionLifecycleHook = 'onConnect' | 'onDisconnect';
const MISSING_LOGIC_FUNCTION_EXCEPTION_CODE_BY_HOOK: Record<
ConnectionLifecycleHook,
ConnectionProviderExceptionCode
> = {
onConnect:
ConnectionProviderExceptionCode.ON_CONNECT_LOGIC_FUNCTION_NOT_FOUND,
onDisconnect:
ConnectionProviderExceptionCode.ON_DISCONNECT_LOGIC_FUNCTION_NOT_FOUND,
};
@Injectable()
export class ConnectionProviderLifecycleHookService {
constructor(
private readonly connectionProviderService: ConnectionProviderService,
@InjectMessageQueue(MessageQueue.logicFunctionQueue)
private readonly messageQueueService: MessageQueueService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly exceptionHandlerService: ExceptionHandlerService,
) {}
async dispatchOnConnect({
provider,
workspaceId,
connectedAccountId,
}: {
provider: Pick<
ConnectionProviderEntity,
'id' | 'name' | 'onConnectLogicFunctionUniversalIdentifier'
>;
workspaceId: string;
connectedAccountId: string;
}): Promise<void> {
await this.captureFailures(workspaceId, () =>
this.enqueueLogicFunction({
hook: 'onConnect',
logicFunctionUniversalIdentifier:
provider.onConnectLogicFunctionUniversalIdentifier,
provider,
workspaceId,
connectedAccountId,
}),
);
}
async dispatchOnDisconnect({
connectionProviderId,
workspaceId,
connectedAccountId,
}: {
connectionProviderId: string;
workspaceId: string;
connectedAccountId: string;
}): Promise<void> {
await this.captureFailures(workspaceId, async () => {
const provider =
await this.connectionProviderService.findOneByIdOrThrow(
connectionProviderId,
);
await this.enqueueLogicFunction({
hook: 'onDisconnect',
logicFunctionUniversalIdentifier:
provider.onDisconnectLogicFunctionUniversalIdentifier,
provider,
workspaceId,
connectedAccountId,
});
});
}
// Lifecycle hooks are best effort: a failing hook must never surface to the
// user connecting or disconnecting their account.
private async captureFailures(
workspaceId: string,
dispatch: () => Promise<void>,
): Promise<void> {
try {
await dispatch();
} catch (error) {
this.exceptionHandlerService.captureExceptions([error], {
workspace: { id: workspaceId },
});
}
}
private async enqueueLogicFunction({
hook,
logicFunctionUniversalIdentifier,
provider,
workspaceId,
connectedAccountId,
}: {
hook: ConnectionLifecycleHook;
logicFunctionUniversalIdentifier: string | null;
provider: Pick<ConnectionProviderEntity, 'id' | 'name'>;
workspaceId: string;
connectedAccountId: string;
}): Promise<void> {
if (!isDefined(logicFunctionUniversalIdentifier)) {
return;
}
const { flatLogicFunctionMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatLogicFunctionMaps',
]);
const flatLogicFunction =
flatLogicFunctionMaps.byUniversalIdentifier[
logicFunctionUniversalIdentifier
];
if (
!isDefined(flatLogicFunction) ||
isDefined(flatLogicFunction.deletedAt)
) {
throw new ConnectionProviderException(
`Connection provider ${provider.id} references ${hook} logic function ${logicFunctionUniversalIdentifier}, which was not found in workspace ${workspaceId}.`,
MISSING_LOGIC_FUNCTION_EXCEPTION_CODE_BY_HOOK[hook],
);
}
await this.messageQueueService.add<LogicFunctionTriggerJobData>(
LogicFunctionTriggerJob.name,
{
logicFunctionId: flatLogicFunction.id,
workspaceId,
payload: {
connectionProviderId: provider.id,
connectionProviderName: provider.name,
connectedAccountId,
},
},
{ retryLimit: 3 },
);
}
}
@@ -7,6 +7,7 @@ import { ConnectedAccountProvider } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { ConnectionProviderExceptionCode } from 'src/engine/core-modules/application/connection-provider/connection-provider-exception-code.enum';
import { ConnectionProviderLifecycleHookService } from 'src/engine/core-modules/application/connection-provider/connection-provider-lifecycle-hook.service';
import { type ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
import { ConnectionProviderException } from 'src/engine/core-modules/application/connection-provider/connection-provider.exception';
import { ConnectionProviderService } from 'src/engine/core-modules/application/connection-provider/connection-provider.service';
@@ -21,20 +22,11 @@ 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';
@@ -70,12 +62,9 @@ export class ConnectionProviderOAuthFlowService {
private readonly secureHttpClientService: SecureHttpClientService,
private readonly twentyConfigService: TwentyConfigService,
private readonly connectedAccountTokenEncryptionService: ConnectedAccountTokenEncryptionService,
private readonly connectionProviderLifecycleHookService: ConnectionProviderLifecycleHookService,
@InjectRepository(ConnectedAccountEntity)
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
@InjectMessageQueue(MessageQueue.logicFunctionQueue)
private readonly messageQueueService: MessageQueueService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly exceptionHandlerService: ExceptionHandlerService,
) {}
async startAuthorizationFlow(
@@ -199,7 +188,7 @@ export class ConnectionProviderOAuthFlowService {
statePayload.reconnectingConnectedAccountId,
});
await this.dispatchOnConnectHook({
await this.connectionProviderLifecycleHookService.dispatchOnConnect({
provider,
workspaceId: statePayload.workspaceId,
connectedAccountId: connectedAccount.id,
@@ -213,65 +202,6 @@ 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,
@@ -47,6 +47,13 @@ export class ConnectionProviderEntity
})
onConnectLogicFunctionUniversalIdentifier: string | null;
@Column({ nullable: true, type: 'uuid' })
@WasIntroducedInUpgrade({
upgradeCommandName:
'2.27.0_AddOnDisconnectLogicFunctionToConnectionProviderFastInstanceCommand_1785810340935',
})
onDisconnectLogicFunctionUniversalIdentifier: string | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@@ -31,6 +31,8 @@ const getConnectionProviderExceptionUserFriendlyMessage = (
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.`;
case ConnectionProviderExceptionCode.ON_DISCONNECT_LOGIC_FUNCTION_NOT_FOUND:
return msg`The logic function to run on disconnect was not found.`;
default:
assertUnreachable(code);
}
@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationConnectionProviderResolver } from 'src/engine/core-modules/application/connection-provider/application-connection-provider.resolver';
import { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
import { ConnectionProviderLifecycleHookService } from 'src/engine/core-modules/application/connection-provider/connection-provider-lifecycle-hook.service';
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 { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
@@ -35,8 +36,13 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
providers: [
ConnectionProviderService,
ConnectionProviderOAuthFlowService,
ConnectionProviderLifecycleHookService,
ApplicationConnectionProviderResolver,
],
exports: [ConnectionProviderService, ConnectionProviderOAuthFlowService],
exports: [
ConnectionProviderService,
ConnectionProviderOAuthFlowService,
ConnectionProviderLifecycleHookService,
],
})
export class ConnectionProviderModule {}
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConnectionProviderModule } from 'src/engine/core-modules/application/connection-provider/connection-provider.module';
import { AppOAuthRefreshModule } from 'src/engine/core-modules/application/connection-provider/refresh/app-oauth-refresh.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
@@ -21,6 +22,7 @@ import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/
MessageChannelEntity,
]),
AppOAuthRefreshModule,
ConnectionProviderModule,
FeatureFlagModule,
PermissionsModule,
WorkspaceEventEmitterModule,
@@ -3,6 +3,9 @@ import { InjectRepository } from '@nestjs/typeorm';
import { In, IsNull, Repository } from 'typeorm';
import { isDefined } from 'twenty-shared/utils';
import { ConnectionProviderLifecycleHookService } from 'src/engine/core-modules/application/connection-provider/connection-provider-lifecycle-hook.service';
import { AppOAuthRevokeService } from 'src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-revoke.service';
import { CALENDAR_CHANNEL_DELETED_EVENT } from 'src/engine/metadata-modules/calendar-channel/constants/calendar-channel-deleted.constant';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
@@ -31,6 +34,7 @@ export class ConnectedAccountMetadataService {
@InjectRepository(MessageChannelEntity)
private readonly messageChannelRepository: Repository<MessageChannelEntity>,
private readonly appOAuthRevokeService: AppOAuthRevokeService,
private readonly connectionProviderLifecycleHookService: ConnectionProviderLifecycleHookService,
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
) {}
@@ -246,6 +250,14 @@ export class ConnectedAccountMetadataService {
await this.repository.delete({ id, workspaceId });
if (isDefined(connectedAccount.connectionProviderId)) {
await this.connectionProviderLifecycleHookService.dispatchOnDisconnect({
connectionProviderId: connectedAccount.connectionProviderId,
workspaceId,
connectedAccountId: id,
});
}
this.workspaceEventEmitter.emitCustomBatchEvent<MessageChannelDeletedEvent>(
MESSAGE_CHANNEL_DELETED_EVENT,
messageChannels.map((messageChannel) => ({
@@ -61,6 +61,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
"type",
"oauthConfig",
"onConnectLogicFunctionUniversalIdentifier",
"onDisconnectLogicFunctionUniversalIdentifier",
],
"propertiesToStringify": [
"oauthConfig",
@@ -1813,6 +1813,11 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
toStringify: false,
universalProperty: undefined,
},
onDisconnectLogicFunctionUniversalIdentifier: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
},
createdAt: {
toCompare: false,
toStringify: false,
@@ -84,6 +84,7 @@ describe('Manifest update - connection providers', () => {
usePkce: true,
},
onConnectLogicFunctionUniversalIdentifier: null,
onDisconnectLogicFunctionUniversalIdentifier: null,
});
}, 60000);
@@ -10,6 +10,7 @@ type ConnectionProviderRow = {
type: string;
oauthConfig: StoredOAuthConnectionProviderConfig | null;
onConnectLogicFunctionUniversalIdentifier: string | null;
onDisconnectLogicFunctionUniversalIdentifier: string | null;
};
export const findConnectionProvidersByApplication = async (
@@ -18,7 +19,8 @@ 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."onConnectLogicFunctionUniversalIdentifier"
cp."oauthConfig", cp."onConnectLogicFunctionUniversalIdentifier",
cp."onDisconnectLogicFunctionUniversalIdentifier"
FROM core."connectionProvider" cp
JOIN core."application" app ON app.id = cp."applicationId"
WHERE app."universalIdentifier" = $1
@@ -7,4 +7,5 @@ export type ConnectionProviderManifest = SyncableEntityOptions & {
type: 'oauth';
oauth: OAuthConnectionProviderConfig;
onConnectLogicFunction?: SyncableEntityOptions;
onDisconnectLogicFunction?: SyncableEntityOptions;
};