Make ConnectionProvider a true SyncableEntity (#20232)

## Summary

PR #20181 left `ConnectionProvider` in the `SyncableEntity` enum but
bypassing the standard sync pipeline — manifest sync called the bespoke
`ApplicationOAuthProviderService.upsertManyFromManifest()` instead of
going through the workspace-migration orchestrator like every other
SyncableEntity. Anything that assumed *"all SyncableEntity values flow
through the same pipeline"* (dev UI sync tracking, verification tooling)
was wrong about ConnectionProvider — that's the inconsistency this PR
closes.

This PR follows the `.cursor/skills/syncable-entity-*` guides
religiously, all six steps.

## What changes

**Step 1 — Types & Constants** (`@syncable-entity-types-and-constants`)
- Add `connectionProvider` to `ALL_METADATA_NAME` (twenty-shared)
- Make `ApplicationOAuthProviderEntity` extend `SyncableEntity` (drops
the ad-hoc columns since the base class provides them, adds `deletedAt`,
drops the old `(applicationId, universalIdentifier)` unique in favour of
SyncableEntity's `(workspaceId, universalIdentifier)`)
- `FlatConnectionProvider`, `FlatConnectionProviderMaps`,
`FLAT_CONNECTION_PROVIDER_EDITABLE_PROPERTIES`,
`UniversalFlatConnectionProvider`, six action types
- Register in **all** the central registries:
`AllFlatEntityTypesByMetadataName`,
`ALL_METADATA_ENTITY_BY_METADATA_NAME`,
`ALL_ENTITY_PROPERTIES_CONFIGURATION`, `ALL_MANY_TO_ONE_*`,
`ALL_ONE_TO_MANY_*`, `ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION`,
`ALL_METADATA_SERIALIZED_RELATION`,
`ALL_JSONB_PROPERTIES_WITH_SERIALIZED_RELATION`,
`WORKSPACE_CACHE_KEYS_V2` (`flatConnectionProviderMaps`),
`METADATA_EVENTS_TO_EMIT`
- `case 'connectionProvider':` in seven discriminated-union switches
(`derive-metadata-events-*`, `optimistically-apply-*`,
`enrich-create-*`)

**Step 2 — Cache & Transform** (`@syncable-entity-cache-and-transform`)
- `WorkspaceFlatConnectionProviderMapCacheService` (extends
`WorkspaceCacheProvider`, decorated with `@WorkspaceCache`,
soft-delete-aware)
- `fromConnectionProviderEntityToFlatConnectionProvider` util
- `fromConnectionProviderManifestToUniversalFlatConnectionProvider` util
- `FlatConnectionProviderModule` wires the cache service
- Wired the manifest converter into
`compute-application-manifest-all-universal-flat-entity-maps`

**Step 3 — Builder & Validation**
(`@syncable-entity-builder-and-validation`)
- `FlatConnectionProviderValidatorService` — never throws, returns error
arrays; uses indexed `byUniversalIdentifier` for the (name,
applicationUniversalIdentifier) uniqueness check (no
`Object.values().find()` on the hot path)
- `WorkspaceMigrationConnectionProviderActionsBuilderService`
- Registered in both validators-module + builder-module
- **Wired into the orchestrator** (the most-commonly-forgotten step per
the rule) — constructor inject, destructure
`flatConnectionProviderMaps`, `validateAndBuild`, append actions to the
final migration

**Step 4 — Runner & Actions** (`@syncable-entity-runner-and-actions`)
- Three handlers (create / update / delete) using the canonical
`WorkspaceMigrationRunnerActionHandler` mixin
- Registered in `WorkspaceSchemaMigrationRunnerActionHandlersModule`

**Step 5 — Integration** (`@syncable-entity-integration`)
- Delete the `upsertManyFromManifest` bypass on
`ApplicationOAuthProviderService`
- Remove the bypass call from `ApplicationSyncService` — manifest sync
now flows through the standard pipeline
- Drop `ApplicationOAuthProviderModule` from `ApplicationManifestModule`
(no longer needed)
- Import `FlatConnectionProviderModule` from
`ApplicationOAuthProviderModule` to keep the cache discoverable
- 3 new exception codes: `INVALID_CONNECTION_PROVIDER_INPUT`,
`CONNECTION_PROVIDER_NOT_FOUND`,
`CONNECTION_PROVIDER_NAME_ALREADY_EXISTS`

**Migration**
- Generated via `database:migrate:generate` (instance command
`1777896012579`): drops the old `(applicationId, universalIdentifier)`
unique constraint, adds `deletedAt` column, adds the `(workspaceId,
universalIdentifier)` unique index that `SyncableEntity` requires.
- Verified clean — a second `migrate:generate` pass produces zero drift.

**Step 6 — Tests** (`@syncable-entity-testing`)
- 3 new specs for the manifest converter (defaults, optional fields,
all-fields)
- All 32 existing OAuth-provider tests still pass
- ConnectionProvider has no end-user GraphQL CRUD (it's manifest-driven
only), so the GraphQL integration suite that other SyncableEntities ship
doesn't apply here

**Codegen**
- Regenerated GraphQL artifacts (twenty-front + twenty-client-sdk)
against the live schema

## Why this matters

Before:
- `ConnectionProvider` claimed to be a `SyncableEntity` (in the enum)
- But the entity didn't extend `SyncableEntity`
- And the manifest sync bypassed the standard pipeline
- → Verification tooling, dev UI sync tracking, anything iterating over
`ALL_METADATA_NAME` got inconsistent behaviour

After:
- `ConnectionProvider` is a `SyncableEntity` end-to-end
- Single sync path through the workspace-migration orchestrator (same as
`agent`, `skill`, `frontComponent`, `webhook`, …)
- One mental model

## Out of scope (deliberate)

- **Renaming the table** from `applicationOAuthProvider` to
`connectionProvider` — the `metadataName` is `connectionProvider` (what
consumers see in code); the table name is internal. A rename would
balloon this PR with mechanical churn unrelated to the sync-pipeline
wiring. Worth doing as a follow-up.
- **`applicationVariable` SyncableEntity conversion** — the other
manifest-sync holdout. Tracked in #20215.

## Test plan

- [ ] Migration up/down clean against fresh DB
- [ ] Install an app whose manifest declares connection providers —
providers appear in the workspace
- [ ] Re-deploy the app with one provider added, one removed, one
renamed → all reconciled correctly via the sync pipeline
- [ ] Verify the dev-UI sync-tracking page shows ConnectionProvider
entries the same way it shows agents/skills/etc
- [ ] OAuth flow still works (existing connections, new connections,
reconnect, list/get from SDK) — should be unchanged since the runtime
code path didn't move

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-05-05 14:04:15 +02:00
committed by GitHub
parent 59107b5b23
commit e3be1f4971
106 changed files with 2076 additions and 1746 deletions
@@ -2550,7 +2550,7 @@ type ConnectedAccountDTO {
connectionParameters: ImapSmtpCaldavConnectionParameters
lastSignedInAt: DateTime
userWorkspaceId: UUID!
applicationConnectionProviderId: UUID
connectionProviderId: UUID
applicationId: UUID
name: String
visibility: String!
@@ -2581,7 +2581,7 @@ type ConnectedAccountPublicDTO {
scopes: [String!]
lastSignedInAt: DateTime
userWorkspaceId: UUID!
applicationConnectionProviderId: UUID
connectionProviderId: UUID
applicationId: UUID
name: String
visibility: String!
@@ -2870,6 +2870,7 @@ enum AllMetadataName {
fieldPermission
frontComponent
webhook
connectionProvider
}
type MinimalObjectMetadata {
@@ -2238,7 +2238,7 @@ export interface ConnectedAccountDTO {
connectionParameters?: ImapSmtpCaldavConnectionParameters
lastSignedInAt?: Scalars['DateTime']
userWorkspaceId: Scalars['UUID']
applicationConnectionProviderId?: Scalars['UUID']
connectionProviderId?: Scalars['UUID']
applicationId?: Scalars['UUID']
name?: Scalars['String']
visibility: Scalars['String']
@@ -2272,7 +2272,7 @@ export interface ConnectedAccountPublicDTO {
scopes?: Scalars['String'][]
lastSignedInAt?: Scalars['DateTime']
userWorkspaceId: Scalars['UUID']
applicationConnectionProviderId?: Scalars['UUID']
connectionProviderId?: Scalars['UUID']
applicationId?: Scalars['UUID']
name?: Scalars['String']
visibility: Scalars['String']
@@ -2493,7 +2493,7 @@ export interface CollectionHash {
__typename: 'CollectionHash'
}
export type AllMetadataName = 'fieldMetadata' | 'objectMetadata' | 'view' | 'viewField' | 'viewFieldGroup' | 'viewGroup' | 'viewSort' | 'rowLevelPermissionPredicate' | 'rowLevelPermissionPredicateGroup' | 'viewFilterGroup' | 'index' | 'logicFunction' | 'viewFilter' | 'role' | 'roleTarget' | 'agent' | 'skill' | 'pageLayout' | 'pageLayoutWidget' | 'pageLayoutTab' | 'commandMenuItem' | 'navigationMenuItem' | 'permissionFlag' | 'objectPermission' | 'fieldPermission' | 'frontComponent' | 'webhook'
export type AllMetadataName = 'fieldMetadata' | 'objectMetadata' | 'view' | 'viewField' | 'viewFieldGroup' | 'viewGroup' | 'viewSort' | 'rowLevelPermissionPredicate' | 'rowLevelPermissionPredicateGroup' | 'viewFilterGroup' | 'index' | 'logicFunction' | 'viewFilter' | 'role' | 'roleTarget' | 'agent' | 'skill' | 'pageLayout' | 'pageLayoutWidget' | 'pageLayoutTab' | 'commandMenuItem' | 'navigationMenuItem' | 'permissionFlag' | 'objectPermission' | 'fieldPermission' | 'frontComponent' | 'webhook' | 'connectionProvider'
export interface MinimalObjectMetadata {
id: Scalars['UUID']
@@ -5250,7 +5250,7 @@ export interface ConnectedAccountDTOGenqlSelection{
connectionParameters?: ImapSmtpCaldavConnectionParametersGenqlSelection
lastSignedInAt?: boolean | number
userWorkspaceId?: boolean | number
applicationConnectionProviderId?: boolean | number
connectionProviderId?: boolean | number
applicationId?: boolean | number
name?: boolean | number
visibility?: boolean | number
@@ -5287,7 +5287,7 @@ export interface ConnectedAccountPublicDTOGenqlSelection{
scopes?: boolean | number
lastSignedInAt?: boolean | number
userWorkspaceId?: boolean | number
applicationConnectionProviderId?: boolean | number
connectionProviderId?: boolean | number
applicationId?: boolean | number
name?: boolean | number
visibility?: boolean | number
@@ -8849,7 +8849,8 @@ export const enumAllMetadataName = {
objectPermission: 'objectPermission' as const,
fieldPermission: 'fieldPermission' as const,
frontComponent: 'frontComponent' as const,
webhook: 'webhook' as const
webhook: 'webhook' as const,
connectionProvider: 'connectionProvider' as const
}
export const enumEventLogTable = {
@@ -5088,7 +5088,7 @@ export default {
"userWorkspaceId": [
3
],
"applicationConnectionProviderId": [
"connectionProviderId": [
3
],
"applicationId": [
@@ -5169,7 +5169,7 @@ export default {
"userWorkspaceId": [
3
],
"applicationConnectionProviderId": [
"connectionProviderId": [
3
],
"applicationId": [
@@ -186,6 +186,7 @@ export type AiSystemPromptSection = {
export enum AllMetadataName {
agent = 'agent',
commandMenuItem = 'commandMenuItem',
connectionProvider = 'connectionProvider',
fieldMetadata = 'fieldMetadata',
fieldPermission = 'fieldPermission',
frontComponent = 'frontComponent',
@@ -896,10 +897,10 @@ export type CommandMenuItemPayload = ObjectMetadataCommandMenuItemPayload | Path
export type ConnectedAccountDto = {
__typename?: 'ConnectedAccountDTO';
applicationConnectionProviderId?: Maybe<Scalars['UUID']>;
applicationId?: Maybe<Scalars['UUID']>;
authFailedAt?: Maybe<Scalars['DateTime']>;
connectionParameters?: Maybe<ImapSmtpCaldavConnectionParameters>;
connectionProviderId?: Maybe<Scalars['UUID']>;
createdAt: Scalars['DateTime'];
handle: Scalars['String'];
handleAliases?: Maybe<Array<Scalars['String']>>;
@@ -916,10 +917,10 @@ export type ConnectedAccountDto = {
export type ConnectedAccountPublicDto = {
__typename?: 'ConnectedAccountPublicDTO';
applicationConnectionProviderId?: Maybe<Scalars['UUID']>;
applicationId?: Maybe<Scalars['UUID']>;
authFailedAt?: Maybe<Scalars['DateTime']>;
connectionParameters?: Maybe<PublicImapSmtpCaldavConnectionParameters>;
connectionProviderId?: Maybe<Scalars['UUID']>;
createdAt: Scalars['DateTime'];
handle: Scalars['String'];
handleAliases?: Maybe<Array<Scalars['String']>>;
@@ -6878,7 +6879,7 @@ export type MyCalendarChannelsQuery = { __typename?: 'Query', myCalendarChannels
export type MyConnectedAccountsQueryVariables = Exact<{ [key: string]: never; }>;
export type MyConnectedAccountsQuery = { __typename?: 'Query', myConnectedAccounts: Array<{ __typename?: 'ConnectedAccountDTO', id: string, handle: string, provider: string, authFailedAt?: string | null, scopes?: Array<string> | null, handleAliases?: Array<string> | null, lastSignedInAt?: string | null, userWorkspaceId: string, applicationConnectionProviderId?: string | null, name?: string | null, visibility: string, lastCredentialsRefreshedAt?: string | null, createdAt: string, updatedAt: string, connectionParameters?: { __typename?: 'ImapSmtpCaldavConnectionParameters', IMAP?: { __typename?: 'ConnectionParametersOutput', host: string, port: number, secure?: boolean | null, username?: string | null, password: string } | null, SMTP?: { __typename?: 'ConnectionParametersOutput', host: string, port: number, secure?: boolean | null, username?: string | null, password: string } | null, CALDAV?: { __typename?: 'ConnectionParametersOutput', host: string, username?: string | null, password: string } | null } | null }> };
export type MyConnectedAccountsQuery = { __typename?: 'Query', myConnectedAccounts: Array<{ __typename?: 'ConnectedAccountDTO', id: string, handle: string, provider: string, authFailedAt?: string | null, scopes?: Array<string> | null, handleAliases?: Array<string> | null, lastSignedInAt?: string | null, userWorkspaceId: string, connectionProviderId?: string | null, name?: string | null, visibility: string, lastCredentialsRefreshedAt?: string | null, createdAt: string, updatedAt: string, connectionParameters?: { __typename?: 'ImapSmtpCaldavConnectionParameters', IMAP?: { __typename?: 'ConnectionParametersOutput', host: string, port: number, secure?: boolean | null, username?: string | null, password: string } | null, SMTP?: { __typename?: 'ConnectionParametersOutput', host: string, port: number, secure?: boolean | null, username?: string | null, password: string } | null, CALDAV?: { __typename?: 'ConnectionParametersOutput', host: string, username?: string | null, password: string } | null } | null }> };
export type MyMessageChannelsQueryVariables = Exact<{
connectedAccountId?: InputMaybe<Scalars['UUID']>;
@@ -8015,7 +8016,7 @@ export const UpdateMessageFoldersDocument = {"kind":"Document","definitions":[{"
export const ConnectedAccountByIdDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ConnectedAccountById"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connectedAccountById"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"provider"}},{"kind":"Field","name":{"kind":"Name","value":"scopes"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"connectionParameters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"IMAP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PublicConnectionParams"}}]}},{"kind":"Field","name":{"kind":"Name","value":"SMTP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PublicConnectionParams"}}]}},{"kind":"Field","name":{"kind":"Name","value":"CALDAV"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"PublicConnectionParams"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PublicConnectionParams"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"PublicConnectionParametersOutput"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}}]}}]} as unknown as DocumentNode<ConnectedAccountByIdQuery, ConnectedAccountByIdQueryVariables>;
export const GetConnectedImapSmtpCaldavAccountDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetConnectedImapSmtpCaldavAccount"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getConnectedImapSmtpCaldavAccount"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"provider"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"connectionParameters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"IMAP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}},{"kind":"Field","name":{"kind":"Name","value":"SMTP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}},{"kind":"Field","name":{"kind":"Name","value":"CALDAV"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}}]}}]}}]}}]} as unknown as DocumentNode<GetConnectedImapSmtpCaldavAccountQuery, GetConnectedImapSmtpCaldavAccountQueryVariables>;
export const MyCalendarChannelsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyCalendarChannels"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myCalendarChannels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"connectedAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"syncStage"}},{"kind":"Field","name":{"kind":"Name","value":"syncStageStartedAt"}},{"kind":"Field","name":{"kind":"Name","value":"isContactAutoCreationEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"contactAutoCreationPolicy"}},{"kind":"Field","name":{"kind":"Name","value":"isSyncEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"connectedAccountId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<MyCalendarChannelsQuery, MyCalendarChannelsQueryVariables>;
export const MyConnectedAccountsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyConnectedAccounts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myConnectedAccounts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"provider"}},{"kind":"Field","name":{"kind":"Name","value":"authFailedAt"}},{"kind":"Field","name":{"kind":"Name","value":"scopes"}},{"kind":"Field","name":{"kind":"Name","value":"handleAliases"}},{"kind":"Field","name":{"kind":"Name","value":"lastSignedInAt"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"applicationConnectionProviderId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"lastCredentialsRefreshedAt"}},{"kind":"Field","name":{"kind":"Name","value":"connectionParameters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"IMAP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}},{"kind":"Field","name":{"kind":"Name","value":"SMTP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}},{"kind":"Field","name":{"kind":"Name","value":"CALDAV"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<MyConnectedAccountsQuery, MyConnectedAccountsQueryVariables>;
export const MyConnectedAccountsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyConnectedAccounts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myConnectedAccounts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"provider"}},{"kind":"Field","name":{"kind":"Name","value":"authFailedAt"}},{"kind":"Field","name":{"kind":"Name","value":"scopes"}},{"kind":"Field","name":{"kind":"Name","value":"handleAliases"}},{"kind":"Field","name":{"kind":"Name","value":"lastSignedInAt"}},{"kind":"Field","name":{"kind":"Name","value":"userWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"connectionProviderId"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"lastCredentialsRefreshedAt"}},{"kind":"Field","name":{"kind":"Name","value":"connectionParameters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"IMAP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}},{"kind":"Field","name":{"kind":"Name","value":"SMTP"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"port"}},{"kind":"Field","name":{"kind":"Name","value":"secure"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}},{"kind":"Field","name":{"kind":"Name","value":"CALDAV"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"host"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"password"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<MyConnectedAccountsQuery, MyConnectedAccountsQueryVariables>;
export const MyMessageChannelsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyMessageChannels"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myMessageChannels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"connectedAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"isContactAutoCreationEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"contactAutoCreationPolicy"}},{"kind":"Field","name":{"kind":"Name","value":"messageFolderImportPolicy"}},{"kind":"Field","name":{"kind":"Name","value":"excludeNonProfessionalEmails"}},{"kind":"Field","name":{"kind":"Name","value":"excludeGroupEmails"}},{"kind":"Field","name":{"kind":"Name","value":"isSyncEnabled"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"syncStage"}},{"kind":"Field","name":{"kind":"Name","value":"syncStageStartedAt"}},{"kind":"Field","name":{"kind":"Name","value":"connectedAccountId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<MyMessageChannelsQuery, MyMessageChannelsQueryVariables>;
export const MyMessageFoldersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"MyMessageFolders"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"messageChannelId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"myMessageFolders"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"messageChannelId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"messageChannelId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"isSynced"}},{"kind":"Field","name":{"kind":"Name","value":"isSentFolder"}},{"kind":"Field","name":{"kind":"Name","value":"parentFolderId"}},{"kind":"Field","name":{"kind":"Name","value":"externalId"}},{"kind":"Field","name":{"kind":"Name","value":"messageChannelId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]}}]} as unknown as DocumentNode<MyMessageFoldersQuery, MyMessageFoldersQueryVariables>;
export const DeleteApplicationRegistrationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteApplicationRegistration"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteApplicationRegistration"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}]}}]} as unknown as DocumentNode<DeleteApplicationRegistrationMutation, DeleteApplicationRegistrationMutationVariables>;
@@ -12,7 +12,7 @@ export type ConnectedAccount = {
handleAliases: string[] | null;
lastSignedInAt: string | null;
userWorkspaceId: string;
applicationConnectionProviderId: string | null;
connectionProviderId: string | null;
name: string | null;
// Connection-row visibility — distinct from the `scopes` array above
// (those are upstream-granted OAuth permissions).
@@ -48,6 +48,7 @@ export const useMetadataErrorHandler = () => {
navigationMenuItem: t`navigation menu item`,
webhook: t`webhook`,
viewSort: t`view sort`,
connectionProvider: t`connection provider`,
} as const satisfies Record<AllMetadataName, string>;
const handleMetadataError = (
@@ -11,7 +11,7 @@ export const GET_MY_CONNECTED_ACCOUNTS = gql`
handleAliases
lastSignedInAt
userWorkspaceId
applicationConnectionProviderId
connectionProviderId
name
visibility
lastCredentialsRefreshedAt
@@ -117,7 +117,7 @@ export const SettingsApplicationConnectionsSection = ({
provider.oauth?.isClientCredentialsConfigured ?? false;
const providerConnections = connectedAccounts.filter(
(account) => account.applicationConnectionProviderId === provider.id,
(account) => account.connectionProviderId === provider.id,
);
return (
@@ -0,0 +1,117 @@
import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.3.0', 1777896012579)
export class ConnectionProviderSyncableEntityFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "core"."connectionProvider" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"workspaceId" uuid NOT NULL,
"applicationId" uuid NOT NULL,
"universalIdentifier" uuid NOT NULL,
"name" varchar NOT NULL,
"displayName" varchar NOT NULL,
"type" varchar NOT NULL,
"oauthConfig" jsonb,
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "IDX_CONNECTION_PROVIDER_NAME_APPLICATION_UNIQUE" UNIQUE ("name", "applicationId"),
CONSTRAINT "PK_connectionProvider_id" PRIMARY KEY ("id")
)`,
);
await queryRunner.query(
`CREATE INDEX "IDX_CONNECTION_PROVIDER_APPLICATION_ID" ON "core"."connectionProvider" ("applicationId")`,
);
await queryRunner.query(
`CREATE UNIQUE INDEX "IDX_44a4fc17a91603c38daabfd4d8" ON "core"."connectionProvider" ("workspaceId", "universalIdentifier")`,
);
await queryRunner.query(
`ALTER TABLE "core"."connectionProvider"
ADD CONSTRAINT "FK_16d8e4d029dd986268d759a2257"
FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id")
ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."connectionProvider"
ADD CONSTRAINT "FK_a2553b431536a5b93211012f984"
FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id")
ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."connectedAccount"
ADD COLUMN "connectionProviderId" uuid,
ADD COLUMN "applicationId" uuid,
ADD COLUMN "name" varchar,
ADD COLUMN "visibility" varchar NOT NULL DEFAULT 'user'`,
);
await queryRunner.query(
`CREATE INDEX "IDX_CONNECTED_ACCOUNT_CONNECTION_PROVIDER_ID" ON "core"."connectedAccount" ("connectionProviderId")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_CONNECTED_ACCOUNT_APPLICATION_ID" ON "core"."connectedAccount" ("applicationId")`,
);
await queryRunner.query(
`ALTER TABLE "core"."connectedAccount"
ADD CONSTRAINT "FK_40de45e67a285dafb84e510cdc6"
FOREIGN KEY ("connectionProviderId") REFERENCES "core"."connectionProvider"("id")
ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."connectedAccount"
ADD CONSTRAINT "FK_21b8e7d3a21ff5712c4dd4875ac"
FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id")
ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."connectedAccount" DROP CONSTRAINT "FK_21b8e7d3a21ff5712c4dd4875ac"`,
);
await queryRunner.query(
`ALTER TABLE "core"."connectedAccount" DROP CONSTRAINT "FK_40de45e67a285dafb84e510cdc6"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_CONNECTED_ACCOUNT_APPLICATION_ID"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_CONNECTED_ACCOUNT_CONNECTION_PROVIDER_ID"`,
);
await queryRunner.query(
`ALTER TABLE "core"."connectedAccount"
DROP COLUMN "visibility",
DROP COLUMN "name",
DROP COLUMN "applicationId",
DROP COLUMN "connectionProviderId"`,
);
await queryRunner.query(
`ALTER TABLE "core"."connectionProvider" DROP CONSTRAINT "FK_a2553b431536a5b93211012f984"`,
);
await queryRunner.query(
`ALTER TABLE "core"."connectionProvider" DROP CONSTRAINT "FK_16d8e4d029dd986268d759a2257"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_44a4fc17a91603c38daabfd4d8"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_CONNECTION_PROVIDER_APPLICATION_ID"`,
);
await queryRunner.query(`DROP TABLE "core"."connectionProvider"`);
}
}
@@ -22,6 +22,7 @@ import { AddUpgradeMigrationWorkspaceIdIndexFastInstanceCommand } from 'src/data
import { AddCacheTokensToAgentChatThreadFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-2/2-2-instance-command-fast-1777455269302-add-cache-tokens-to-agent-chat-thread';
import { AddLogoToApplicationFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-2/2-2-instance-command-fast-1777539664664-add-logo-to-application';
import { AddDeletedAtToAgentChatThreadFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1777682000000-add-deleted-at-to-agent-chat-thread';
import { ConnectionProviderSyncableEntityFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1777896012579-connection-provider-syncable-entity';
import { RemoveUserDefaultAvatarUrlFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-3/2-3-instance-command-fast-1777915958318-remove-user-default-avatar-url';
export const INSTANCE_COMMANDS = [
@@ -47,5 +48,6 @@ export const INSTANCE_COMMANDS = [
AddCacheTokensToAgentChatThreadFastInstanceCommand,
AddLogoToApplicationFastInstanceCommand,
AddDeletedAtToAgentChatThreadFastInstanceCommand,
ConnectionProviderSyncableEntityFastInstanceCommand,
RemoveUserDefaultAvatarUrlFastInstanceCommand,
];
@@ -1,133 +0,0 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class AddApplicationOAuthProviderAndConnectedAccountColumn1777558657640
implements MigrationInterface
{
name = 'AddApplicationOAuthProviderAndConnectedAccountColumn1777558657640';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "core"."applicationOAuthProvider" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"workspaceId" uuid NOT NULL,
"applicationId" uuid NOT NULL,
"universalIdentifier" uuid NOT NULL,
"name" varchar NOT NULL,
"displayName" varchar NOT NULL,
"authorizationEndpoint" varchar NOT NULL,
"tokenEndpoint" varchar NOT NULL,
"revokeEndpoint" varchar,
"scopes" varchar array NOT NULL DEFAULT '{}',
"clientIdVariable" varchar NOT NULL,
"clientSecretVariable" varchar NOT NULL,
"authorizationParams" jsonb,
"tokenRequestContentType" varchar NOT NULL DEFAULT 'json',
"usePkce" boolean NOT NULL DEFAULT true,
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "IDX_APP_OAUTH_PROVIDER_NAME_APPLICATION_UNIQUE" UNIQUE ("name", "applicationId"),
CONSTRAINT "IDX_APP_OAUTH_PROVIDER_UNIVERSAL_ID_APPLICATION_UNIQUE" UNIQUE ("universalIdentifier", "applicationId"),
CONSTRAINT "PK_applicationOAuthProvider_id" PRIMARY KEY ("id")
)`,
);
await queryRunner.query(
`CREATE INDEX "IDX_APP_OAUTH_PROVIDER_APPLICATION_ID" ON "core"."applicationOAuthProvider" ("applicationId")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_APP_OAUTH_PROVIDER_WORKSPACE_ID" ON "core"."applicationOAuthProvider" ("workspaceId")`,
);
// FK constraint names match the hashes that TypeORM auto-generates from
// the @ManyToOne decorators in the entities. Keeping them in sync here
// avoids a "pending migration" diff on every CI run.
await queryRunner.query(
`ALTER TABLE "core"."applicationOAuthProvider"
ADD CONSTRAINT "FK_c63de8b90514de1798876c30f2e"
FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id")
ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationOAuthProvider"
ADD CONSTRAINT "FK_2d01320998547c2f5059d8b09d6"
FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id")
ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."connectedAccount"
ADD COLUMN "applicationOAuthProviderId" uuid,
ADD COLUMN "applicationId" uuid,
ADD COLUMN "name" varchar,
ADD COLUMN "visibility" varchar NOT NULL DEFAULT 'user'`,
);
await queryRunner.query(
`CREATE INDEX "IDX_CONNECTED_ACCOUNT_APP_OAUTH_PROVIDER_ID" ON "core"."connectedAccount" ("applicationOAuthProviderId")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_CONNECTED_ACCOUNT_APPLICATION_ID" ON "core"."connectedAccount" ("applicationId")`,
);
await queryRunner.query(
`ALTER TABLE "core"."connectedAccount"
ADD CONSTRAINT "FK_344a905bc2041c998d5b57f9bde"
FOREIGN KEY ("applicationOAuthProviderId") REFERENCES "core"."applicationOAuthProvider"("id")
ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."connectedAccount"
ADD CONSTRAINT "FK_21b8e7d3a21ff5712c4dd4875ac"
FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id")
ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."connectedAccount" DROP CONSTRAINT "FK_21b8e7d3a21ff5712c4dd4875ac"`,
);
await queryRunner.query(
`ALTER TABLE "core"."connectedAccount" DROP CONSTRAINT "FK_344a905bc2041c998d5b57f9bde"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_CONNECTED_ACCOUNT_APPLICATION_ID"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_CONNECTED_ACCOUNT_APP_OAUTH_PROVIDER_ID"`,
);
await queryRunner.query(
`ALTER TABLE "core"."connectedAccount"
DROP COLUMN "visibility",
DROP COLUMN "name",
DROP COLUMN "applicationId",
DROP COLUMN "applicationOAuthProviderId"`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationOAuthProvider" DROP CONSTRAINT "FK_2d01320998547c2f5059d8b09d6"`,
);
await queryRunner.query(
`ALTER TABLE "core"."applicationOAuthProvider" DROP CONSTRAINT "FK_c63de8b90514de1798876c30f2e"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_APP_OAUTH_PROVIDER_WORKSPACE_ID"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_APP_OAUTH_PROVIDER_APPLICATION_ID"`,
);
await queryRunner.query(`DROP TABLE "core"."applicationOAuthProvider"`);
}
}
@@ -4,7 +4,6 @@ import { ApplicationModule } from 'src/engine/core-modules/application/applicati
import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/application-manifest/application-manifest-migration.service';
import { ApplicationManifestResolver } from 'src/engine/core-modules/application/application-manifest/application-manifest.resolver';
import { ApplicationSyncService } from 'src/engine/core-modules/application/application-manifest/application-sync.service';
import { ApplicationOAuthProviderModule } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.module';
import { ApplicationVariableEntityModule } from 'src/engine/core-modules/application/application-variable/application-variable.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module';
@@ -17,7 +16,6 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
@Module({
imports: [
ApplicationModule,
ApplicationOAuthProviderModule,
ApplicationVariableEntityModule,
FeatureFlagModule,
FileStorageModule,
@@ -12,7 +12,6 @@ import {
ApplicationExceptionCode,
} from 'src/engine/core-modules/application/application.exception';
import { ApplicationManifestMigrationService } from 'src/engine/core-modules/application/application-manifest/application-manifest-migration.service';
import { ApplicationOAuthProviderService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.service';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { buildFromToAllUniversalFlatEntityMaps } from 'src/engine/core-modules/application/application-manifest/utils/build-from-to-all-universal-flat-entity-maps.util';
@@ -34,7 +33,6 @@ export class ApplicationSyncService {
constructor(
private readonly applicationService: ApplicationService,
private readonly applicationVariableService: ApplicationVariableEntityService,
private readonly applicationOAuthProviderService: ApplicationOAuthProviderService,
private readonly applicationManifestMigrationService: ApplicationManifestMigrationService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceCacheService: WorkspaceCacheService,
@@ -155,12 +153,6 @@ export class ApplicationSyncService {
},
);
await this.applicationOAuthProviderService.upsertManyFromManifest({
connectionProviders: manifest.connectionProviders,
applicationId: application.id,
workspaceId,
});
const resolvedRegistrationId =
applicationRegistrationId ?? application.applicationRegistrationId;
@@ -0,0 +1,97 @@
import { type ConnectionProviderManifest } from 'twenty-shared/application';
import { fromConnectionProviderManifestToUniversalFlatConnectionProvider } from 'src/engine/core-modules/application/application-manifest/converters/from-connection-provider-manifest-to-universal-flat-connection-provider.util';
const APP_UID = 'a8a8a8a8-a8a8-4a8a-a8a8-a8a8a8a8a8a8';
const PROVIDER_UID = '99fcd8e8-fbb1-4d2c-bc16-7c61ef3eaaaa';
const NOW = '2026-05-04T00:00:00.000Z';
const buildManifest = (
overrides: Partial<ConnectionProviderManifest> = {},
): ConnectionProviderManifest =>
({
universalIdentifier: PROVIDER_UID,
name: 'linear',
displayName: 'Linear',
type: 'oauth',
oauth: {
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
scopes: ['read', 'write'],
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
},
...overrides,
}) as ConnectionProviderManifest;
describe('fromConnectionProviderManifestToUniversalFlatConnectionProvider', () => {
it('moves OAuth manifest fields into the resolved oauthConfig blob with defaults filled', () => {
const result =
fromConnectionProviderManifestToUniversalFlatConnectionProvider({
connectionProviderManifest: buildManifest(),
applicationUniversalIdentifier: APP_UID,
now: NOW,
});
expect(result).toEqual({
universalIdentifier: PROVIDER_UID,
applicationUniversalIdentifier: APP_UID,
name: 'linear',
displayName: 'Linear',
type: 'oauth',
oauthConfig: {
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
revokeEndpoint: null,
scopes: ['read', 'write'],
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
authorizationParams: null,
tokenRequestContentType: 'json',
usePkce: true,
},
createdAt: NOW,
updatedAt: NOW,
});
});
it('passes through optional oauth config when provided', () => {
const result =
fromConnectionProviderManifestToUniversalFlatConnectionProvider({
connectionProviderManifest: buildManifest({
oauth: {
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
revokeEndpoint: 'https://api.linear.app/oauth/revoke',
scopes: ['read', 'write'],
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
authorizationParams: { prompt: 'consent' },
tokenRequestContentType: 'form-urlencoded',
usePkce: false,
},
}),
applicationUniversalIdentifier: APP_UID,
now: NOW,
});
expect(result.oauthConfig).toMatchObject({
revokeEndpoint: 'https://api.linear.app/oauth/revoke',
authorizationParams: { prompt: 'consent' },
tokenRequestContentType: 'form-urlencoded',
usePkce: false,
});
});
it('defaults to json content-type and PKCE-on when oauth config omits them', () => {
const result =
fromConnectionProviderManifestToUniversalFlatConnectionProvider({
connectionProviderManifest: buildManifest(),
applicationUniversalIdentifier: APP_UID,
now: NOW,
});
expect(result.oauthConfig?.tokenRequestContentType).toBe('json');
expect(result.oauthConfig?.usePkce).toBe(true);
});
});
@@ -0,0 +1,49 @@
import {
type ConnectionProviderManifest,
type StoredOAuthConnectionProviderConfig,
} from 'twenty-shared/application';
import { type UniversalFlatConnectionProvider } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-connection-provider.type';
export const fromConnectionProviderManifestToUniversalFlatConnectionProvider =
({
connectionProviderManifest,
applicationUniversalIdentifier,
now,
}: {
connectionProviderManifest: ConnectionProviderManifest;
applicationUniversalIdentifier: string;
now: string;
}): UniversalFlatConnectionProvider => {
const oauthConfig: StoredOAuthConnectionProviderConfig | null =
connectionProviderManifest.type === 'oauth'
? {
authorizationEndpoint:
connectionProviderManifest.oauth.authorizationEndpoint,
tokenEndpoint: connectionProviderManifest.oauth.tokenEndpoint,
revokeEndpoint:
connectionProviderManifest.oauth.revokeEndpoint ?? null,
scopes: connectionProviderManifest.oauth.scopes,
clientIdVariable: connectionProviderManifest.oauth.clientIdVariable,
clientSecretVariable:
connectionProviderManifest.oauth.clientSecretVariable,
authorizationParams:
connectionProviderManifest.oauth.authorizationParams ?? null,
tokenRequestContentType:
connectionProviderManifest.oauth.tokenRequestContentType ??
'json',
usePkce: connectionProviderManifest.oauth.usePkce ?? true,
}
: null;
return {
universalIdentifier: connectionProviderManifest.universalIdentifier,
applicationUniversalIdentifier,
name: connectionProviderManifest.name,
displayName: connectionProviderManifest.displayName,
type: connectionProviderManifest.type,
oauthConfig,
createdAt: now,
updatedAt: now,
};
};
@@ -5,6 +5,7 @@ import { isDefined } from 'twenty-shared/utils';
import { generateIndexForFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/generate-index-for-flat-field-metadata.util';
import { fromCommandMenuItemManifestToUniversalFlatCommandMenuItem } from 'src/engine/core-modules/application/application-manifest/converters/from-command-menu-item-manifest-to-universal-flat-command-menu-item.util';
import { fromConnectionProviderManifestToUniversalFlatConnectionProvider } from 'src/engine/core-modules/application/application-manifest/converters/from-connection-provider-manifest-to-universal-flat-connection-provider.util';
import { fromFieldManifestToUniversalFlatFieldMetadata } from 'src/engine/core-modules/application/application-manifest/converters/from-field-manifest-to-universal-flat-field-metadata.util';
import { fromFieldPermissionManifestToUniversalFlatFieldPermission } from 'src/engine/core-modules/application/application-manifest/converters/from-field-permission-manifest-to-universal-flat-field-permission.util';
import { fromFrontComponentManifestToUniversalFlatFrontComponent } from 'src/engine/core-modules/application/application-manifest/converters/from-front-component-manifest-to-universal-flat-front-component.util';
@@ -176,6 +177,19 @@ export const computeApplicationManifestAllUniversalFlatEntityMaps = ({
}
}
for (const connectionProviderManifest of manifest.connectionProviders ?? []) {
addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({
universalFlatEntity:
fromConnectionProviderManifestToUniversalFlatConnectionProvider({
connectionProviderManifest,
applicationUniversalIdentifier,
now,
}),
universalFlatEntityMapsToMutate:
allUniversalFlatEntityMaps.flatConnectionProviderMaps,
});
}
for (const roleManifest of manifest.roles) {
addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({
universalFlatEntity: fromRoleManifestToUniversalFlatRole({
@@ -1,384 +0,0 @@
// SecureHttpClientService transitively depends on `@lifeomic/axios-fetch`,
// which is an optional native-binding dep that's flaky in some test envs.
// We never use the real implementation here (the test always injects a
// mock via `useValue`), so stub the module to avoid loading the dep at all.
jest.mock(
'src/engine/core-modules/secure-http-client/secure-http-client.service',
() => ({
SecureHttpClientService: class {},
}),
);
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { type ApplicationOAuthProviderEntity } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.entity';
import { ApplicationOAuthProviderFlowService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider-flow.service';
import { ApplicationOAuthProviderService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.service';
import { JwtTokenTypeEnum } from 'src/engine/core-modules/auth/types/auth-context.type';
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 { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
describe('ApplicationOAuthProviderFlowService', () => {
let service: ApplicationOAuthProviderFlowService;
let oauthProviderService: {
findOneByIdOrThrow: jest.Mock;
getClientCredentials: jest.Mock;
};
let jwtWrapperService: {
sign: jest.Mock;
verifyJwtToken: jest.Mock;
generateAppSecret: jest.Mock;
};
let secureHttpClientService: { createSsrfSafeFetch: jest.Mock };
let connectedAccountRepository: {
count: jest.Mock;
update: jest.Mock;
create: jest.Mock;
save: jest.Mock;
findOne: jest.Mock;
findOneByOrFail: jest.Mock;
};
const baseProvider: ApplicationOAuthProviderEntity = {
id: 'provider-1',
universalIdentifier: 'provider-uid',
applicationId: 'app-1',
workspaceId: 'workspace-1',
name: 'linear',
displayName: 'Linear',
icon: null,
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
revokeEndpoint: null,
scopes: ['read', 'write'],
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
authorizationParams: null,
tokenRequestContentType: 'form-urlencoded',
usePkce: false,
createdAt: new Date(),
updatedAt: new Date(),
} as unknown as ApplicationOAuthProviderEntity;
beforeEach(async () => {
oauthProviderService = {
findOneByIdOrThrow: jest.fn(),
getClientCredentials: jest.fn(async () => ({
clientId: 'lin_client_id',
clientSecret: 'lin_client_secret',
})),
};
jwtWrapperService = {
sign: jest.fn(),
verifyJwtToken: jest.fn(),
generateAppSecret: jest.fn(() => 'derived-secret'),
};
secureHttpClientService = { createSsrfSafeFetch: jest.fn() };
connectedAccountRepository = {
count: jest.fn(async () => 0),
update: jest.fn(),
create: jest.fn((entity) => entity),
save: jest.fn(async (entity) => ({ ...entity, id: 'new-account-id' })),
findOne: jest.fn(async () => null),
findOneByOrFail: jest.fn(async ({ id }) => ({
id,
provider: ConnectedAccountProvider.APP,
})),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
ApplicationOAuthProviderFlowService,
{
provide: ApplicationOAuthProviderService,
useValue: oauthProviderService,
},
{ provide: JwtWrapperService, useValue: jwtWrapperService },
{ provide: SecureHttpClientService, useValue: secureHttpClientService },
{
provide: TwentyConfigService,
useValue: { get: jest.fn(() => 'https://api.example.com') },
},
{
provide: getRepositoryToken(ConnectedAccountEntity),
useValue: connectedAccountRepository,
},
],
}).compile();
service = module.get(ApplicationOAuthProviderFlowService);
});
afterEach(() => jest.clearAllMocks());
describe('startAuthorizationFlow', () => {
it('builds the provider authorization URL with the workspace + visibility context signed into state', async () => {
jwtWrapperService.sign.mockReturnValue('signed-state-token');
const { authorizationUrl } = await service.startAuthorizationFlow({
applicationOAuthProvider: baseProvider,
workspaceId: 'workspace-1',
userId: 'user-1',
userWorkspaceId: 'uws-1',
visibility: 'user',
reconnectingConnectedAccountId: null,
redirectLocation: null,
});
const url = new URL(authorizationUrl);
expect(url.origin + url.pathname).toBe(
'https://linear.app/oauth/authorize',
);
expect(url.searchParams.get('client_id')).toBe('lin_client_id');
expect(url.searchParams.get('response_type')).toBe('code');
// OAuth-standard `scope` (plural meaning) — these are the upstream
// permissions we're requesting, unrelated to the row-visibility field.
expect(url.searchParams.get('scope')).toBe('read write');
expect(url.searchParams.get('state')).toBe('signed-state-token');
expect(url.searchParams.get('redirect_uri')).toBe(
'https://api.example.com/apps/oauth/callback',
);
expect(url.searchParams.has('code_challenge')).toBe(false);
// signed payload carries workspace identity for the callback to use
expect(jwtWrapperService.sign).toHaveBeenCalledWith(
expect.objectContaining({
type: JwtTokenTypeEnum.APP_OAUTH_STATE,
workspaceId: 'workspace-1',
applicationOAuthProviderId: 'provider-1',
visibility: 'user',
reconnectingConnectedAccountId: null,
}),
expect.objectContaining({ secret: 'derived-secret' }),
);
});
it('emits PKCE challenge params when usePkce is enabled', async () => {
jwtWrapperService.sign.mockReturnValue('signed-state');
const { authorizationUrl } = await service.startAuthorizationFlow({
applicationOAuthProvider: { ...baseProvider, usePkce: true },
workspaceId: 'workspace-1',
userId: 'user-1',
userWorkspaceId: 'uws-1',
visibility: 'user',
reconnectingConnectedAccountId: null,
redirectLocation: null,
});
const url = new URL(authorizationUrl);
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
expect(url.searchParams.get('code_challenge')).toMatch(/^[\w-]+$/);
});
describe('reconnect target validation', () => {
// Cross-workspace reconnect was a real bug: the persist UPDATE filtered
// by (id, workspaceId) so it wrote nothing, but the subsequent
// findOneByOrFail({ id }) returned the foreign-workspace row with stale
// tokens, making the reconnect look successful. Catch it at authorize
// time before the upstream OAuth round-trip.
const validateArgs = {
applicationOAuthProvider: baseProvider,
workspaceId: 'workspace-1',
userId: 'user-1',
userWorkspaceId: 'uws-1',
visibility: 'user' as const,
redirectLocation: null,
};
it('throws FORBIDDEN when reconnecting an id that lives in another workspace', async () => {
connectedAccountRepository.findOne.mockResolvedValue(null);
const error = await service
.startAuthorizationFlow({
...validateArgs,
reconnectingConnectedAccountId: 'foreign-account-id',
})
.catch((caught) => caught);
expect(error).toMatchObject({
code: 'FORBIDDEN',
});
expect(error.message).toContain('foreign-account-id');
expect(connectedAccountRepository.findOne).toHaveBeenCalledWith({
where: {
id: 'foreign-account-id',
workspaceId: 'workspace-1',
applicationConnectionProviderId: 'provider-1',
},
});
// No state JWT signed, no upstream URL built.
expect(jwtWrapperService.sign).not.toHaveBeenCalled();
});
it('throws FORBIDDEN when reconnecting an id that belongs to a different provider in the same workspace', async () => {
// findOne with the provider filter returns null even though the row
// exists in this workspace under a different provider.
connectedAccountRepository.findOne.mockResolvedValue(null);
await expect(
service.startAuthorizationFlow({
...validateArgs,
reconnectingConnectedAccountId: 'wrong-provider-account-id',
}),
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});
it('proceeds when the reconnect target matches workspace and provider', async () => {
connectedAccountRepository.findOne.mockResolvedValue({
id: 'existing-account-id',
workspaceId: 'workspace-1',
applicationConnectionProviderId: 'provider-1',
});
jwtWrapperService.sign.mockReturnValue('state');
const { authorizationUrl } = await service.startAuthorizationFlow({
...validateArgs,
reconnectingConnectedAccountId: 'existing-account-id',
});
expect(new URL(authorizationUrl).searchParams.get('state')).toBe(
'state',
);
expect(jwtWrapperService.sign).toHaveBeenCalled();
});
it('skips the lookup entirely when reconnectingConnectedAccountId is null', async () => {
jwtWrapperService.sign.mockReturnValue('state');
await service.startAuthorizationFlow({
...validateArgs,
reconnectingConnectedAccountId: null,
});
expect(connectedAccountRepository.findOne).not.toHaveBeenCalled();
});
});
});
describe('completeAuthorizationFlow', () => {
const stateClaims = {
sub: 'provider-1',
type: JwtTokenTypeEnum.APP_OAUTH_STATE,
applicationOAuthProviderId: 'provider-1',
workspaceId: 'workspace-1',
userId: 'user-1',
userWorkspaceId: 'uws-1',
visibility: 'user' as const,
reconnectingConnectedAccountId: null,
redirectLocation: null,
codeVerifier: null,
};
const successfulTokenResponse = {
ok: true,
status: 200,
json: async () => ({
access_token: 'new_access',
refresh_token: 'new_refresh',
scope: 'read write',
}),
text: async () => '',
};
beforeEach(() => {
jwtWrapperService.verifyJwtToken.mockReturnValue(stateClaims);
oauthProviderService.findOneByIdOrThrow.mockResolvedValue(baseProvider);
secureHttpClientService.createSsrfSafeFetch.mockReturnValue(
jest.fn(async () => successfulTokenResponse),
);
});
it('always creates a new ConnectedAccount when no reconnect id is supplied', async () => {
const result = await service.completeAuthorizationFlow({
code: 'auth_code',
state: 'signed-state',
});
expect(result.connectedAccountId).toBe('new-account-id');
expect(result.workspaceId).toBe('workspace-1');
expect(result.applicationId).toBe('app-1');
expect(connectedAccountRepository.create).toHaveBeenCalledWith(
expect.objectContaining({
provider: ConnectedAccountProvider.APP,
accessToken: 'new_access',
refreshToken: 'new_refresh',
applicationConnectionProviderId: 'provider-1',
applicationId: 'app-1',
workspaceId: 'workspace-1',
userWorkspaceId: 'uws-1',
visibility: 'user',
}),
);
expect(connectedAccountRepository.save).toHaveBeenCalled();
expect(connectedAccountRepository.update).not.toHaveBeenCalled();
});
it('updates the existing ConnectedAccount when reconnectingConnectedAccountId is supplied', async () => {
jwtWrapperService.verifyJwtToken.mockReturnValue({
...stateClaims,
reconnectingConnectedAccountId: 'existing-account-id',
});
const result = await service.completeAuthorizationFlow({
code: 'auth_code',
state: 'signed-state',
});
expect(result.connectedAccountId).toBe('existing-account-id');
expect(connectedAccountRepository.update).toHaveBeenCalledWith(
{ id: 'existing-account-id', workspaceId: 'workspace-1' },
expect.objectContaining({
accessToken: 'new_access',
refreshToken: 'new_refresh',
authFailedAt: null,
}),
);
// Defense-in-depth: the post-update read MUST also be workspace-scoped,
// otherwise a foreign-id that slipped past the authorize-time guard
// would still surface stale fields from another workspace.
expect(connectedAccountRepository.findOneByOrFail).toHaveBeenCalledWith({
id: 'existing-account-id',
workspaceId: 'workspace-1',
});
expect(connectedAccountRepository.create).not.toHaveBeenCalled();
});
it('persists the workspace visibility when state asks for it', async () => {
jwtWrapperService.verifyJwtToken.mockReturnValue({
...stateClaims,
visibility: 'workspace',
});
await service.completeAuthorizationFlow({
code: 'auth_code',
state: 'signed-state',
});
expect(connectedAccountRepository.create).toHaveBeenCalledWith(
expect.objectContaining({ visibility: 'workspace' }),
);
});
it('rejects an invalid state', async () => {
jwtWrapperService.verifyJwtToken.mockImplementation(() => {
throw new Error('JWT expired');
});
await expect(
service.completeAuthorizationFlow({
code: 'auth_code',
state: 'bad-state',
}),
).rejects.toThrow(/state/);
});
});
});
@@ -1,142 +0,0 @@
jest.mock(
'src/engine/core-modules/secret-encryption/secret-encryption.service',
() => ({
SecretEncryptionService: class {},
}),
);
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { type ConnectionProviderManifest } from 'twenty-shared/application';
import { ApplicationOAuthProviderEntity } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.entity';
import { ApplicationOAuthProviderExceptionCode } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider-exception-code.enum';
import { ApplicationOAuthProviderException } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.exception';
import { ApplicationOAuthProviderService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.service';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
const APP_ID = 'a8a8a8a8-a8a8-4a8a-a8a8-a8a8a8a8a8a8';
const WORKSPACE_ID = 'b8b8b8b8-b8b8-4b8b-b8b8-b8b8b8b8b8b8';
const buildOAuthManifest = (
overrides: Partial<ConnectionProviderManifest> = {},
): ConnectionProviderManifest =>
({
universalIdentifier: '99fcd8e8-fbb1-4d2c-bc16-7c61ef3eaaaa',
name: 'linear',
displayName: 'Linear',
type: 'oauth',
oauth: {
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
scopes: ['read', 'write'],
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
},
...overrides,
}) as ConnectionProviderManifest;
describe('ApplicationOAuthProviderService', () => {
let service: ApplicationOAuthProviderService;
let oauthProviderRepository: {
find: jest.Mock;
save: jest.Mock;
delete: jest.Mock;
};
beforeEach(async () => {
oauthProviderRepository = {
find: jest.fn().mockResolvedValue([]),
save: jest.fn().mockResolvedValue(undefined),
delete: jest.fn().mockResolvedValue(undefined),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
ApplicationOAuthProviderService,
{
provide: getRepositoryToken(ApplicationOAuthProviderEntity),
useValue: oauthProviderRepository,
},
{
provide: getRepositoryToken(ApplicationEntity),
useValue: { findOneBy: jest.fn() },
},
{
provide: getRepositoryToken(ApplicationRegistrationVariableEntity),
useValue: { find: jest.fn() },
},
{ provide: SecretEncryptionService, useValue: {} },
],
}).compile();
service = module.get(ApplicationOAuthProviderService);
});
afterEach(() => jest.clearAllMocks());
describe('upsertManyFromManifest', () => {
it('rejects a manifest whose connection provider has a non-UUID universalIdentifier', async () => {
const manifestWithBadId = buildOAuthManifest({
universalIdentifier: 'linear-provider',
});
const error = await service
.upsertManyFromManifest({
connectionProviders: [manifestWithBadId],
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
})
.catch((caught) => caught);
expect(error).toBeInstanceOf(ApplicationOAuthProviderException);
expect(error.code).toBe(
ApplicationOAuthProviderExceptionCode.INVALID_REQUEST,
);
expect(error.message).toContain('linear');
expect(error.message).toContain('linear-provider');
// Crucially: the failing validation must run before any DB write.
expect(oauthProviderRepository.save).not.toHaveBeenCalled();
expect(oauthProviderRepository.delete).not.toHaveBeenCalled();
});
it('points at the first invalid provider when multiple are wrong', async () => {
const error = await service
.upsertManyFromManifest({
connectionProviders: [
buildOAuthManifest({
name: 'first-bad',
universalIdentifier: 'not-a-uuid',
}),
buildOAuthManifest({
name: 'second-bad',
universalIdentifier: 'also-bad',
}),
],
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
})
.catch((caught) => caught);
expect(error.message).toContain('first-bad');
});
it('accepts a valid UUID and persists the provider', async () => {
await service.upsertManyFromManifest({
connectionProviders: [buildOAuthManifest()],
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
});
expect(oauthProviderRepository.save).toHaveBeenCalledWith([
expect.objectContaining({
universalIdentifier: '99fcd8e8-fbb1-4d2c-bc16-7c61ef3eaaaa',
name: 'linear',
}),
]);
});
});
});
@@ -1,81 +0,0 @@
import { type OAuthProviderTokenRequestContentType } from 'twenty-shared/application';
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity';
@Entity({ name: 'applicationOAuthProvider', schema: 'core' })
@Unique('IDX_APP_OAUTH_PROVIDER_NAME_APPLICATION_UNIQUE', [
'name',
'applicationId',
])
@Unique('IDX_APP_OAUTH_PROVIDER_UNIVERSAL_ID_APPLICATION_UNIQUE', [
'universalIdentifier',
'applicationId',
])
@Index('IDX_APP_OAUTH_PROVIDER_APPLICATION_ID', ['applicationId'])
@Index('IDX_APP_OAUTH_PROVIDER_WORKSPACE_ID', ['workspaceId'])
export class ApplicationOAuthProviderEntity extends WorkspaceRelatedEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: false, type: 'uuid' })
universalIdentifier: string;
@Column({ nullable: false, type: 'uuid' })
applicationId: string;
@ManyToOne(() => ApplicationEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'applicationId' })
application: Relation<ApplicationEntity>;
@Column({ nullable: false, type: 'varchar' })
name: string;
@Column({ nullable: false, type: 'varchar' })
displayName: string;
@Column({ nullable: false, type: 'varchar' })
authorizationEndpoint: string;
@Column({ nullable: false, type: 'varchar' })
tokenEndpoint: string;
@Column({ nullable: true, type: 'varchar' })
revokeEndpoint: string | null;
@Column({ type: 'varchar', array: true, nullable: false, default: '{}' })
scopes: string[];
@Column({ nullable: false, type: 'varchar' })
clientIdVariable: string;
@Column({ nullable: false, type: 'varchar' })
clientSecretVariable: string;
@Column({ nullable: true, type: 'jsonb' })
authorizationParams: Record<string, string> | null;
@Column({ nullable: false, type: 'varchar', default: 'json' })
tokenRequestContentType: OAuthProviderTokenRequestContentType;
@Column({ nullable: false, type: 'boolean', default: true })
usePkce: boolean;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
}
@@ -1,43 +0,0 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { ApplicationOAuthProviderExceptionCode } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider-exception-code.enum';
import { CustomException } from 'src/utils/custom-exception';
const getApplicationOAuthProviderExceptionUserFriendlyMessage = (
code: ApplicationOAuthProviderExceptionCode,
) => {
switch (code) {
case ApplicationOAuthProviderExceptionCode.PROVIDER_NOT_FOUND:
return msg`OAuth provider not found.`;
case ApplicationOAuthProviderExceptionCode.CLIENT_CREDENTIALS_NOT_CONFIGURED:
return msg`Client credentials are not configured for this OAuth provider.`;
case ApplicationOAuthProviderExceptionCode.TOKEN_EXCHANGE_FAILED:
return msg`Failed to exchange the authorization code for an access token.`;
case ApplicationOAuthProviderExceptionCode.REFRESH_FAILED:
return msg`Failed to refresh the access token.`;
case ApplicationOAuthProviderExceptionCode.INVALID_STATE:
return msg`The OAuth state parameter is invalid or expired.`;
case ApplicationOAuthProviderExceptionCode.INVALID_REQUEST:
return msg`The OAuth request is missing required parameters.`;
case ApplicationOAuthProviderExceptionCode.FORBIDDEN:
return msg`Not authorized to access this OAuth provider.`;
default:
assertUnreachable(code);
}
};
export class ApplicationOAuthProviderException extends CustomException<ApplicationOAuthProviderExceptionCode> {
constructor(
message: string,
code: ApplicationOAuthProviderExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ??
getApplicationOAuthProviderExceptionUserFriendlyMessage(code),
});
}
}
@@ -1,285 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isUUID } from 'class-validator';
import { type ConnectionProviderManifest } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
import { In, Not, Repository } from 'typeorm';
import { ApplicationOAuthProviderEntity } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.entity';
import { ApplicationOAuthProviderExceptionCode } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider-exception-code.enum';
import { ApplicationOAuthProviderException } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.exception';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
@Injectable()
export class ApplicationOAuthProviderService {
constructor(
@InjectRepository(ApplicationOAuthProviderEntity)
private readonly oauthProviderRepository: Repository<ApplicationOAuthProviderEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
@InjectRepository(ApplicationRegistrationVariableEntity)
private readonly registrationVariableRepository: Repository<ApplicationRegistrationVariableEntity>,
private readonly secretEncryptionService: SecretEncryptionService,
) {}
// Stored on the registration (one OAuth app per Twenty server, set by
// the server admin) — not per-workspace.
async getClientCredentials(
provider: ApplicationOAuthProviderEntity,
): Promise<{ clientId: string; clientSecret: string }> {
const application = await this.applicationRepository.findOneBy({
id: provider.applicationId,
});
if (!isDefined(application?.applicationRegistrationId)) {
throw new ApplicationOAuthProviderException(
`Application ${provider.applicationId} has no registration; OAuth client credentials cannot be resolved`,
ApplicationOAuthProviderExceptionCode.CLIENT_CREDENTIALS_NOT_CONFIGURED,
);
}
const variables = await this.registrationVariableRepository.find({
where: {
applicationRegistrationId: application.applicationRegistrationId,
key: In([provider.clientIdVariable, provider.clientSecretVariable]),
},
});
const valuesByKey = new Map(
variables.map((v) => [
v.key,
v.encryptedValue
? this.secretEncryptionService.decrypt(v.encryptedValue)
: '',
]),
);
const clientId = valuesByKey.get(provider.clientIdVariable) ?? '';
const clientSecret = valuesByKey.get(provider.clientSecretVariable) ?? '';
if (!clientId || !clientSecret) {
throw new ApplicationOAuthProviderException(
`OAuth client credentials are not configured for provider "${provider.name}". The server administrator needs to fill in "${provider.clientIdVariable}" and "${provider.clientSecretVariable}" on the application registration.`,
ApplicationOAuthProviderExceptionCode.CLIENT_CREDENTIALS_NOT_CONFIGURED,
);
}
return { clientId, clientSecret };
}
// For batched calls (e.g. the resolver listing path) prefer
// `areClientCredentialsConfiguredBatch` to avoid N+1.
async areClientCredentialsConfigured(
provider: ApplicationOAuthProviderEntity,
): Promise<boolean> {
const result = await this.areClientCredentialsConfiguredBatch([provider]);
return result.get(provider.id) ?? false;
}
async areClientCredentialsConfiguredBatch(
providers: ApplicationOAuthProviderEntity[],
): Promise<Map<string, boolean>> {
const result = new Map<string, boolean>();
if (providers.length === 0) {
return result;
}
const applicationIds = [...new Set(providers.map((p) => p.applicationId))];
const applications = await this.applicationRepository.find({
where: { id: In(applicationIds) },
});
const registrationIdByApplicationId = new Map(
applications.map((app) => [app.id, app.applicationRegistrationId]),
);
const registrationIds = [
...new Set(
applications
.map((app) => app.applicationRegistrationId)
.filter(isDefined),
),
];
if (registrationIds.length === 0) {
providers.forEach((p) => result.set(p.id, false));
return result;
}
const allKeys = providers.flatMap((p) => [
p.clientIdVariable,
p.clientSecretVariable,
]);
const variables = await this.registrationVariableRepository.find({
where: {
applicationRegistrationId: In(registrationIds),
key: In(allKeys),
},
});
const filledKeysByRegistrationId = new Map<string, Set<string>>();
for (const variable of variables) {
if (variable.encryptedValue === '') continue;
const set =
filledKeysByRegistrationId.get(variable.applicationRegistrationId) ??
new Set<string>();
set.add(variable.key);
filledKeysByRegistrationId.set(variable.applicationRegistrationId, set);
}
for (const provider of providers) {
const registrationId = registrationIdByApplicationId.get(
provider.applicationId,
);
if (!isDefined(registrationId)) {
result.set(provider.id, false);
continue;
}
const filled = filledKeysByRegistrationId.get(registrationId);
result.set(
provider.id,
filled?.has(provider.clientIdVariable) === true &&
filled?.has(provider.clientSecretVariable) === true,
);
}
return result;
}
async findOneByApplicationAndName({
applicationId,
name,
}: {
applicationId: string;
name: string;
}): Promise<ApplicationOAuthProviderEntity | null> {
return this.oauthProviderRepository.findOne({
where: { applicationId, name },
});
}
async findOneByIdOrThrow(
id: string,
): Promise<ApplicationOAuthProviderEntity> {
const provider = await this.oauthProviderRepository.findOne({
where: { id },
});
if (!isDefined(provider)) {
throw new ApplicationOAuthProviderException(
`OAuth provider with id "${id}" not found`,
ApplicationOAuthProviderExceptionCode.PROVIDER_NOT_FOUND,
);
}
return provider;
}
async findManyByApplication({
applicationId,
workspaceId,
}: {
applicationId: string;
workspaceId: string;
}): Promise<ApplicationOAuthProviderEntity[]> {
return this.oauthProviderRepository.find({
where: { applicationId, workspaceId },
});
}
// Persists OAuth-typed entries only. Other connection-provider types get
// their own sibling persistence helpers when added.
async upsertManyFromManifest({
connectionProviders,
applicationId,
workspaceId,
}: {
connectionProviders?: ConnectionProviderManifest[];
applicationId: string;
workspaceId: string;
}): Promise<void> {
const oauthProviders = (connectionProviders ?? []).filter(
(provider) => provider.type === 'oauth',
);
// The DB column is `uuid NOT NULL`. The manifest type is just `string`
// because manifests are dev-supplied and TS can't enforce UUID at the
// type level. Validate up-front so we throw a typed exception instead
// of letting Postgres reject the insert with an opaque type error.
for (const provider of oauthProviders) {
if (!isUUID(provider.universalIdentifier)) {
throw new ApplicationOAuthProviderException(
`Connection provider "${provider.name}" has an invalid universalIdentifier "${provider.universalIdentifier}" — must be a UUID.`,
ApplicationOAuthProviderExceptionCode.INVALID_REQUEST,
);
}
}
const existing = await this.oauthProviderRepository.find({
where: { applicationId, workspaceId },
});
if (oauthProviders.length === 0 && existing.length === 0) {
return;
}
const existingByUniversalIdentifier = new Map(
existing.map((p) => [p.universalIdentifier, p]),
);
const toSave: Partial<ApplicationOAuthProviderEntity>[] =
oauthProviders.map((manifest) => {
const fields = {
applicationId,
workspaceId,
universalIdentifier: manifest.universalIdentifier,
name: manifest.name,
displayName: manifest.displayName,
authorizationEndpoint: manifest.oauth.authorizationEndpoint,
tokenEndpoint: manifest.oauth.tokenEndpoint,
revokeEndpoint: manifest.oauth.revokeEndpoint ?? null,
scopes: manifest.oauth.scopes,
clientIdVariable: manifest.oauth.clientIdVariable,
clientSecretVariable: manifest.oauth.clientSecretVariable,
authorizationParams: manifest.oauth.authorizationParams ?? null,
tokenRequestContentType:
manifest.oauth.tokenRequestContentType ?? 'json',
usePkce: manifest.oauth.usePkce ?? true,
};
const existingEntity = existingByUniversalIdentifier.get(
manifest.universalIdentifier,
);
return isDefined(existingEntity)
? { id: existingEntity.id, ...fields }
: fields;
});
if (toSave.length > 0) {
await this.oauthProviderRepository.save(toSave);
}
await this.oauthProviderRepository.delete(
oauthProviders.length > 0
? {
applicationId,
workspaceId,
universalIdentifier: Not(
In(oauthProviders.map((p) => p.universalIdentifier)),
),
}
: { applicationId, workspaceId },
);
}
}
@@ -1,405 +0,0 @@
// SecureHttpClientService transitively depends on `@lifeomic/axios-fetch`,
// which is an optional native-binding dep that's flaky in some test envs.
// The list service uses ConnectedAccountRefreshTokensService (which pulls in
// the SSRF-safe HTTP client), so stub the module to avoid loading the dep.
// We never use the real implementation here — the test always injects a mock.
jest.mock(
'src/engine/core-modules/secure-http-client/secure-http-client.service',
() => ({
SecureHttpClientService: class {},
}),
);
import { NotFoundException } from '@nestjs/common';
import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { ApplicationOAuthProviderEntity } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.entity';
import { ApplicationConnectionsListService } from 'src/engine/core-modules/application/application-oauth-provider/connections/services/application-connections-list.service';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { ConnectedAccountRefreshTokensService } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service';
const APP_ID = 'app-1';
const WORKSPACE_ID = 'workspace-1';
const REQUEST_USER_WORKSPACE_ID = 'uws-request';
const OTHER_USER_WORKSPACE_ID = 'uws-other';
const PROVIDER_ID = 'provider-1';
const buildProvider = (
overrides: Partial<ApplicationOAuthProviderEntity> = {},
): ApplicationOAuthProviderEntity =>
({
id: PROVIDER_ID,
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
name: 'linear',
displayName: 'Linear',
scopes: ['read', 'write'],
...overrides,
}) as unknown as ApplicationOAuthProviderEntity;
const buildAccount = (
overrides: Partial<ConnectedAccountEntity> = {},
): ConnectedAccountEntity =>
({
id: 'conn-1',
name: 'Linear #1',
handle: 'octocat@example.com',
visibility: 'user',
applicationId: APP_ID,
applicationConnectionProviderId: PROVIDER_ID,
workspaceId: WORKSPACE_ID,
userWorkspaceId: REQUEST_USER_WORKSPACE_ID,
provider: ConnectedAccountProvider.APP,
accessToken: 'enc',
refreshToken: 'enc',
// OAuth scopes granted by the upstream provider — distinct from the
// row-level `visibility` field above.
scopes: ['read', 'write'],
lastCredentialsRefreshedAt: new Date('2024-01-01T00:00:00Z'),
authFailedAt: null,
...overrides,
}) as unknown as ConnectedAccountEntity;
describe('ApplicationConnectionsListService', () => {
let service: ApplicationConnectionsListService;
let connectedAccountRepository: { find: jest.Mock; findOne: jest.Mock };
let oauthProviderRepository: {
find: jest.Mock;
findOneByOrFail: jest.Mock;
};
let refreshTokensService: { refreshAndSaveTokens: jest.Mock };
beforeEach(async () => {
connectedAccountRepository = { find: jest.fn(), findOne: jest.fn() };
oauthProviderRepository = {
find: jest.fn().mockResolvedValue([buildProvider()]),
findOneByOrFail: jest.fn().mockResolvedValue(buildProvider()),
};
refreshTokensService = {
refreshAndSaveTokens: jest.fn(async () => ({
accessToken: 'fresh-access',
refreshToken: 'fresh-refresh',
})),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
ApplicationConnectionsListService,
{
provide: ConnectedAccountRefreshTokensService,
useValue: refreshTokensService,
},
{
provide: getRepositoryToken(ConnectedAccountEntity),
useValue: connectedAccountRepository,
},
{
provide: getRepositoryToken(ApplicationOAuthProviderEntity),
useValue: oauthProviderRepository,
},
],
}).compile();
service = module.get(ApplicationConnectionsListService);
});
afterEach(() => jest.clearAllMocks());
describe('list', () => {
it('asks SQL to OR (visibility = workspace) with (visibility = user AND userWorkspaceId = me) when there is a request user', async () => {
connectedAccountRepository.find.mockResolvedValue([
buildAccount({ id: 'mine' }),
buildAccount({
id: 'shared',
visibility: 'workspace',
userWorkspaceId: OTHER_USER_WORKSPACE_ID,
}),
]);
const result = await service.list({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
filter: {},
});
expect(result.map((c) => c.id).sort()).toEqual(['mine', 'shared']);
expect(connectedAccountRepository.find).toHaveBeenCalledWith({
where: [
expect.objectContaining({ visibility: 'workspace' }),
expect.objectContaining({
visibility: 'user',
userWorkspaceId: REQUEST_USER_WORKSPACE_ID,
}),
],
});
});
it('skips the privacy OR clause when no request user is provided (cron)', async () => {
connectedAccountRepository.find.mockResolvedValue([
buildAccount({ id: 'mine' }),
buildAccount({
id: 'theirs',
userWorkspaceId: OTHER_USER_WORKSPACE_ID,
}),
]);
const result = await service.list({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: null,
filter: {},
});
expect(result.map((c) => c.id).sort()).toEqual(['mine', 'theirs']);
expect(connectedAccountRepository.find).toHaveBeenCalledWith({
where: expect.not.objectContaining({ visibility: expect.anything() }),
});
});
it('respects filter.visibility=user under request-user privacy (regression)', async () => {
// Bug guard: an earlier version OR'd { visibility: 'workspace' } into
// the privacy where regardless of the caller's filter, so requesting
// user-visibility only would silently leak workspace-shared rows back.
connectedAccountRepository.find.mockResolvedValue([buildAccount()]);
await service.list({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
filter: { visibility: 'user' },
});
expect(connectedAccountRepository.find).toHaveBeenCalledWith({
where: expect.objectContaining({
visibility: 'user',
userWorkspaceId: REQUEST_USER_WORKSPACE_ID,
}),
});
// Specifically not the OR shape — single AND object.
const passed = connectedAccountRepository.find.mock.calls[0][0];
expect(Array.isArray(passed.where)).toBe(false);
});
it('respects filter.visibility=workspace under request-user privacy', async () => {
connectedAccountRepository.find.mockResolvedValue([buildAccount()]);
await service.list({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
filter: { visibility: 'workspace' },
});
const passed = connectedAccountRepository.find.mock.calls[0][0];
expect(passed.where).toEqual(
expect.objectContaining({ visibility: 'workspace' }),
);
expect(passed.where).not.toHaveProperty('userWorkspaceId');
expect(Array.isArray(passed.where)).toBe(false);
});
it('passes filter.visibility through unchanged in cron context', async () => {
connectedAccountRepository.find.mockResolvedValue([buildAccount()]);
await service.list({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: null,
filter: { visibility: 'user' },
});
expect(connectedAccountRepository.find).toHaveBeenCalledWith({
where: expect.objectContaining({ visibility: 'user' }),
});
});
it('returns empty list when filter.providerName matches no provider for this app', async () => {
oauthProviderRepository.find.mockResolvedValue([]);
const result = await service.list({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
filter: { providerName: 'unknown-provider' },
});
expect(result).toEqual([]);
expect(connectedAccountRepository.find).not.toHaveBeenCalled();
});
it('refreshes the access token before returning', async () => {
connectedAccountRepository.find.mockResolvedValue([buildAccount()]);
const result = await service.list({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
filter: {},
});
expect(refreshTokensService.refreshAndSaveTokens).toHaveBeenCalledTimes(
1,
);
expect(result[0].accessToken).toBe('fresh-access');
});
it('exposes provider name and other public fields in the DTO', async () => {
connectedAccountRepository.find.mockResolvedValue([buildAccount()]);
const [connection] = await service.list({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
filter: {},
});
expect(connection).toEqual({
id: 'conn-1',
providerName: 'linear',
name: 'Linear #1',
handle: 'octocat@example.com',
visibility: 'user',
userWorkspaceId: REQUEST_USER_WORKSPACE_ID,
accessToken: 'fresh-access',
scopes: ['read', 'write'],
authFailedAt: null,
});
});
it('falls back to handle when name is null', async () => {
connectedAccountRepository.find.mockResolvedValue([
buildAccount({ name: null }),
]);
const [connection] = await service.list({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
filter: {},
});
expect(connection.name).toBe('octocat@example.com');
});
it('skips a connection when the refresh fails', async () => {
connectedAccountRepository.find.mockResolvedValue([
buildAccount({ id: 'good' }),
buildAccount({ id: 'broken' }),
]);
refreshTokensService.refreshAndSaveTokens
.mockResolvedValueOnce({ accessToken: 'fresh', refreshToken: 'r' })
.mockRejectedValueOnce(new Error('refresh failed'));
const result = await service.list({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
filter: {},
});
expect(result.map((c) => c.id)).toEqual(['good']);
});
it('skips a connection whose provider was deleted (orphan)', async () => {
connectedAccountRepository.find.mockResolvedValue([
buildAccount({
id: 'orphan',
applicationConnectionProviderId: 'gone-provider',
}),
]);
const result = await service.list({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
filter: {},
});
expect(result).toEqual([]);
});
});
describe('getOne', () => {
it('returns the connection when the request user owns it', async () => {
connectedAccountRepository.findOne.mockResolvedValue(buildAccount());
const result = await service.getOne({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
id: 'conn-1',
});
expect(result.id).toBe('conn-1');
expect(result.providerName).toBe('linear');
expect(result.accessToken).toBe('fresh-access');
});
it('returns the connection when visibility is workspace, regardless of owner', async () => {
connectedAccountRepository.findOne.mockResolvedValue(
buildAccount({
visibility: 'workspace',
userWorkspaceId: OTHER_USER_WORKSPACE_ID,
}),
);
const result = await service.getOne({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
id: 'conn-1',
});
expect(result.id).toBe('conn-1');
});
it('throws NotFound when the connection does not exist', async () => {
connectedAccountRepository.findOne.mockResolvedValue(null);
await expect(
service.getOne({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
id: 'missing',
}),
).rejects.toBeInstanceOf(NotFoundException);
});
it('throws NotFound when a request user asks for another user-visibility connection', async () => {
connectedAccountRepository.findOne.mockResolvedValue(
buildAccount({ userWorkspaceId: OTHER_USER_WORKSPACE_ID }),
);
await expect(
service.getOne({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: REQUEST_USER_WORKSPACE_ID,
id: 'conn-1',
}),
).rejects.toBeInstanceOf(NotFoundException);
});
it('returns another user-visibility connection in cron context (no request user)', async () => {
connectedAccountRepository.findOne.mockResolvedValue(
buildAccount({ userWorkspaceId: OTHER_USER_WORKSPACE_ID }),
);
const result = await service.getOne({
applicationId: APP_ID,
workspaceId: WORKSPACE_ID,
requestUserWorkspaceId: null,
id: 'conn-1',
});
expect(result.userWorkspaceId).toBe(OTHER_USER_WORKSPACE_ID);
});
});
});
@@ -3,8 +3,8 @@ import { Args, Query } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { ApplicationConnectionProviderDTO } from 'src/engine/core-modules/application/application-oauth-provider/dtos/application-connection-provider.dto';
import { ApplicationOAuthProviderService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.service';
import { ApplicationConnectionProviderDTO } from 'src/engine/core-modules/application/connection-provider/dtos/application-connection-provider.dto';
import { ConnectionProviderService } from 'src/engine/core-modules/application/connection-provider/connection-provider.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
@@ -14,7 +14,7 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@MetadataResolver(() => ApplicationConnectionProviderDTO)
export class ApplicationConnectionProviderResolver {
constructor(
private readonly oauthProviderService: ApplicationOAuthProviderService,
private readonly oauthProviderService: ConnectionProviderService,
) {}
@Query(() => [ApplicationConnectionProviderDTO])
@@ -37,14 +37,17 @@ export class ApplicationConnectionProviderResolver {
return providers.map((provider) => ({
id: provider.id,
applicationId: provider.applicationId,
type: 'oauth',
type: provider.type,
name: provider.name,
displayName: provider.displayName,
oauth: {
scopes: provider.scopes,
isClientCredentialsConfigured:
credentialsConfiguredByProviderId.get(provider.id) ?? false,
},
oauth:
provider.type === 'oauth' && provider.oauthConfig
? {
scopes: provider.oauthConfig.scopes,
isClientCredentialsConfigured:
credentialsConfiguredByProviderId.get(provider.id) ?? false,
}
: null,
}));
}
}
@@ -1,4 +1,4 @@
export enum ApplicationOAuthProviderExceptionCode {
export enum ConnectionProviderExceptionCode {
PROVIDER_NOT_FOUND = 'PROVIDER_NOT_FOUND',
CLIENT_CREDENTIALS_NOT_CONFIGURED = 'CLIENT_CREDENTIALS_NOT_CONFIGURED',
TOKEN_EXCHANGE_FAILED = 'TOKEN_EXCHANGE_FAILED',
@@ -6,4 +6,7 @@ export enum ApplicationOAuthProviderExceptionCode {
INVALID_STATE = 'INVALID_STATE',
INVALID_REQUEST = 'INVALID_REQUEST',
FORBIDDEN = 'FORBIDDEN',
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',
}
@@ -6,15 +6,19 @@ import { Repository } from 'typeorm';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type ApplicationOAuthProviderEntity } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.entity';
import { ApplicationOAuthProviderExceptionCode } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider-exception-code.enum';
import { ApplicationOAuthProviderException } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.exception';
import { ApplicationOAuthProviderService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.service';
import { type TokenExchangeResponse } from 'src/engine/core-modules/application/application-oauth-provider/types/token-exchange-response.type';
import { buildAppOAuthCallbackUrl } from 'src/engine/core-modules/application/application-oauth-provider/utils/build-callback-url.util';
import { computePkceChallenge } from 'src/engine/core-modules/application/application-oauth-provider/utils/compute-pkce-challenge.util';
import { exchangeCodeForToken } from 'src/engine/core-modules/application/application-oauth-provider/utils/exchange-code-for-token.util';
import { generatePkceVerifier } from 'src/engine/core-modules/application/application-oauth-provider/utils/generate-pkce-verifier.util';
import { type ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
import { ConnectionProviderExceptionCode } from 'src/engine/core-modules/application/connection-provider/connection-provider-exception-code.enum';
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 { type TokenExchangeResponse } from 'src/engine/core-modules/application/connection-provider/types/token-exchange-response.type';
import {
assertOAuthProvider,
type OAuthConnectionProvider,
} from 'src/engine/core-modules/application/connection-provider/utils/assert-oauth-provider.util';
import { buildAppOAuthCallbackUrl } from 'src/engine/core-modules/application/connection-provider/utils/build-callback-url.util';
import { computePkceChallenge } from 'src/engine/core-modules/application/connection-provider/utils/compute-pkce-challenge.util';
import { exchangeCodeForToken } from 'src/engine/core-modules/application/connection-provider/utils/exchange-code-for-token.util';
import { generatePkceVerifier } from 'src/engine/core-modules/application/connection-provider/utils/generate-pkce-verifier.util';
import {
type AppOAuthStateJwtPayload,
JwtTokenTypeEnum,
@@ -27,13 +31,10 @@ import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-ac
const STATE_JWT_EXPIRES_IN = '10m';
type AuthorizeArgs = {
applicationOAuthProvider: ApplicationOAuthProviderEntity;
connectionProvider: ConnectionProviderEntity;
workspaceId: string;
userId: string;
userWorkspaceId: string;
// Connection-row visibility: 'user' = private to userWorkspaceId,
// 'workspace' = shared with all members. Distinct from OAuth `scopes`
// on the row, which are the upstream-granted permissions.
visibility: 'user' | 'workspace';
reconnectingConnectedAccountId: string | null;
redirectLocation: string | null;
@@ -52,13 +53,11 @@ type CallbackResult = {
};
@Injectable()
export class ApplicationOAuthProviderFlowService {
private readonly logger = new Logger(
ApplicationOAuthProviderFlowService.name,
);
export class ConnectionProviderOAuthFlowService {
private readonly logger = new Logger(ConnectionProviderOAuthFlowService.name);
constructor(
private readonly oauthProviderService: ApplicationOAuthProviderService,
private readonly oauthProviderService: ConnectionProviderService,
private readonly jwtWrapperService: JwtWrapperService,
private readonly secureHttpClientService: SecureHttpClientService,
private readonly twentyConfigService: TwentyConfigService,
@@ -69,45 +68,42 @@ export class ApplicationOAuthProviderFlowService {
async startAuthorizationFlow(
args: AuthorizeArgs,
): Promise<{ authorizationUrl: string }> {
const { applicationOAuthProvider, workspaceId, userId, userWorkspaceId } =
args;
const { connectionProvider, workspaceId, userId, userWorkspaceId } = args;
// Reconnect can only target a row that lives in the requesting workspace
// *and* belongs to the same provider. Without this check, a caller could
// pass any connectedAccount id from any workspace; persist() filters its
// UPDATE by workspaceId so nothing would be written, but the subsequent
// findOneByOrFail (and the redirect URL we build from it) would happily
// surface stale fields from the foreign row. Fail fast at authorize time
// so the user sees the error before the upstream OAuth round-trip.
assertOAuthProvider(connectionProvider);
// Reconnect target must live in the requesting workspace and belong to
// the same provider — without this guard a foreign id would silently
// leak through findOneByOrFail later in the flow.
if (isDefined(args.reconnectingConnectedAccountId)) {
const target = await this.connectedAccountRepository.findOne({
where: {
id: args.reconnectingConnectedAccountId,
workspaceId,
applicationConnectionProviderId: applicationOAuthProvider.id,
connectionProviderId: connectionProvider.id,
},
});
if (!isDefined(target)) {
throw new ApplicationOAuthProviderException(
throw new ConnectionProviderException(
`Cannot reconnect connectedAccount ${args.reconnectingConnectedAccountId}: not found in this workspace for the requested provider.`,
ApplicationOAuthProviderExceptionCode.FORBIDDEN,
ConnectionProviderExceptionCode.FORBIDDEN,
);
}
}
const { clientId } = await this.oauthProviderService.getClientCredentials(
applicationOAuthProvider,
);
const { clientId } =
await this.oauthProviderService.getClientCredentials(connectionProvider);
const codeVerifier = applicationOAuthProvider.usePkce
? generatePkceVerifier()
: null;
const { authorizationEndpoint, scopes, authorizationParams, usePkce } =
connectionProvider.oauthConfig;
const codeVerifier = usePkce ? generatePkceVerifier() : null;
const state = this.signState({
sub: applicationOAuthProvider.id,
sub: connectionProvider.id,
type: JwtTokenTypeEnum.APP_OAUTH_STATE,
applicationOAuthProviderId: applicationOAuthProvider.id,
connectionProviderId: connectionProvider.id,
workspaceId,
userId,
userWorkspaceId,
@@ -119,17 +115,12 @@ export class ApplicationOAuthProviderFlowService {
const callbackUrl = buildAppOAuthCallbackUrl(this.getServerUrl());
const authorizationUrl = new URL(
applicationOAuthProvider.authorizationEndpoint,
);
const authorizationUrl = new URL(authorizationEndpoint);
authorizationUrl.searchParams.set('client_id', clientId);
authorizationUrl.searchParams.set('redirect_uri', callbackUrl);
authorizationUrl.searchParams.set('response_type', 'code');
authorizationUrl.searchParams.set(
'scope',
applicationOAuthProvider.scopes.join(' '),
);
authorizationUrl.searchParams.set('scope', scopes.join(' '));
authorizationUrl.searchParams.set('state', state);
if (codeVerifier) {
@@ -140,9 +131,7 @@ export class ApplicationOAuthProviderFlowService {
authorizationUrl.searchParams.set('code_challenge_method', 'S256');
}
for (const [key, value] of Object.entries(
applicationOAuthProvider.authorizationParams ?? {},
)) {
for (const [key, value] of Object.entries(authorizationParams ?? {})) {
authorizationUrl.searchParams.set(key, value);
}
@@ -153,9 +142,11 @@ export class ApplicationOAuthProviderFlowService {
const statePayload = this.verifyState(args.state);
const provider = await this.oauthProviderService.findOneByIdOrThrow(
statePayload.applicationOAuthProviderId,
statePayload.connectionProviderId,
);
assertOAuthProvider(provider);
const { clientId, clientSecret } =
await this.oauthProviderService.getClientCredentials(provider);
@@ -166,22 +157,22 @@ export class ApplicationOAuthProviderFlowService {
try {
tokenResponse = await exchangeCodeForToken({
fetchFn: this.secureHttpClientService.createSsrfSafeFetch(),
tokenEndpoint: provider.tokenEndpoint,
tokenEndpoint: provider.oauthConfig.tokenEndpoint,
clientId,
clientSecret,
code: args.code,
redirectUri: callbackUrl,
codeVerifier: statePayload.codeVerifier,
contentType: provider.tokenRequestContentType,
contentType: provider.oauthConfig.tokenRequestContentType,
});
} catch (error) {
this.logger.error(
`OAuth token exchange failed for provider ${provider.id}: ${(error as Error).message}`,
);
throw new ApplicationOAuthProviderException(
throw new ConnectionProviderException(
(error as Error).message,
ApplicationOAuthProviderExceptionCode.TOKEN_EXCHANGE_FAILED,
ConnectionProviderExceptionCode.TOKEN_EXCHANGE_FAILED,
);
}
@@ -231,9 +222,9 @@ export class ApplicationOAuthProviderFlowService {
`Rejected OAuth state: ${(error as Error).message ?? 'unknown reason'}`,
);
throw new ApplicationOAuthProviderException(
throw new ConnectionProviderException(
'OAuth state signature invalid or expired',
ApplicationOAuthProviderExceptionCode.INVALID_STATE,
ConnectionProviderExceptionCode.INVALID_STATE,
);
}
}
@@ -242,9 +233,6 @@ export class ApplicationOAuthProviderFlowService {
return this.twentyConfigService.get('SERVER_URL');
}
// Reconnect updates an existing row (preserves the id so logic-function
// bindings via id keep working). New connections always create — multiple
// credentials per (user, provider) are now allowed and intentional.
private async persistConnectedAccount({
provider,
tokenResponse,
@@ -253,7 +241,7 @@ export class ApplicationOAuthProviderFlowService {
visibility,
reconnectingConnectedAccountId,
}: {
provider: ApplicationOAuthProviderEntity;
provider: OAuthConnectionProvider;
tokenResponse: TokenExchangeResponse;
workspaceId: string;
userWorkspaceId: string;
@@ -263,17 +251,14 @@ export class ApplicationOAuthProviderFlowService {
const sharedFields = {
accessToken: tokenResponse.accessToken,
refreshToken: tokenResponse.refreshToken,
scopes: tokenResponse.scopes ?? provider.scopes,
scopes: tokenResponse.scopes ?? provider.oauthConfig.scopes,
lastCredentialsRefreshedAt: new Date(),
authFailedAt: null,
};
if (isDefined(reconnectingConnectedAccountId)) {
// Workspace-scope BOTH the update and the read a foreign-id passed
// through here (the authorize-time guard should have caught it) would
// otherwise update zero rows but still return the foreign row from
// findOneByOrFail({ id }), making a silently-failed reconnect look
// successful.
// Workspace-scope both the update and the read so a foreign id can't
// leak through findOneByOrFail.
await this.connectedAccountRepository.update(
{ id: reconnectingConnectedAccountId, workspaceId },
sharedFields,
@@ -286,10 +271,9 @@ export class ApplicationOAuthProviderFlowService {
}
const existingCount = await this.connectedAccountRepository.count({
where: { applicationConnectionProviderId: provider.id, workspaceId },
where: { connectionProviderId: provider.id, workspaceId },
});
// Auto-generated default — the user can rename from the app settings tab.
const name = `${provider.displayName} #${existingCount + 1}`;
const created = this.connectedAccountRepository.create({
@@ -300,7 +284,7 @@ export class ApplicationOAuthProviderFlowService {
provider: ConnectedAccountProvider.APP,
workspaceId,
applicationId: provider.applicationId,
applicationConnectionProviderId: provider.id,
connectionProviderId: provider.id,
userWorkspaceId,
});
@@ -6,10 +6,10 @@ import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { ApplicationOAuthProviderFlowService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider-flow.service';
import { ApplicationOAuthProviderExceptionCode } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider-exception-code.enum';
import { ApplicationOAuthProviderException } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.exception';
import { ApplicationOAuthProviderService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.service';
import { ConnectionProviderOAuthFlowService } from 'src/engine/core-modules/application/connection-provider/connection-provider-oauth-flow.service';
import { ConnectionProviderExceptionCode } from 'src/engine/core-modules/application/connection-provider/connection-provider-exception-code.enum';
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 {
AuthException,
AuthExceptionCode,
@@ -25,12 +25,12 @@ import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
@Controller('apps/oauth')
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
export class ApplicationOAuthProviderController {
private readonly logger = new Logger(ApplicationOAuthProviderController.name);
export class ConnectionProviderOAuthController {
private readonly logger = new Logger(ConnectionProviderOAuthController.name);
constructor(
private readonly oauthProviderService: ApplicationOAuthProviderService,
private readonly oauthProviderFlowService: ApplicationOAuthProviderFlowService,
private readonly oauthProviderService: ConnectionProviderService,
private readonly oauthProviderFlowService: ConnectionProviderOAuthFlowService,
private readonly transientTokenService: TransientTokenService,
private readonly workspaceDomainsService: WorkspaceDomainsService,
private readonly guardRedirectService: GuardRedirectService,
@@ -60,9 +60,9 @@ export class ApplicationOAuthProviderController {
try {
if (!applicationId || !providerName || !transientToken) {
throw new ApplicationOAuthProviderException(
throw new ConnectionProviderException(
'Missing required query parameters: applicationId, providerName, transientToken',
ApplicationOAuthProviderExceptionCode.INVALID_REQUEST,
ConnectionProviderExceptionCode.INVALID_REQUEST,
);
}
@@ -71,9 +71,9 @@ export class ApplicationOAuthProviderController {
visibility !== 'user' &&
visibility !== 'workspace'
) {
throw new ApplicationOAuthProviderException(
throw new ConnectionProviderException(
`Invalid visibility "${visibility}" — must be 'user' or 'workspace'`,
ApplicationOAuthProviderExceptionCode.INVALID_REQUEST,
ConnectionProviderExceptionCode.INVALID_REQUEST,
);
}
@@ -105,16 +105,16 @@ export class ApplicationOAuthProviderController {
});
if (!provider) {
throw new ApplicationOAuthProviderException(
throw new ConnectionProviderException(
`OAuth provider "${providerName}" not found for application ${applicationId}`,
ApplicationOAuthProviderExceptionCode.PROVIDER_NOT_FOUND,
ConnectionProviderExceptionCode.PROVIDER_NOT_FOUND,
);
}
if (provider.workspaceId !== workspaceId) {
throw new ApplicationOAuthProviderException(
throw new ConnectionProviderException(
'OAuth provider does not belong to the requesting workspace',
ApplicationOAuthProviderExceptionCode.FORBIDDEN,
ConnectionProviderExceptionCode.FORBIDDEN,
);
}
@@ -131,7 +131,7 @@ export class ApplicationOAuthProviderController {
const { authorizationUrl } =
await this.oauthProviderFlowService.startAuthorizationFlow({
applicationOAuthProvider: provider,
connectionProvider: provider,
workspaceId,
userId,
userWorkspaceId: userWorkspace.id,
@@ -197,9 +197,9 @@ export class ApplicationOAuthProviderController {
});
if (!workspace) {
throw new ApplicationOAuthProviderException(
throw new ConnectionProviderException(
`Workspace ${workspaceId} not found after OAuth callback`,
ApplicationOAuthProviderExceptionCode.PROVIDER_NOT_FOUND,
ConnectionProviderExceptionCode.PROVIDER_NOT_FOUND,
);
}
@@ -0,0 +1,47 @@
import {
type ConnectionProviderType,
type StoredOAuthConnectionProviderConfig,
} from 'twenty-shared/application';
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
@Entity({ name: 'connectionProvider', schema: 'core' })
@Unique('IDX_CONNECTION_PROVIDER_NAME_APPLICATION_UNIQUE', [
'name',
'applicationId',
])
@Index('IDX_CONNECTION_PROVIDER_APPLICATION_ID', ['applicationId'])
export class ConnectionProviderEntity
extends SyncableEntity
implements Required<ConnectionProviderEntity>
{
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: false, type: 'varchar' })
name: string;
@Column({ nullable: false, type: 'varchar' })
displayName: string;
@Column({ nullable: false, type: 'varchar' })
type: ConnectionProviderType;
@Column({ nullable: true, type: 'jsonb' })
oauthConfig: StoredOAuthConnectionProviderConfig | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
}
@@ -0,0 +1,49 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { ConnectionProviderExceptionCode } from 'src/engine/core-modules/application/connection-provider/connection-provider-exception-code.enum';
import { CustomException } from 'src/utils/custom-exception';
const getConnectionProviderExceptionUserFriendlyMessage = (
code: ConnectionProviderExceptionCode,
) => {
switch (code) {
case ConnectionProviderExceptionCode.PROVIDER_NOT_FOUND:
return msg`OAuth provider not found.`;
case ConnectionProviderExceptionCode.CLIENT_CREDENTIALS_NOT_CONFIGURED:
return msg`Client credentials are not configured for this OAuth provider.`;
case ConnectionProviderExceptionCode.TOKEN_EXCHANGE_FAILED:
return msg`Failed to exchange the authorization code for an access token.`;
case ConnectionProviderExceptionCode.REFRESH_FAILED:
return msg`Failed to refresh the access token.`;
case ConnectionProviderExceptionCode.INVALID_STATE:
return msg`The OAuth state parameter is invalid or expired.`;
case ConnectionProviderExceptionCode.INVALID_REQUEST:
return msg`The OAuth request is missing required parameters.`;
case ConnectionProviderExceptionCode.FORBIDDEN:
return msg`Not authorized to access this OAuth provider.`;
case ConnectionProviderExceptionCode.INVALID_CONNECTION_PROVIDER_INPUT:
return msg`The connection-provider manifest is missing required fields.`;
case ConnectionProviderExceptionCode.CONNECTION_PROVIDER_NOT_FOUND:
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.`;
default:
assertUnreachable(code);
}
};
export class ConnectionProviderException extends CustomException<ConnectionProviderExceptionCode> {
constructor(
message: string,
code: ConnectionProviderExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ??
getConnectionProviderExceptionUserFriendlyMessage(code),
});
}
}
@@ -1,10 +1,10 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationConnectionProviderResolver } from 'src/engine/core-modules/application/application-oauth-provider/application-connection-provider.resolver';
import { ApplicationOAuthProviderEntity } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.entity';
import { ApplicationOAuthProviderFlowService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider-flow.service';
import { ApplicationOAuthProviderService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.service';
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 { 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';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
@@ -12,11 +12,12 @@ import { SecretEncryptionModule } from 'src/engine/core-modules/secret-encryptio
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { FlatConnectionProviderModule } from 'src/engine/metadata-modules/flat-connection-provider/flat-connection-provider.module';
@Module({
imports: [
TypeOrmModule.forFeature([
ApplicationOAuthProviderEntity,
ConnectionProviderEntity,
ApplicationEntity,
ApplicationRegistrationVariableEntity,
ConnectedAccountEntity,
@@ -25,15 +26,13 @@ import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-ac
SecretEncryptionModule,
SecureHttpClientModule,
TwentyConfigModule,
FlatConnectionProviderModule,
],
providers: [
ApplicationOAuthProviderService,
ApplicationOAuthProviderFlowService,
ConnectionProviderService,
ConnectionProviderOAuthFlowService,
ApplicationConnectionProviderResolver,
],
exports: [
ApplicationOAuthProviderService,
ApplicationOAuthProviderFlowService,
],
exports: [ConnectionProviderService, ConnectionProviderOAuthFlowService],
})
export class ApplicationOAuthProviderModule {}
export class ConnectionProviderModule {}
@@ -0,0 +1,208 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { In, Repository } from 'typeorm';
import { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
import { ConnectionProviderExceptionCode } from 'src/engine/core-modules/application/connection-provider/connection-provider-exception-code.enum';
import { ConnectionProviderException } from 'src/engine/core-modules/application/connection-provider/connection-provider.exception';
import { assertOAuthProvider } from 'src/engine/core-modules/application/connection-provider/utils/assert-oauth-provider.util';
import { ApplicationRegistrationVariableEntity } from 'src/engine/core-modules/application/application-registration-variable/application-registration-variable.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { SecretEncryptionService } from 'src/engine/core-modules/secret-encryption/secret-encryption.service';
@Injectable()
export class ConnectionProviderService {
constructor(
@InjectRepository(ConnectionProviderEntity)
private readonly connectionProviderRepository: Repository<ConnectionProviderEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
@InjectRepository(ApplicationRegistrationVariableEntity)
private readonly registrationVariableRepository: Repository<ApplicationRegistrationVariableEntity>,
private readonly secretEncryptionService: SecretEncryptionService,
) {}
async getClientCredentials(
provider: ConnectionProviderEntity,
): Promise<{ clientId: string; clientSecret: string }> {
assertOAuthProvider(provider);
const application = await this.applicationRepository.findOneBy({
id: provider.applicationId,
});
if (!isDefined(application?.applicationRegistrationId)) {
throw new ConnectionProviderException(
`Application ${provider.applicationId} has no registration; OAuth client credentials cannot be resolved`,
ConnectionProviderExceptionCode.CLIENT_CREDENTIALS_NOT_CONFIGURED,
);
}
const { clientIdVariable, clientSecretVariable } = provider.oauthConfig;
const variables = await this.registrationVariableRepository.find({
where: {
applicationRegistrationId: application.applicationRegistrationId,
key: In([clientIdVariable, clientSecretVariable]),
},
});
const valuesByKey = new Map(
variables.map((v) => [
v.key,
v.encryptedValue
? this.secretEncryptionService.decrypt(v.encryptedValue)
: '',
]),
);
const clientId = valuesByKey.get(clientIdVariable) ?? '';
const clientSecret = valuesByKey.get(clientSecretVariable) ?? '';
if (!clientId || !clientSecret) {
throw new ConnectionProviderException(
`OAuth client credentials are not configured for provider "${provider.name}". The server administrator needs to fill in "${clientIdVariable}" and "${clientSecretVariable}" on the application registration.`,
ConnectionProviderExceptionCode.CLIENT_CREDENTIALS_NOT_CONFIGURED,
);
}
return { clientId, clientSecret };
}
async areClientCredentialsConfigured(
provider: ConnectionProviderEntity,
): Promise<boolean> {
const result = await this.areClientCredentialsConfiguredBatch([provider]);
return result.get(provider.id) ?? false;
}
async areClientCredentialsConfiguredBatch(
providers: ConnectionProviderEntity[],
): Promise<Map<string, boolean>> {
const result = new Map<string, boolean>();
if (providers.length === 0) {
return result;
}
const oauthProviders = providers.filter(
(p) => p.type === 'oauth' && isDefined(p.oauthConfig),
);
for (const provider of providers) {
result.set(provider.id, false);
}
if (oauthProviders.length === 0) {
return result;
}
const applicationIds = [
...new Set(oauthProviders.map((p) => p.applicationId)),
];
const applications = await this.applicationRepository.find({
where: { id: In(applicationIds) },
});
const registrationIdByApplicationId = new Map(
applications.map((app) => [app.id, app.applicationRegistrationId]),
);
const registrationIds = [
...new Set(
applications
.map((app) => app.applicationRegistrationId)
.filter(isDefined),
),
];
if (registrationIds.length === 0) {
return result;
}
const allKeys = oauthProviders.flatMap((p) => [
p.oauthConfig!.clientIdVariable,
p.oauthConfig!.clientSecretVariable,
]);
const variables = await this.registrationVariableRepository.find({
where: {
applicationRegistrationId: In(registrationIds),
key: In(allKeys),
},
});
const filledKeysByRegistrationId = new Map<string, Set<string>>();
for (const variable of variables) {
if (variable.encryptedValue === '') continue;
const set =
filledKeysByRegistrationId.get(variable.applicationRegistrationId) ??
new Set<string>();
set.add(variable.key);
filledKeysByRegistrationId.set(variable.applicationRegistrationId, set);
}
for (const provider of oauthProviders) {
const registrationId = registrationIdByApplicationId.get(
provider.applicationId,
);
if (!isDefined(registrationId)) {
continue;
}
const filled = filledKeysByRegistrationId.get(registrationId);
const { clientIdVariable, clientSecretVariable } = provider.oauthConfig!;
result.set(
provider.id,
filled?.has(clientIdVariable) === true &&
filled?.has(clientSecretVariable) === true,
);
}
return result;
}
async findOneByApplicationAndName({
applicationId,
name,
}: {
applicationId: string;
name: string;
}): Promise<ConnectionProviderEntity | null> {
return this.connectionProviderRepository.findOne({
where: { applicationId, name },
});
}
async findOneByIdOrThrow(id: string): Promise<ConnectionProviderEntity> {
const provider = await this.connectionProviderRepository.findOne({
where: { id },
});
if (!isDefined(provider)) {
throw new ConnectionProviderException(
`Connection provider with id "${id}" not found`,
ConnectionProviderExceptionCode.PROVIDER_NOT_FOUND,
);
}
return provider;
}
async findManyByApplication({
applicationId,
workspaceId,
}: {
applicationId: string;
workspaceId: string;
}): Promise<ConnectionProviderEntity[]> {
return this.connectionProviderRepository.find({
where: { applicationId, workspaceId },
});
}
}
@@ -14,10 +14,10 @@ import {
import { Request } from 'express';
import { isDefined } from 'twenty-shared/utils';
import { type AppConnectionDto } from 'src/engine/core-modules/application/application-oauth-provider/connections/dtos/app-connection.dto';
import { GetAppConnectionDto } from 'src/engine/core-modules/application/application-oauth-provider/connections/dtos/get-app-connection.dto';
import { ListAppConnectionsDto } from 'src/engine/core-modules/application/application-oauth-provider/connections/dtos/list-app-connections.dto';
import { ApplicationConnectionsListService } from 'src/engine/core-modules/application/application-oauth-provider/connections/services/application-connections-list.service';
import { type AppConnectionDto } from 'src/engine/core-modules/application/connection-provider/connections/dtos/app-connection.dto';
import { GetAppConnectionDto } from 'src/engine/core-modules/application/connection-provider/connections/dtos/get-app-connection.dto';
import { ListAppConnectionsDto } from 'src/engine/core-modules/application/connection-provider/connections/dtos/list-app-connections.dto';
import { ApplicationConnectionsListService } from 'src/engine/core-modules/application/connection-provider/connections/services/application-connections-list.service';
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@@ -1,9 +1,9 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationOAuthProviderEntity } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.entity';
import { ApplicationConnectionsController } from 'src/engine/core-modules/application/application-oauth-provider/connections/application-connections.controller';
import { ApplicationConnectionsListService } from 'src/engine/core-modules/application/application-oauth-provider/connections/services/application-connections-list.service';
import { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
import { ApplicationConnectionsController } from 'src/engine/core-modules/application/connection-provider/connections/application-connections.controller';
import { ApplicationConnectionsListService } from 'src/engine/core-modules/application/connection-provider/connections/services/application-connections-list.service';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@@ -11,13 +11,13 @@ import { RefreshTokensManagerModule } from 'src/modules/connected-account/refres
// Top-level consumer: depends on RefreshTokensManagerModule (which itself
// imports the engine-side AppOAuthRefreshModule). Kept separate from
// ApplicationOAuthProviderModule to avoid the import cycle. TokenModule +
// ConnectionProviderModule to avoid the import cycle. TokenModule +
// WorkspaceCacheStorageModule are pulled in for the controller's JwtAuthGuard.
@Module({
imports: [
TypeOrmModule.forFeature([
ConnectedAccountEntity,
ApplicationOAuthProviderEntity,
ConnectionProviderEntity,
]),
TokenModule,
WorkspaceCacheStorageModule,
@@ -6,8 +6,8 @@ import { type FindOptionsWhere, In, Repository } from 'typeorm';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationOAuthProviderEntity } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.entity';
import { type AppConnectionDto } from 'src/engine/core-modules/application/application-oauth-provider/connections/dtos/app-connection.dto';
import { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
import { type AppConnectionDto } from 'src/engine/core-modules/application/connection-provider/connections/dtos/app-connection.dto';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { ConnectedAccountRefreshTokensService } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service';
@@ -40,8 +40,8 @@ export class ApplicationConnectionsListService {
private readonly refreshTokensService: ConnectedAccountRefreshTokensService,
@InjectRepository(ConnectedAccountEntity)
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
@InjectRepository(ApplicationOAuthProviderEntity)
private readonly oauthProviderRepository: Repository<ApplicationOAuthProviderEntity>,
@InjectRepository(ConnectionProviderEntity)
private readonly oauthProviderRepository: Repository<ConnectionProviderEntity>,
) {}
async list({
@@ -72,7 +72,7 @@ export class ApplicationConnectionsListService {
workspaceId,
provider: ConnectedAccountProvider.APP,
...(isDefined(providerIds)
? { applicationConnectionProviderId: In(providerIds) }
? { connectionProviderId: In(providerIds) }
: {}),
...(isDefined(filter.userWorkspaceId)
? { userWorkspaceId: filter.userWorkspaceId }
@@ -126,12 +126,12 @@ export class ApplicationConnectionsListService {
throw new NotFoundException(`Connection ${id} not found`);
}
if (!isDefined(account.applicationConnectionProviderId)) {
if (!isDefined(account.connectionProviderId)) {
throw new NotFoundException(`Connection ${id} has no provider`);
}
const provider = await this.oauthProviderRepository.findOneByOrFail({
id: account.applicationConnectionProviderId,
id: account.connectionProviderId,
workspaceId,
});
@@ -150,14 +150,6 @@ export class ApplicationConnectionsListService {
return dto;
}
// Composes the caller's `visibility` filter with the per-request privacy
// rule. Always returns a TypeORM where (single object = AND, array = OR)
// so the caller doesn't have to branch.
//
// The earlier inline version OR'd `{ ...baseWhere, visibility: 'workspace' }`
// with `{ ...baseWhere, userWorkspaceId: me }` regardless of caller intent,
// which silently overrode an explicit `filter.visibility: 'user'` (the
// first OR branch always returned workspace-shared rows).
private buildPrivacyWhere(
baseWhere: FindOptionsWhere<ConnectedAccountEntity>,
requestUserWorkspaceId: string | null,
@@ -165,15 +157,12 @@ export class ApplicationConnectionsListService {
):
| FindOptionsWhere<ConnectedAccountEntity>
| FindOptionsWhere<ConnectedAccountEntity>[] {
// Cron / DB-event triggers carry no user — the app is trusted to use
// its own criteria, so honour the visibility filter as-is.
if (!isDefined(requestUserWorkspaceId)) {
return isDefined(visibilityFilter)
? { ...baseWhere, visibility: visibilityFilter }
: baseWhere;
}
// Caller asked for user-visibility only → must be theirs.
if (visibilityFilter === 'user') {
return {
...baseWhere,
@@ -182,14 +171,10 @@ export class ApplicationConnectionsListService {
};
}
// Caller asked for workspace-shared only → no per-user restriction
// (workspace-shared credentials are visible to everyone in the workspace).
if (visibilityFilter === 'workspace') {
return { ...baseWhere, visibility: 'workspace' };
}
// No visibility filter → return both: every workspace-shared row, plus
// the request user's own user-visibility rows.
return [
{ ...baseWhere, visibility: 'workspace' },
{
@@ -203,10 +188,10 @@ export class ApplicationConnectionsListService {
private async refreshAndMap(
account: ConnectedAccountEntity,
workspaceId: string,
providerById: Map<string, ApplicationOAuthProviderEntity>,
providerById: Map<string, ConnectionProviderEntity>,
): Promise<AppConnectionDto | null> {
const provider = isDefined(account.applicationConnectionProviderId)
? providerById.get(account.applicationConnectionProviderId)
const provider = isDefined(account.connectionProviderId)
? providerById.get(account.connectionProviderId)
: undefined;
// Connections without a resolvable provider can't be refreshed and the
@@ -215,7 +200,7 @@ export class ApplicationConnectionsListService {
// ON DELETE CASCADE when the provider is removed.
if (!isDefined(provider)) {
this.logger.warn(
`Connection ${account.id} references missing provider ${account.applicationConnectionProviderId}`,
`Connection ${account.id} references missing provider ${account.connectionProviderId}`,
);
return null;
@@ -235,7 +220,7 @@ export class ApplicationConnectionsListService {
visibility: account.visibility as 'user' | 'workspace',
userWorkspaceId: account.userWorkspaceId,
accessToken: tokens.accessToken,
scopes: account.scopes ?? provider.scopes,
scopes: account.scopes ?? provider.oauthConfig?.scopes ?? [],
authFailedAt: account.authFailedAt?.toISOString() ?? null,
};
} catch (error) {
@@ -1,14 +1,14 @@
import { Module } from '@nestjs/common';
import { ApplicationOAuthProviderModule } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.module';
import { AppOAuthRefreshAccessTokenService } from 'src/engine/core-modules/application/application-oauth-provider/refresh/services/app-oauth-refresh-tokens.service';
import { AppOAuthRevokeService } from 'src/engine/core-modules/application/application-oauth-provider/refresh/services/app-oauth-revoke.service';
import { ConnectionProviderModule } from 'src/engine/core-modules/application/connection-provider/connection-provider.module';
import { AppOAuthRefreshAccessTokenService } from 'src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-refresh-tokens.service';
import { AppOAuthRevokeService } from 'src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-revoke.service';
import { ApplicationVariableEntityModule } from 'src/engine/core-modules/application/application-variable/application-variable.module';
import { SecureHttpClientModule } from 'src/engine/core-modules/secure-http-client/secure-http-client.module';
@Module({
imports: [
ApplicationOAuthProviderModule,
ConnectionProviderModule,
ApplicationVariableEntityModule,
SecureHttpClientModule,
],
@@ -2,11 +2,12 @@ import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationOAuthProviderException } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.exception';
import { ApplicationOAuthProviderService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.service';
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 { assertOAuthProvider } from 'src/engine/core-modules/application/connection-provider/utils/assert-oauth-provider.util';
import { type ConnectedAccountTokens } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service';
import { exchangeRefreshTokenForToken } from 'src/engine/core-modules/application/application-oauth-provider/utils/exchange-refresh-token-for-token.util';
import { OAuthTokenEndpointError } from 'src/engine/core-modules/application/application-oauth-provider/utils/post-oauth-token-request.util';
import { exchangeRefreshTokenForToken } from 'src/engine/core-modules/application/connection-provider/utils/exchange-refresh-token-for-token.util';
import { OAuthTokenEndpointError } from 'src/engine/core-modules/application/connection-provider/utils/post-oauth-token-request.util';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import {
@@ -19,7 +20,7 @@ export class AppOAuthRefreshAccessTokenService {
private readonly logger = new Logger(AppOAuthRefreshAccessTokenService.name);
constructor(
private readonly applicationOAuthProviderService: ApplicationOAuthProviderService,
private readonly connectionProviderService: ConnectionProviderService,
private readonly secureHttpClientService: SecureHttpClientService,
) {}
@@ -27,52 +28,31 @@ export class AppOAuthRefreshAccessTokenService {
connectedAccount: ConnectedAccountEntity,
refreshToken: string,
): Promise<ConnectedAccountTokens> {
if (!isDefined(connectedAccount.applicationConnectionProviderId)) {
if (!isDefined(connectedAccount.connectionProviderId)) {
throw new ConnectedAccountRefreshAccessTokenException(
`Connected account ${connectedAccount.id} has no applicationConnectionProviderId`,
`Connected account ${connectedAccount.id} has no connectionProviderId`,
ConnectedAccountRefreshAccessTokenExceptionCode.PROVIDER_NOT_SUPPORTED,
);
}
let provider, clientId, clientSecret;
try {
provider = await this.applicationOAuthProviderService.findOneByIdOrThrow(
connectedAccount.applicationConnectionProviderId,
);
({ clientId, clientSecret } =
await this.applicationOAuthProviderService.getClientCredentials(
provider,
));
} catch (error) {
// Provider lookup or credential resolution failed (provider deleted,
// server admin hasn't filled in client_id/secret). Translate so callers
// see one exception class regardless of provider.
if (error instanceof ApplicationOAuthProviderException) {
throw new ConnectedAccountRefreshAccessTokenException(
error.message,
ConnectedAccountRefreshAccessTokenExceptionCode.PROVIDER_NOT_SUPPORTED,
);
}
throw error;
}
const { provider, clientId, clientSecret } = await this.resolveProvider(
connectedAccount.connectionProviderId,
);
try {
const tokenResponse = await exchangeRefreshTokenForToken({
fetchFn: this.secureHttpClientService.createSsrfSafeFetch(),
tokenEndpoint: provider.tokenEndpoint,
tokenEndpoint: provider.oauthConfig.tokenEndpoint,
clientId,
clientSecret,
refreshToken,
contentType: provider.tokenRequestContentType,
contentType: provider.oauthConfig.tokenRequestContentType,
});
return {
accessToken: tokenResponse.accessToken,
// Some providers (e.g. Google) keep the refresh token stable across
// refreshes; others rotate. Fall back to the original when the
// response omits one.
// Fall back to the original when the response omits one — some
// providers don't rotate refresh tokens.
refreshToken: tokenResponse.refreshToken ?? refreshToken,
};
} catch (error) {
@@ -80,9 +60,8 @@ export class AppOAuthRefreshAccessTokenService {
`App OAuth refresh failed for connected account ${connectedAccount.id}: ${(error as Error).message}`,
);
// 5xx and network/transport errors are transient — don't mark the
// credential as permanently invalid. Only 4xx responses from the
// token endpoint (esp. invalid_grant) imply the user must reconnect.
// Only 4xx token-endpoint responses (esp. invalid_grant) imply the
// user must reconnect — 5xx and transport errors stay transient.
const isTransient =
!(error instanceof OAuthTokenEndpointError) || error.status >= 500;
@@ -94,4 +73,29 @@ export class AppOAuthRefreshAccessTokenService {
);
}
}
private async resolveProvider(connectionProviderId: string) {
try {
const provider =
await this.connectionProviderService.findOneByIdOrThrow(
connectionProviderId,
);
assertOAuthProvider(provider);
const { clientId, clientSecret } =
await this.connectionProviderService.getClientCredentials(provider);
return { provider, clientId, clientSecret };
} catch (error) {
if (error instanceof ConnectionProviderException) {
throw new ConnectedAccountRefreshAccessTokenException(
error.message,
ConnectedAccountRefreshAccessTokenExceptionCode.PROVIDER_NOT_SUPPORTED,
);
}
throw error;
}
}
}
@@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationOAuthProviderService } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.service';
import { ConnectionProviderService } from 'src/engine/core-modules/application/connection-provider/connection-provider.service';
import { SecureHttpClientService } from 'src/engine/core-modules/secure-http-client/secure-http-client.service';
import { type ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
@@ -11,18 +11,14 @@ export class AppOAuthRevokeService {
private readonly logger = new Logger(AppOAuthRevokeService.name);
constructor(
private readonly applicationOAuthProviderService: ApplicationOAuthProviderService,
private readonly connectionProviderService: ConnectionProviderService,
private readonly secureHttpClientService: SecureHttpClientService,
) {}
// Best-effort revoke against the provider's `revokeEndpoint` if declared
// in the manifest. Failures are swallowed (logged as warnings) so a
// disconnect always succeeds locally even when the provider is down or
// doesn't support revocation. RFC 7009 form-urlencoded body is the
// de-facto standard.
// Best-effort: failures are logged but never block disconnect.
async revokeIfApp(connectedAccount: ConnectedAccountEntity): Promise<void> {
if (
!isDefined(connectedAccount.applicationConnectionProviderId) ||
!isDefined(connectedAccount.connectionProviderId) ||
!isDefined(connectedAccount.accessToken)
) {
return;
@@ -31,20 +27,22 @@ export class AppOAuthRevokeService {
let provider;
try {
provider = await this.applicationOAuthProviderService.findOneByIdOrThrow(
connectedAccount.applicationConnectionProviderId,
provider = await this.connectionProviderService.findOneByIdOrThrow(
connectedAccount.connectionProviderId,
);
} catch {
return;
}
if (!provider.revokeEndpoint) {
const revokeEndpoint = provider.oauthConfig?.revokeEndpoint;
if (provider.type !== 'oauth' || !isDefined(revokeEndpoint)) {
return;
}
try {
const response = await this.secureHttpClientService.createSsrfSafeFetch()(
provider.revokeEndpoint,
revokeEndpoint,
{
method: 'POST',
headers: {
@@ -1,5 +1,5 @@
import { exchangeCodeForToken } from 'src/engine/core-modules/application/application-oauth-provider/utils/exchange-code-for-token.util';
import { exchangeRefreshTokenForToken } from 'src/engine/core-modules/application/application-oauth-provider/utils/exchange-refresh-token-for-token.util';
import { exchangeCodeForToken } from 'src/engine/core-modules/application/connection-provider/utils/exchange-code-for-token.util';
import { exchangeRefreshTokenForToken } from 'src/engine/core-modules/application/connection-provider/utils/exchange-refresh-token-for-token.util';
const buildResponse = (
json: unknown,
@@ -0,0 +1,22 @@
import { type StoredOAuthConnectionProviderConfig } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
import { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
import { ConnectionProviderExceptionCode } from 'src/engine/core-modules/application/connection-provider/connection-provider-exception-code.enum';
import { ConnectionProviderException } from 'src/engine/core-modules/application/connection-provider/connection-provider.exception';
export type OAuthConnectionProvider = ConnectionProviderEntity & {
type: 'oauth';
oauthConfig: StoredOAuthConnectionProviderConfig;
};
export function assertOAuthProvider(
provider: ConnectionProviderEntity,
): asserts provider is OAuthConnectionProvider {
if (provider.type !== 'oauth' || !isDefined(provider.oauthConfig)) {
throw new ConnectionProviderException(
`Connection provider "${provider.name}" (id ${provider.id}) is not OAuth-typed or has no oauthConfig`,
ConnectionProviderExceptionCode.INVALID_REQUEST,
);
}
}
@@ -1,7 +1,7 @@
import { type OAuthProviderTokenRequestContentType } from 'twenty-shared/application';
import { type TokenExchangeResponse } from 'src/engine/core-modules/application/application-oauth-provider/types/token-exchange-response.type';
import { postOAuthTokenRequest } from 'src/engine/core-modules/application/application-oauth-provider/utils/post-oauth-token-request.util';
import { type TokenExchangeResponse } from 'src/engine/core-modules/application/connection-provider/types/token-exchange-response.type';
import { postOAuthTokenRequest } from 'src/engine/core-modules/application/connection-provider/utils/post-oauth-token-request.util';
type FetchFn = typeof globalThis.fetch;
@@ -1,7 +1,7 @@
import { type OAuthProviderTokenRequestContentType } from 'twenty-shared/application';
import { type TokenExchangeResponse } from 'src/engine/core-modules/application/application-oauth-provider/types/token-exchange-response.type';
import { postOAuthTokenRequest } from 'src/engine/core-modules/application/application-oauth-provider/utils/post-oauth-token-request.util';
import { type TokenExchangeResponse } from 'src/engine/core-modules/application/connection-provider/types/token-exchange-response.type';
import { postOAuthTokenRequest } from 'src/engine/core-modules/application/connection-provider/utils/post-oauth-token-request.util';
type FetchFn = typeof globalThis.fetch;
@@ -1,4 +1,4 @@
import { type TokenExchangeResponse } from 'src/engine/core-modules/application/application-oauth-provider/types/token-exchange-response.type';
import { type TokenExchangeResponse } from 'src/engine/core-modules/application/connection-provider/types/token-exchange-response.type';
export const parseTokenResponse = (
json: Record<string, unknown>,
@@ -1,8 +1,8 @@
import { type OAuthProviderTokenRequestContentType } from 'twenty-shared/application';
import { type TokenExchangeResponse } from 'src/engine/core-modules/application/application-oauth-provider/types/token-exchange-response.type';
import { encodeOAuthBody } from 'src/engine/core-modules/application/application-oauth-provider/utils/encode-oauth-body.util';
import { parseTokenResponse } from 'src/engine/core-modules/application/application-oauth-provider/utils/parse-token-response.util';
import { type TokenExchangeResponse } from 'src/engine/core-modules/application/connection-provider/types/token-exchange-response.type';
import { encodeOAuthBody } from 'src/engine/core-modules/application/connection-provider/utils/encode-oauth-body.util';
import { parseTokenResponse } from 'src/engine/core-modules/application/connection-provider/utils/parse-token-response.util';
type FetchFn = typeof globalThis.fetch;
@@ -8,9 +8,9 @@ import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.enti
import { AppTokenService } from 'src/engine/core-modules/app-token/services/app-token.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { ApplicationOAuthProviderModule } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.module';
import { ApplicationOAuthProviderController } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.controller';
import { ApplicationConnectionsModule } from 'src/engine/core-modules/application/application-oauth-provider/connections/application-connections.module';
import { ConnectionProviderModule } from 'src/engine/core-modules/application/connection-provider/connection-provider.module';
import { ConnectionProviderOAuthController } from 'src/engine/core-modules/application/connection-provider/connection-provider-oauth.controller';
import { ApplicationConnectionsModule } from 'src/engine/core-modules/application/connection-provider/connections/application-connections.module';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
import { GoogleAPIsAuthController } from 'src/engine/core-modules/auth/controllers/google-apis-auth.controller';
import { GoogleAuthController } from 'src/engine/core-modules/auth/controllers/google-auth.controller';
@@ -118,7 +118,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
DomainServerConfigModule,
ApplicationRegistrationModule,
ApplicationModule,
ApplicationOAuthProviderModule,
ConnectionProviderModule,
ApplicationConnectionsModule,
WorkspaceCacheModule,
CoreEntityCacheModule,
@@ -133,7 +133,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
MicrosoftAPIsAuthController,
OAuthPropagatorController,
SSOAuthController,
ApplicationOAuthProviderController,
ConnectionProviderOAuthController,
],
providers: [
SignInUpService,
@@ -145,7 +145,7 @@ export type PostgresProxyTokenJwtPayload = CommonPropertiesJwtPayload & {
export type AppOAuthStateJwtPayload = CommonPropertiesJwtPayload & {
type: JwtTokenTypeEnum.APP_OAUTH_STATE;
workspaceId: string;
applicationOAuthProviderId: string;
connectionProviderId: string;
userId: string;
userWorkspaceId: string;
// 'user' = the resulting credential is private to userWorkspaceId.
@@ -1,7 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppOAuthRefreshModule } from 'src/engine/core-modules/application/application-oauth-provider/refresh/app-oauth-refresh.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';
import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service';
@@ -5,7 +5,7 @@ import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { In, Repository } from 'typeorm';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { AppOAuthRevokeService } from 'src/engine/core-modules/application/application-oauth-provider/refresh/services/app-oauth-revoke.service';
import { AppOAuthRevokeService } from 'src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-revoke.service';
import {
ConnectedAccountException,
ConnectedAccountExceptionCode,
@@ -75,7 +75,7 @@ export class ConnectedAccountDTO {
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
applicationConnectionProviderId: string | null;
connectionProviderId: string | null;
@IsUUID()
@IsOptional()
@@ -14,21 +14,16 @@ import {
import { type ConnectedAccountProvider } from 'twenty-shared/types';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { ApplicationOAuthProviderEntity } from 'src/engine/core-modules/application/application-oauth-provider/application-oauth-provider.entity';
import { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
import { type ImapSmtpCaldavParams } from 'src/engine/core-modules/imap-smtp-caldav-connection/types/imap-smtp-caldav-connection.type';
import { type CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { type MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity';
// Distinguishes who can use this credential. Named `visibility` (not
// `scope`) so it doesn't clash with the OAuth `scopes` array on the same
// row — those are unrelated concepts that used to differ by one letter.
export type ConnectedAccountVisibility = 'user' | 'workspace';
@Entity({ name: 'connectedAccount', schema: 'core' })
@Index('IDX_CONNECTED_ACCOUNT_APP_OAUTH_PROVIDER_ID', [
'applicationConnectionProviderId',
])
@Index('IDX_CONNECTED_ACCOUNT_CONNECTION_PROVIDER_ID', ['connectionProviderId'])
@Index('IDX_CONNECTED_ACCOUNT_APPLICATION_ID', ['applicationId'])
export class ConnectedAccountEntity extends WorkspaceRelatedEntity {
@PrimaryGeneratedColumn('uuid')
@@ -70,15 +65,15 @@ export class ConnectedAccountEntity extends WorkspaceRelatedEntity {
@Column({ type: 'uuid', nullable: false })
userWorkspaceId: string;
@Column({ type: 'uuid', nullable: true, name: 'applicationOAuthProviderId' })
applicationConnectionProviderId: string | null;
@Column({ type: 'uuid', nullable: true })
connectionProviderId: string | null;
@ManyToOne(() => ApplicationOAuthProviderEntity, {
@ManyToOne(() => ConnectionProviderEntity, {
onDelete: 'CASCADE',
nullable: true,
})
@JoinColumn({ name: 'applicationOAuthProviderId' })
applicationConnectionProvider: Relation<ApplicationOAuthProviderEntity> | null;
@JoinColumn({ name: 'connectionProviderId' })
connectionProvider: Relation<ConnectionProviderEntity> | null;
@Column({ type: 'uuid', nullable: true })
applicationId: string | null;
@@ -0,0 +1,7 @@
import { type MetadataEntityPropertyName } from 'src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant';
export const FLAT_CONNECTION_PROVIDER_EDITABLE_PROPERTIES = [
'displayName',
'type',
'oauthConfig',
] as const satisfies MetadataEntityPropertyName<'connectionProvider'>[];
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { WorkspaceFlatConnectionProviderMapCacheService } from 'src/engine/metadata-modules/flat-connection-provider/services/workspace-flat-connection-provider-map-cache.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
@Module({
imports: [
TypeOrmModule.forFeature([ApplicationEntity, ConnectionProviderEntity]),
WorkspaceManyOrAllFlatEntityMapsCacheModule,
],
providers: [WorkspaceFlatConnectionProviderMapCacheService],
exports: [WorkspaceFlatConnectionProviderMapCacheService],
})
export class FlatConnectionProviderModule {}
@@ -0,0 +1,62 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service';
import { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import { type FlatConnectionProviderMaps } from 'src/engine/metadata-modules/flat-connection-provider/types/flat-connection-provider-maps.type';
import { fromConnectionProviderEntityToFlatConnectionProvider } from 'src/engine/metadata-modules/flat-connection-provider/utils/from-connection-provider-entity-to-flat-connection-provider.util';
import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator';
import { createIdToUniversalIdentifierMap } from 'src/engine/workspace-cache/utils/create-id-to-universal-identifier-map.util';
import { addFlatEntityToFlatEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration/utils/add-flat-entity-to-flat-entity-maps-through-mutation-or-throw.util';
@Injectable()
@WorkspaceCache('flatConnectionProviderMaps')
export class WorkspaceFlatConnectionProviderMapCacheService extends WorkspaceCacheProvider<FlatConnectionProviderMaps> {
constructor(
@InjectRepository(ConnectionProviderEntity)
private readonly connectionProviderRepository: Repository<ConnectionProviderEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
) {
super();
}
async computeForCache(
workspaceId: string,
): Promise<FlatConnectionProviderMaps> {
const [connectionProviders, applications] = await Promise.all([
this.connectionProviderRepository.find({
where: { workspaceId },
}),
this.applicationRepository.find({
where: { workspaceId },
select: ['id', 'universalIdentifier'],
}),
]);
const applicationIdToUniversalIdentifierMap =
createIdToUniversalIdentifierMap(applications);
const flatConnectionProviderMaps = createEmptyFlatEntityMaps();
for (const connectionProviderEntity of connectionProviders) {
const flatConnectionProvider =
fromConnectionProviderEntityToFlatConnectionProvider({
entity: connectionProviderEntity,
applicationIdToUniversalIdentifierMap,
});
addFlatEntityToFlatEntityMapsThroughMutationOrThrow({
flatEntity: flatConnectionProvider,
flatEntityMapsToMutate: flatConnectionProviderMaps,
});
}
return flatConnectionProviderMaps;
}
}
@@ -0,0 +1,4 @@
import { type FlatConnectionProvider } from 'src/engine/metadata-modules/flat-connection-provider/types/flat-connection-provider.type';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
export type FlatConnectionProviderMaps = FlatEntityMaps<FlatConnectionProvider>;
@@ -0,0 +1,4 @@
import { type ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
import { type FlatEntityFrom } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-from.type';
export type FlatConnectionProvider = FlatEntityFrom<ConnectionProviderEntity>;
@@ -0,0 +1,39 @@
import { isDefined } from 'twenty-shared/utils';
import {
FlatEntityMapsException,
FlatEntityMapsExceptionCode,
} from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception';
import { type FlatConnectionProvider } from 'src/engine/metadata-modules/flat-connection-provider/types/flat-connection-provider.type';
import { type FromEntityToFlatEntityArgs } from 'src/engine/workspace-cache/types/from-entity-to-flat-entity-args.type';
export const fromConnectionProviderEntityToFlatConnectionProvider = ({
entity: connectionProviderEntity,
applicationIdToUniversalIdentifierMap,
}: FromEntityToFlatEntityArgs<'connectionProvider'>): FlatConnectionProvider => {
const applicationUniversalIdentifier =
applicationIdToUniversalIdentifierMap.get(
connectionProviderEntity.applicationId,
);
if (!isDefined(applicationUniversalIdentifier)) {
throw new FlatEntityMapsException(
`Application with id ${connectionProviderEntity.applicationId} not found for connection provider ${connectionProviderEntity.id}`,
FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
);
}
return {
id: connectionProviderEntity.id,
universalIdentifier: connectionProviderEntity.universalIdentifier,
applicationId: connectionProviderEntity.applicationId,
workspaceId: connectionProviderEntity.workspaceId,
name: connectionProviderEntity.name,
displayName: connectionProviderEntity.displayName,
type: connectionProviderEntity.type,
oauthConfig: connectionProviderEntity.oauthConfig,
createdAt: connectionProviderEntity.createdAt.toISOString(),
updatedAt: connectionProviderEntity.updatedAt.toISOString(),
applicationUniversalIdentifier,
};
};
@@ -39,6 +39,17 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
"hotKeys",
],
},
"connectionProvider": {
"propertiesToCompare": [
"name",
"displayName",
"type",
"oauthConfig",
],
"propertiesToStringify": [
"oauthConfig",
],
},
"fieldMetadata": {
"propertiesToCompare": [
"defaultValue",
@@ -1542,6 +1542,30 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
universalProperty: undefined,
},
},
connectionProvider: {
name: { toCompare: true, toStringify: false, universalProperty: undefined },
displayName: {
toCompare: true,
toStringify: false,
universalProperty: undefined,
},
type: { toCompare: true, toStringify: false, universalProperty: undefined },
oauthConfig: {
toCompare: true,
toStringify: true,
universalProperty: undefined,
},
createdAt: {
toCompare: false,
toStringify: false,
universalProperty: undefined,
},
updatedAt: {
toCompare: false,
toStringify: false,
universalProperty: undefined,
},
},
} as const satisfies {
[P in AllMetadataName]: MetadataEntityPropertyConfiguration<P>;
};
@@ -285,6 +285,10 @@ export const ALL_MANY_TO_ONE_METADATA_FOREIGN_KEY = {
foreignKey: 'viewId',
},
},
connectionProvider: {
workspace: null,
application: null,
},
} as const satisfies ManyToOneMetadataRelationsProperties;
// satisfies with complex mapped types involving nested generics doesn't always catch missing required keys
@@ -505,6 +505,10 @@ export const ALL_MANY_TO_ONE_METADATA_RELATIONS = {
metadataName: 'view',
},
},
connectionProvider: {
workspace: null,
application: null,
},
} as const satisfies ManyToOneMetadataRelationsProperties;
// satisfies with complex mapped types involving nested generics doesn't always catch missing required keys
@@ -1,6 +1,7 @@
import { type AllMetadataName } from 'twenty-shared/metadata';
import { type EntityTarget, type ObjectLiteral } from 'typeorm';
import { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { CommandMenuItemEntity } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
@@ -57,4 +58,5 @@ export const ALL_METADATA_ENTITY_BY_METADATA_NAME = {
permissionFlag: PermissionFlagEntity,
webhook: WebhookEntity,
viewSort: ViewSortEntity,
connectionProvider: ConnectionProviderEntity,
} as const satisfies Record<AllMetadataName, EntityTarget<ObjectLiteral>>;
@@ -112,4 +112,5 @@ export const ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION = {
},
frontComponent: {},
webhook: {},
connectionProvider: {},
} as const satisfies MetadataRequiredForValidation;
@@ -50,6 +50,7 @@ export const ALL_METADATA_SERIALIZED_RELATION = {
viewSort: {},
frontComponent: {},
webhook: {},
connectionProvider: {},
} as const satisfies MetadataSerializedRelationProperties;
// satisfies with complex mapped types involving nested generics doesn't always catch missing required keys
@@ -244,6 +244,7 @@ export const ALL_ONE_TO_MANY_METADATA_RELATIONS = {
frontComponent: {},
webhook: {},
viewSort: {},
connectionProvider: {},
} as const satisfies OneToManyMetadataRelationsProperties;
// satisfies with complex mapped types involving nested generics doesn't always catch missing required keys
@@ -2,6 +2,8 @@ import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-meta
import { type FlatAgentMaps } from 'src/engine/metadata-modules/flat-agent/types/flat-agent-maps.type';
import { type FlatAgent } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type';
import { type FlatCommandMenuItemMaps } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item-maps.type';
import { type FlatConnectionProviderMaps } from 'src/engine/metadata-modules/flat-connection-provider/types/flat-connection-provider-maps.type';
import { type FlatConnectionProvider } from 'src/engine/metadata-modules/flat-connection-provider/types/flat-connection-provider.type';
import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type MetadataEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-entity.type';
@@ -53,6 +55,7 @@ import { type FlatRowLevelPermissionPredicateMaps } from 'src/engine/metadata-mo
import { type FlatRowLevelPermissionPredicate } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate.type';
import { type UniversalFlatAgent } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-agent.type';
import { type UniversalFlatCommandMenuItem } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-command-menu-item.type';
import { type UniversalFlatConnectionProvider } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-connection-provider.type';
import { type UniversalFlatEntityFrom } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-entity-from.type';
import { type UniversalFlatFieldPermission } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-permission.type';
import { type UniversalFlatFrontComponent } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-front-component.type';
@@ -94,6 +97,14 @@ import {
type UniversalDeleteCommandMenuItemAction,
type UniversalUpdateCommandMenuItemAction,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/command-menu-item/types/workspace-migration-command-menu-item-action.type';
import {
type FlatCreateConnectionProviderAction,
type FlatDeleteConnectionProviderAction,
type FlatUpdateConnectionProviderAction,
type UniversalCreateConnectionProviderAction,
type UniversalDeleteConnectionProviderAction,
type UniversalUpdateConnectionProviderAction,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/connection-provider/types/workspace-migration-connection-provider-action.type';
import {
type FlatCreateFieldPermissionAction,
type FlatDeleteFieldPermissionAction,
@@ -731,4 +742,20 @@ export type AllFlatEntityTypesByMetadataName = {
universalFlatEntity: UniversalFlatWebhook;
entity: MetadataEntity<'webhook'>;
};
connectionProvider: {
flatEntityMaps: FlatConnectionProviderMaps;
universalActions: {
create: UniversalCreateConnectionProviderAction;
update: UniversalUpdateConnectionProviderAction;
delete: UniversalDeleteConnectionProviderAction;
};
flatActions: {
create: FlatCreateConnectionProviderAction;
update: FlatUpdateConnectionProviderAction;
delete: FlatDeleteConnectionProviderAction;
};
flatEntity: FlatConnectionProvider;
universalFlatEntity: UniversalFlatConnectionProvider;
entity: MetadataEntity<'connectionProvider'>;
};
};
@@ -1,4 +1,4 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`getMetadataRelatedMetadataNames should return related metadata names for agent 1`] = `[]`;
@@ -10,6 +10,8 @@ exports[`getMetadataRelatedMetadataNames should return related metadata names fo
]
`;
exports[`getMetadataRelatedMetadataNames should return related metadata names for connectionProvider 1`] = `[]`;
exports[`getMetadataRelatedMetadataNames should return related metadata names for fieldMetadata 1`] = `
[
"objectMetadata",
@@ -18,6 +18,7 @@ exports[`sortMetadataNamesChildrenFirst should return metadata names sorted with
"rowLevelPermissionPredicateGroup",
"viewGroup",
"agent",
"connectionProvider",
"frontComponent",
"logicFunction",
"pageLayoutTab",
@@ -53,6 +53,7 @@ export const WORKSPACE_CACHE_KEYS_V2 = {
'flat-maps:row-level-permission-predicate-group',
flatFrontComponentMaps: 'flat-maps:front-component',
flatWebhookMaps: 'flat-maps:webhook',
flatConnectionProviderMaps: 'flat-maps:connection-provider',
flatWorkspaceMemberMaps: 'flat-maps:workspace-member',
apiKeyMap: 'cache:api-key-map',
applicationVariableMaps: 'cache:application-variable',
@@ -170,7 +170,8 @@ export const enrichCreateWorkspaceMigrationActionsWithIds = ({
case 'permissionFlag':
case 'objectPermission':
case 'fieldPermission':
case 'webhook': {
case 'webhook':
case 'connectionProvider': {
if (!isDefined(idByUniversalIdentifier)) {
return action;
}
@@ -16,6 +16,7 @@ import { crossEntityTransversalValidation } from 'src/engine/workspace-manager/w
import { mergeOrchestratorFailureReports } from 'src/engine/workspace-manager/workspace-migration/utils/merge-orchestrator-failure-reports.util';
import { WorkspaceMigrationAgentActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/agent/workspace-migration-agent-actions-builder.service';
import { WorkspaceMigrationCommandMenuItemActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/command-menu-item/workspace-migration-command-menu-item-actions-builder.service';
import { WorkspaceMigrationConnectionProviderActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/connection-provider/workspace-migration-connection-provider-actions-builder.service';
import { WorkspaceMigrationFieldPermissionActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field-permission/workspace-migration-field-permission-actions-builder.service';
import { WorkspaceMigrationFieldActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field/workspace-migration-field-actions-builder.service';
import { WorkspaceMigrationFrontComponentActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/front-component/workspace-migration-front-component-actions-builder.service';
@@ -72,6 +73,7 @@ export class WorkspaceMigrationBuildOrchestratorService {
private readonly workspaceMigrationRowLevelPermissionPredicateGroupActionsBuilderService: WorkspaceMigrationRowLevelPermissionPredicateGroupActionsBuilderService,
private readonly workspaceMigrationFrontComponentActionsBuilderService: WorkspaceMigrationFrontComponentActionsBuilderService,
private readonly workspaceMigrationWebhookActionsBuilderService: WorkspaceMigrationWebhookActionsBuilderService,
private readonly workspaceMigrationConnectionProviderActionsBuilderService: WorkspaceMigrationConnectionProviderActionsBuilderService,
) {}
private setupOptimisticCache({
@@ -164,6 +166,7 @@ export class WorkspaceMigrationBuildOrchestratorService {
flatPageLayoutTabMaps,
flatFrontComponentMaps,
flatWebhookMaps,
flatConnectionProviderMaps,
} = fromToAllFlatEntityMaps;
if (isDefined(flatObjectMetadataMaps)) {
@@ -823,6 +826,34 @@ export class WorkspaceMigrationBuildOrchestratorService {
}
}
if (isDefined(flatConnectionProviderMaps)) {
const {
from: fromFlatConnectionProviderMaps,
to: toFlatConnectionProviderMaps,
} = flatConnectionProviderMaps;
const connectionProviderResult =
await this.workspaceMigrationConnectionProviderActionsBuilderService.validateAndBuild(
{
additionalCacheDataMaps,
from: fromFlatConnectionProviderMaps,
to: toFlatConnectionProviderMaps,
buildOptions,
dependencyOptimisticFlatEntityMaps: optimisticAllFlatEntityMaps,
workspaceId,
},
);
if (connectionProviderResult.status === 'fail') {
orchestratorFailureReport.connectionProvider.push(
...connectionProviderResult.errors,
);
} else {
orchestratorActionsReport.connectionProvider =
connectionProviderResult.actions;
}
}
const crossEntityFailureReport = crossEntityTransversalValidation({
optimisticUniversalFlatMaps: optimisticAllFlatEntityMaps,
orchestratorActionsReport,
@@ -999,6 +1030,12 @@ export class WorkspaceMigrationBuildOrchestratorService {
...aggregatedOrchestratorActionsReport.webhook.create,
...aggregatedOrchestratorActionsReport.webhook.update,
///
// Connection providers
...aggregatedOrchestratorActionsReport.connectionProvider.delete,
...aggregatedOrchestratorActionsReport.connectionProvider.create,
...aggregatedOrchestratorActionsReport.connectionProvider.update,
///
],
},
};
@@ -1,9 +1,10 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ALL_UNIVERSAL_FLAT_ENTITY_FOREIGN_KEY_AGGREGATOR_PROPERTIES should match snapshot 1`] = `
{
"agent": [],
"commandMenuItem": [],
"connectionProvider": [],
"fieldMetadata": [
"viewSortUniversalIdentifiers",
"fieldPermissionUniversalIdentifiers",
@@ -42,6 +42,7 @@ export const ALL_JSONB_PROPERTIES_WITH_SERIALIZED_RELATION_BY_METADATA_NAME = {
objectPermission: {},
fieldPermission: {},
frontComponent: {},
connectionProvider: {},
} as const satisfies {
[P in AllMetadataName]: Partial<{
// TODO prastoin: improve strict typing to recursively serach for nested SerializedRelation
@@ -0,0 +1,7 @@
import { type ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
import { type UniversalFlatEntityFrom } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-entity-from.type';
export type UniversalFlatConnectionProvider = UniversalFlatEntityFrom<
ConnectionProviderEntity,
'connectionProvider'
>;
@@ -0,0 +1,24 @@
import { type BaseFlatCreateWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-flat-create-workspace-migration-action.type';
import { type BaseFlatDeleteWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-flat-delete-workspace-migration-action.type';
import { type BaseFlatUpdateWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-flat-update-workspace-migration-action.type';
import { type BaseUniversalCreateWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-universal-create-workspace-migration-action.type';
import { type BaseUniversalDeleteWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-universal-delete-workspace-migration-action.type';
import { type BaseUniversalUpdateWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-universal-update-workspace-migration-action.type';
export type FlatCreateConnectionProviderAction =
BaseFlatCreateWorkspaceMigrationAction<'connectionProvider'>;
export type UniversalCreateConnectionProviderAction =
BaseUniversalCreateWorkspaceMigrationAction<'connectionProvider'>;
export type FlatUpdateConnectionProviderAction =
BaseFlatUpdateWorkspaceMigrationAction<'connectionProvider'>;
export type UniversalUpdateConnectionProviderAction =
BaseUniversalUpdateWorkspaceMigrationAction<'connectionProvider'>;
export type UniversalDeleteConnectionProviderAction =
BaseUniversalDeleteWorkspaceMigrationAction<'connectionProvider'>;
export type FlatDeleteConnectionProviderAction =
BaseFlatDeleteWorkspaceMigrationAction<'connectionProvider'>;
@@ -0,0 +1,122 @@
import { Injectable } from '@nestjs/common';
import { ALL_METADATA_NAME } from 'twenty-shared/metadata';
import { UniversalUpdateConnectionProviderAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/connection-provider/types/workspace-migration-connection-provider-action.type';
import { WorkspaceEntityMigrationBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/services/workspace-entity-migration-builder.service';
import { FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-update-validation-args.type';
import { UniversalFlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-validation-args.type';
import { UniversalFlatEntityValidationReturnType } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-validation-result.type';
import { FlatConnectionProviderValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-connection-provider-validator.service';
@Injectable()
export class WorkspaceMigrationConnectionProviderActionsBuilderService extends WorkspaceEntityMigrationBuilderService<
typeof ALL_METADATA_NAME.connectionProvider
> {
constructor(
private readonly flatConnectionProviderValidatorService: FlatConnectionProviderValidatorService,
) {
super(ALL_METADATA_NAME.connectionProvider);
}
protected validateFlatEntityCreation(
args: UniversalFlatEntityValidationArgs<
typeof ALL_METADATA_NAME.connectionProvider
>,
): UniversalFlatEntityValidationReturnType<
typeof ALL_METADATA_NAME.connectionProvider,
'create'
> {
const validationResult =
this.flatConnectionProviderValidatorService.validateFlatConnectionProviderCreation(
args,
);
if (validationResult.errors.length > 0) {
return {
status: 'fail',
...validationResult,
};
}
const { flatEntityToValidate: flatConnectionProviderToValidate } = args;
return {
status: 'success',
action: {
type: 'create',
metadataName: 'connectionProvider',
flatEntity: flatConnectionProviderToValidate,
},
};
}
protected validateFlatEntityDeletion(
args: UniversalFlatEntityValidationArgs<
typeof ALL_METADATA_NAME.connectionProvider
>,
): UniversalFlatEntityValidationReturnType<
typeof ALL_METADATA_NAME.connectionProvider,
'delete'
> {
const validationResult =
this.flatConnectionProviderValidatorService.validateFlatConnectionProviderDeletion(
args,
);
if (validationResult.errors.length > 0) {
return {
status: 'fail',
...validationResult,
};
}
const { flatEntityToValidate: flatConnectionProviderToValidate } = args;
return {
status: 'success',
action: {
type: 'delete',
metadataName: 'connectionProvider',
universalIdentifier:
flatConnectionProviderToValidate.universalIdentifier,
},
};
}
protected validateFlatEntityUpdate(
args: FlatEntityUpdateValidationArgs<
typeof ALL_METADATA_NAME.connectionProvider
>,
): UniversalFlatEntityValidationReturnType<
typeof ALL_METADATA_NAME.connectionProvider,
'update'
> {
const validationResult =
this.flatConnectionProviderValidatorService.validateFlatConnectionProviderUpdate(
args,
);
if (validationResult.errors.length > 0) {
return {
status: 'fail',
...validationResult,
};
}
const { universalIdentifier, flatEntityUpdate } = args;
const updateConnectionProviderAction: UniversalUpdateConnectionProviderAction =
{
type: 'update',
metadataName: 'connectionProvider',
universalIdentifier,
update: flatEntityUpdate,
};
return {
status: 'success',
action: updateConnectionProviderAction,
};
}
}
@@ -0,0 +1,173 @@
import { Injectable } from '@nestjs/common';
import { msg, t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { ALL_METADATA_NAME } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { ConnectionProviderExceptionCode } from 'src/engine/core-modules/application/connection-provider/connection-provider-exception-code.enum';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
import { type FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type';
import { getEmptyFlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/utils/get-flat-entity-validation-error.util';
import { type FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-update-validation-args.type';
import { type UniversalFlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-validation-args.type';
@Injectable()
export class FlatConnectionProviderValidatorService {
public validateFlatConnectionProviderCreation({
flatEntityToValidate: flatConnectionProvider,
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: {
flatConnectionProviderMaps: optimisticFlatConnectionProviderMaps,
},
}: UniversalFlatEntityValidationArgs<
typeof ALL_METADATA_NAME.connectionProvider
>): FailedFlatEntityValidation<'connectionProvider', 'create'> {
const validationResult = getEmptyFlatEntityValidationError({
flatEntityMinimalInformation: {
universalIdentifier: flatConnectionProvider.universalIdentifier,
name: flatConnectionProvider.name,
},
metadataName: 'connectionProvider',
type: 'create',
});
if (!isNonEmptyString(flatConnectionProvider.name)) {
validationResult.errors.push({
code: ConnectionProviderExceptionCode.INVALID_CONNECTION_PROVIDER_INPUT,
message: t`Connection provider name is required`,
userFriendlyMessage: msg`Connection provider name is required`,
});
}
if (!isNonEmptyString(flatConnectionProvider.displayName)) {
validationResult.errors.push({
code: ConnectionProviderExceptionCode.INVALID_CONNECTION_PROVIDER_INPUT,
message: t`Connection provider displayName is required`,
userFriendlyMessage: msg`Connection provider display name is required`,
});
}
if (flatConnectionProvider.type === 'oauth') {
const oauthConfig = flatConnectionProvider.oauthConfig;
if (!isDefined(oauthConfig)) {
validationResult.errors.push({
code: ConnectionProviderExceptionCode.INVALID_CONNECTION_PROVIDER_INPUT,
message: t`Connection provider with type 'oauth' is missing oauthConfig`,
userFriendlyMessage: msg`OAuth connection provider is missing its oauth config block`,
});
} else {
const requiredOAuthFields: Array<{
key: keyof typeof oauthConfig;
label: string;
}> = [
{ key: 'authorizationEndpoint', label: 'authorizationEndpoint' },
{ key: 'tokenEndpoint', label: 'tokenEndpoint' },
{ key: 'clientIdVariable', label: 'clientIdVariable' },
{ key: 'clientSecretVariable', label: 'clientSecretVariable' },
];
for (const { key, label } of requiredOAuthFields) {
if (!isNonEmptyString(oauthConfig[key])) {
validationResult.errors.push({
code: ConnectionProviderExceptionCode.INVALID_CONNECTION_PROVIDER_INPUT,
message: t`Connection provider oauthConfig.${label} is required`,
userFriendlyMessage: msg`OAuth ${label} is required`,
});
}
}
}
}
const existingByName = Object.values(
optimisticFlatConnectionProviderMaps.byUniversalIdentifier,
).find(
(existing) =>
isDefined(existing) &&
existing.name === flatConnectionProvider.name &&
existing.applicationUniversalIdentifier ===
flatConnectionProvider.applicationUniversalIdentifier &&
existing.universalIdentifier !==
flatConnectionProvider.universalIdentifier,
);
if (isDefined(existingByName)) {
validationResult.errors.push({
code: ConnectionProviderExceptionCode.CONNECTION_PROVIDER_NAME_ALREADY_EXISTS,
message: t`Connection provider with name ${flatConnectionProvider.name} already exists for this application`,
userFriendlyMessage: msg`A connection provider with this name already exists for this application`,
});
}
return validationResult;
}
public validateFlatConnectionProviderDeletion({
flatEntityToValidate,
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: {
flatConnectionProviderMaps: optimisticFlatConnectionProviderMaps,
},
}: UniversalFlatEntityValidationArgs<
typeof ALL_METADATA_NAME.connectionProvider
>): FailedFlatEntityValidation<'connectionProvider', 'delete'> {
const validationResult = getEmptyFlatEntityValidationError({
flatEntityMinimalInformation: {
universalIdentifier: flatEntityToValidate.universalIdentifier,
name: flatEntityToValidate.name,
},
metadataName: 'connectionProvider',
type: 'delete',
});
const existingConnectionProvider = findFlatEntityByUniversalIdentifier({
universalIdentifier: flatEntityToValidate.universalIdentifier,
flatEntityMaps: optimisticFlatConnectionProviderMaps,
});
if (!isDefined(existingConnectionProvider)) {
validationResult.errors.push({
code: ConnectionProviderExceptionCode.CONNECTION_PROVIDER_NOT_FOUND,
message: t`Connection provider not found`,
userFriendlyMessage: msg`Connection provider not found`,
});
return validationResult;
}
return validationResult;
}
public validateFlatConnectionProviderUpdate({
universalIdentifier,
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: {
flatConnectionProviderMaps: optimisticFlatConnectionProviderMaps,
},
}: FlatEntityUpdateValidationArgs<
typeof ALL_METADATA_NAME.connectionProvider
>): FailedFlatEntityValidation<'connectionProvider', 'update'> {
const fromFlatConnectionProvider = findFlatEntityByUniversalIdentifier({
universalIdentifier,
flatEntityMaps: optimisticFlatConnectionProviderMaps,
});
const validationResult = getEmptyFlatEntityValidationError({
flatEntityMinimalInformation: {
universalIdentifier,
},
metadataName: 'connectionProvider',
type: 'update',
});
if (!isDefined(fromFlatConnectionProvider)) {
validationResult.errors.push({
code: ConnectionProviderExceptionCode.CONNECTION_PROVIDER_NOT_FOUND,
message: t`Connection provider not found`,
userFriendlyMessage: msg`Connection provider not found`,
});
return validationResult;
}
return validationResult;
}
}
@@ -30,6 +30,7 @@ import { FlatViewGroupValidatorService } from 'src/engine/workspace-manager/work
import { FlatViewSortValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-view-sort-validator.service';
import { FlatViewValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-view-validator.service';
import { FlatWebhookValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-webhook-validator.service';
import { FlatConnectionProviderValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-connection-provider-validator.service';
@Module({
imports: [FeatureFlagModule],
@@ -63,6 +64,7 @@ import { FlatWebhookValidatorService } from 'src/engine/workspace-manager/worksp
FlatRowLevelPermissionPredicateGroupValidatorService,
FlatFrontComponentValidatorService,
FlatWebhookValidatorService,
FlatConnectionProviderValidatorService,
],
exports: [
FlatViewValidatorService,
@@ -93,6 +95,7 @@ import { FlatWebhookValidatorService } from 'src/engine/workspace-manager/worksp
FlatRowLevelPermissionPredicateGroupValidatorService,
FlatFrontComponentValidatorService,
FlatWebhookValidatorService,
FlatConnectionProviderValidatorService,
],
})
export class WorkspaceMigrationBuilderValidatorsModule {}
@@ -29,6 +29,7 @@ import { WorkspaceMigrationViewGroupActionsBuilderService } from 'src/engine/wor
import { WorkspaceMigrationViewSortActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/view-sort/workspace-migration-view-sort-actions.builder.service';
import { WorkspaceMigrationViewActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/view/workspace-migration-view-actions-builder.service';
import { WorkspaceMigrationWebhookActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/webhook/workspace-migration-webhook-actions-builder.service';
import { WorkspaceMigrationConnectionProviderActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/connection-provider/workspace-migration-connection-provider-actions-builder.service';
import { WorkspaceMigrationBuilderValidatorsModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/workspace-migration-builder-validators.module';
@Module({
@@ -62,6 +63,7 @@ import { WorkspaceMigrationBuilderValidatorsModule } from 'src/engine/workspace-
WorkspaceMigrationRowLevelPermissionPredicateGroupActionsBuilderService,
WorkspaceMigrationFrontComponentActionsBuilderService,
WorkspaceMigrationWebhookActionsBuilderService,
WorkspaceMigrationConnectionProviderActionsBuilderService,
],
exports: [
WorkspaceMigrationViewActionsBuilderService,
@@ -92,6 +94,7 @@ import { WorkspaceMigrationBuilderValidatorsModule } from 'src/engine/workspace-
FlatFieldMetadataTypeValidatorService,
WorkspaceMigrationFrontComponentActionsBuilderService,
WorkspaceMigrationWebhookActionsBuilderService,
WorkspaceMigrationConnectionProviderActionsBuilderService,
],
})
export class WorkspaceMigrationBuilderModule {}
@@ -0,0 +1,65 @@
import { Injectable } from '@nestjs/common';
import { v4 } from 'uuid';
import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface';
import { getUniversalFlatEntityEmptyForeignKeyAggregators } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/reset-universal-flat-entity-foreign-key-aggregators.util';
import {
FlatCreateConnectionProviderAction,
UniversalCreateConnectionProviderAction,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/connection-provider/types/workspace-migration-connection-provider-action.type';
import {
WorkspaceMigrationActionRunnerArgs,
WorkspaceMigrationActionRunnerContext,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type';
@Injectable()
export class CreateConnectionProviderActionHandlerService extends WorkspaceMigrationRunnerActionHandler(
'create',
'connectionProvider',
) {
constructor() {
super();
}
override async transpileUniversalActionToFlatAction({
action,
flatApplication,
workspaceId,
}: WorkspaceMigrationActionRunnerArgs<UniversalCreateConnectionProviderAction>): Promise<FlatCreateConnectionProviderAction> {
const emptyUniversalForeignKeyAggregators =
getUniversalFlatEntityEmptyForeignKeyAggregators({
metadataName: 'connectionProvider',
});
return {
...action,
flatEntity: {
...action.flatEntity,
applicationId: flatApplication.id,
id: action.id ?? v4(),
workspaceId,
...emptyUniversalForeignKeyAggregators,
},
};
}
async executeForMetadata(
context: WorkspaceMigrationActionRunnerContext<FlatCreateConnectionProviderAction>,
): Promise<void> {
const { flatAction, queryRunner } = context;
const { flatEntity } = flatAction;
await this.insertFlatEntitiesInRepository({
queryRunner,
flatEntities: [flatEntity],
});
}
async executeForWorkspaceSchema(
_context: WorkspaceMigrationActionRunnerContext<FlatCreateConnectionProviderAction>,
): Promise<void> {
return;
}
}
@@ -0,0 +1,51 @@
import { Injectable } from '@nestjs/common';
import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface';
import { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
import {
FlatDeleteConnectionProviderAction,
UniversalDeleteConnectionProviderAction,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/connection-provider/types/workspace-migration-connection-provider-action.type';
import {
WorkspaceMigrationActionRunnerArgs,
WorkspaceMigrationActionRunnerContext,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type';
@Injectable()
export class DeleteConnectionProviderActionHandlerService extends WorkspaceMigrationRunnerActionHandler(
'delete',
'connectionProvider',
) {
constructor() {
super();
}
override async transpileUniversalActionToFlatAction(
context: WorkspaceMigrationActionRunnerArgs<UniversalDeleteConnectionProviderAction>,
): Promise<FlatDeleteConnectionProviderAction> {
return this.transpileUniversalDeleteActionToFlatDeleteAction(context);
}
async executeForMetadata(
context: WorkspaceMigrationActionRunnerContext<FlatDeleteConnectionProviderAction>,
): Promise<void> {
const { flatAction, queryRunner, workspaceId } = context;
const connectionProviderRepository =
queryRunner.manager.getRepository<ConnectionProviderEntity>(
ConnectionProviderEntity,
);
await connectionProviderRepository.delete({
id: flatAction.entityId,
workspaceId,
});
}
async executeForWorkspaceSchema(
_context: WorkspaceMigrationActionRunnerContext<FlatDeleteConnectionProviderAction>,
): Promise<void> {
return;
}
}
@@ -0,0 +1,68 @@
import { Injectable } from '@nestjs/common';
import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface';
import { ConnectionProviderEntity } from 'src/engine/core-modules/application/connection-provider/connection-provider.entity';
import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util';
import { resolveUniversalUpdateRelationIdentifiersToIds } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/resolve-universal-update-relation-identifiers-to-ids.util';
import {
FlatUpdateConnectionProviderAction,
UniversalUpdateConnectionProviderAction,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/connection-provider/types/workspace-migration-connection-provider-action.type';
import {
WorkspaceMigrationActionRunnerArgs,
WorkspaceMigrationActionRunnerContext,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type';
@Injectable()
export class UpdateConnectionProviderActionHandlerService extends WorkspaceMigrationRunnerActionHandler(
'update',
'connectionProvider',
) {
override async transpileUniversalActionToFlatAction(
context: WorkspaceMigrationActionRunnerArgs<UniversalUpdateConnectionProviderAction>,
): Promise<FlatUpdateConnectionProviderAction> {
const { action, allFlatEntityMaps } = context;
const flatConnectionProvider = findFlatEntityByUniversalIdentifierOrThrow({
flatEntityMaps: allFlatEntityMaps.flatConnectionProviderMaps,
universalIdentifier: action.universalIdentifier,
});
const update = resolveUniversalUpdateRelationIdentifiersToIds({
metadataName: 'connectionProvider',
universalUpdate: action.update,
allFlatEntityMaps,
});
return {
type: 'update',
metadataName: 'connectionProvider',
entityId: flatConnectionProvider.id,
update,
};
}
async executeForMetadata(
context: WorkspaceMigrationActionRunnerContext<FlatUpdateConnectionProviderAction>,
): Promise<void> {
const { flatAction, queryRunner, workspaceId } = context;
const { entityId, update } = flatAction;
const connectionProviderRepository =
queryRunner.manager.getRepository<ConnectionProviderEntity>(
ConnectionProviderEntity,
);
await connectionProviderRepository.update(
{ id: entityId, workspaceId },
update,
);
}
async executeForWorkspaceSchema(
_context: WorkspaceMigrationActionRunnerContext<FlatUpdateConnectionProviderAction>,
): Promise<void> {
return;
}
}
@@ -84,6 +84,9 @@ import { UpdateViewActionHandlerService } from 'src/engine/workspace-manager/wor
import { CreateWebhookActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/webhook/services/create-webhook-action-handler.service';
import { DeleteWebhookActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/webhook/services/delete-webhook-action-handler.service';
import { UpdateWebhookActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/webhook/services/update-webhook-action-handler.service';
import { CreateConnectionProviderActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/connection-provider/services/create-connection-provider-action-handler.service';
import { DeleteConnectionProviderActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/connection-provider/services/delete-connection-provider-action-handler.service';
import { UpdateConnectionProviderActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/connection-provider/services/update-connection-provider-action-handler.service';
@Module({
imports: [
@@ -198,6 +201,9 @@ import { UpdateWebhookActionHandlerService } from 'src/engine/workspace-manager/
CreateWebhookActionHandlerService,
UpdateWebhookActionHandlerService,
DeleteWebhookActionHandlerService,
CreateConnectionProviderActionHandlerService,
UpdateConnectionProviderActionHandlerService,
DeleteConnectionProviderActionHandlerService,
],
})
export class WorkspaceSchemaMigrationRunnerActionHandlersModule {}
@@ -28,4 +28,5 @@ export const METADATA_EVENTS_TO_EMIT = {
logicFunction: true,
viewSort: true,
webhook: false,
connectionProvider: true,
} as const satisfies { [P in AllMetadataName]: boolean };
@@ -93,7 +93,8 @@ const deriveAllMetadataEventsFromCreateAction = (
case 'objectPermission':
case 'fieldPermission':
case 'viewSort':
case 'webhook': {
case 'webhook':
case 'connectionProvider': {
return [
{
type: 'created',
@@ -57,7 +57,8 @@ const deriveAllMetadataEventsFromDeleteAction = ({
case 'objectPermission':
case 'fieldPermission':
case 'viewSort':
case 'webhook': {
case 'webhook':
case 'connectionProvider': {
const flatEntityToDelete = findFlatEntityByIdInFlatEntityMapsOrThrow<
MetadataFlatEntity<typeof flatAction.metadataName>
>({
@@ -135,7 +135,8 @@ const deriveAllMetadataEventsFromUpdateAction = ({
case 'objectPermission':
case 'fieldPermission':
case 'viewSort':
case 'webhook': {
case 'webhook':
case 'connectionProvider': {
const flatEntityMapsKey = getMetadataFlatEntityMapsKey(
flatAction.metadataName,
);
@@ -71,7 +71,8 @@ export const optimisticallyApplyCreateActionOnAllFlatEntityMaps = ({
case 'permissionFlag':
case 'objectPermission':
case 'fieldPermission':
case 'webhook': {
case 'webhook':
case 'connectionProvider': {
addFlatEntityToFlatEntityAndRelatedEntityMapsThroughMutationOrThrow({
flatEntity: flatAction.flatEntity,
flatEntityAndRelatedMapsToMutate: allFlatEntityMaps,
@@ -43,7 +43,8 @@ export const optimisticallyApplyDeleteActionOnAllFlatEntityMaps = ({
case 'permissionFlag':
case 'objectPermission':
case 'fieldPermission':
case 'webhook': {
case 'webhook':
case 'connectionProvider': {
const flatEntityToDelete = findFlatEntityByIdInFlatEntityMapsOrThrow<
MetadataFlatEntity<typeof flatAction.metadataName>
>({
@@ -64,7 +64,8 @@ export const optimisticallyApplyUpdateActionOnAllFlatEntityMaps = ({
case 'permissionFlag':
case 'objectPermission':
case 'fieldPermission':
case 'webhook': {
case 'webhook':
case 'connectionProvider': {
const flatEntityMapsKey = getMetadataFlatEntityMapsKey(
flatAction.metadataName,
);
@@ -1,7 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppOAuthRefreshModule } from 'src/engine/core-modules/application/application-oauth-provider/refresh/app-oauth-refresh.module';
import { AppOAuthRefreshModule } from 'src/engine/core-modules/application/connection-provider/refresh/app-oauth-refresh.module';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { GoogleAPIRefreshAccessTokenModule } from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/google-api-refresh-access-token.module';
@@ -3,7 +3,7 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { AppOAuthRefreshAccessTokenService } from 'src/engine/core-modules/application/application-oauth-provider/refresh/services/app-oauth-refresh-tokens.service';
import { AppOAuthRefreshAccessTokenService } from 'src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-refresh-tokens.service';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { GoogleAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/services/google-api-refresh-tokens.service';
@@ -5,7 +5,7 @@ import { ConnectedAccountProvider } from 'twenty-shared/types';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { AppOAuthRefreshAccessTokenService } from 'src/engine/core-modules/application/application-oauth-provider/refresh/services/app-oauth-refresh-tokens.service';
import { AppOAuthRefreshAccessTokenService } from 'src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-refresh-tokens.service';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
@@ -0,0 +1,268 @@
import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
import { findConnectionProvidersByApplication } from 'test/integration/metadata/suites/connection-provider/utils/find-connection-providers-by-application.util';
import { type Manifest } from 'twenty-shared/application';
import { v4 as uuidv4 } from 'uuid';
const TEST_APP_ID = uuidv4();
const TEST_ROLE_ID = uuidv4();
const TEST_PROVIDER_ID = uuidv4();
const TEST_SECOND_PROVIDER_ID = uuidv4();
const buildManifest = (
overrides?: Partial<Pick<Manifest, 'connectionProviders'>>,
) => buildBaseManifest({ appId: TEST_APP_ID, roleId: TEST_ROLE_ID, overrides });
describe('Manifest update - connection providers', () => {
beforeEach(async () => {
await setupApplicationForSync({
applicationUniversalIdentifier: TEST_APP_ID,
name: 'Test Application',
description: 'App for testing connection-provider manifest updates',
sourcePath: 'test-manifest-update-connection-provider',
});
}, 60000);
afterEach(async () => {
await cleanupApplicationAndAppRegistration({
applicationUniversalIdentifier: TEST_APP_ID,
});
});
it('should create a new connection provider when added to manifest on second sync', async () => {
await syncApplication({
manifest: buildManifest({ connectionProviders: [] }),
expectToFail: false,
});
const providersAfterFirstSync =
await findConnectionProvidersByApplication(TEST_APP_ID);
expect(providersAfterFirstSync).toHaveLength(0);
await syncApplication({
manifest: buildManifest({
connectionProviders: [
{
universalIdentifier: TEST_PROVIDER_ID,
name: 'linear',
displayName: 'Linear',
type: 'oauth',
oauth: {
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
scopes: ['read', 'write'],
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
},
},
],
}),
expectToFail: false,
});
const providersAfterSecondSync =
await findConnectionProvidersByApplication(TEST_APP_ID);
expect(providersAfterSecondSync).toHaveLength(1);
expect(providersAfterSecondSync[0]).toMatchObject({
universalIdentifier: TEST_PROVIDER_ID,
name: 'linear',
displayName: 'Linear',
type: 'oauth',
oauthConfig: {
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
revokeEndpoint: null,
scopes: ['read', 'write'],
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
authorizationParams: null,
tokenRequestContentType: 'json',
usePkce: true,
},
});
}, 60000);
it('should update oauthConfig fields when manifest changes on second sync', async () => {
await syncApplication({
manifest: buildManifest({
connectionProviders: [
{
universalIdentifier: TEST_PROVIDER_ID,
name: 'linear',
displayName: 'Linear',
type: 'oauth',
oauth: {
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
scopes: ['read'],
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
},
},
],
}),
expectToFail: false,
});
const providersAfterFirstSync =
await findConnectionProvidersByApplication(TEST_APP_ID);
expect(providersAfterFirstSync).toHaveLength(1);
expect(providersAfterFirstSync[0].oauthConfig?.scopes).toEqual(['read']);
expect(providersAfterFirstSync[0].oauthConfig?.usePkce).toBe(true);
await syncApplication({
manifest: buildManifest({
connectionProviders: [
{
universalIdentifier: TEST_PROVIDER_ID,
name: 'linear',
displayName: 'Linear (renamed)',
type: 'oauth',
oauth: {
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
revokeEndpoint: 'https://api.linear.app/oauth/revoke',
scopes: ['read', 'write', 'admin'],
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
authorizationParams: { prompt: 'consent' },
tokenRequestContentType: 'form-urlencoded',
usePkce: false,
},
},
],
}),
expectToFail: false,
});
const providersAfterSecondSync =
await findConnectionProvidersByApplication(TEST_APP_ID);
expect(providersAfterSecondSync).toHaveLength(1);
expect(providersAfterSecondSync[0]).toMatchObject({
universalIdentifier: TEST_PROVIDER_ID,
displayName: 'Linear (renamed)',
oauthConfig: {
revokeEndpoint: 'https://api.linear.app/oauth/revoke',
scopes: ['read', 'write', 'admin'],
authorizationParams: { prompt: 'consent' },
tokenRequestContentType: 'form-urlencoded',
usePkce: false,
},
});
}, 60000);
it('should delete a connection provider when removed from manifest on second sync', async () => {
await syncApplication({
manifest: buildManifest({
connectionProviders: [
{
universalIdentifier: TEST_PROVIDER_ID,
name: 'linear',
displayName: 'Linear',
type: 'oauth',
oauth: {
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
scopes: ['read'],
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
},
},
{
universalIdentifier: TEST_SECOND_PROVIDER_ID,
name: 'slack',
displayName: 'Slack',
type: 'oauth',
oauth: {
authorizationEndpoint: 'https://slack.com/oauth/v2/authorize',
tokenEndpoint: 'https://slack.com/api/oauth.v2.access',
scopes: ['chat:write'],
clientIdVariable: 'SLACK_CLIENT_ID',
clientSecretVariable: 'SLACK_CLIENT_SECRET',
},
},
],
}),
expectToFail: false,
});
const providersAfterFirstSync =
await findConnectionProvidersByApplication(TEST_APP_ID);
expect(providersAfterFirstSync).toHaveLength(2);
expect(providersAfterFirstSync.map((p) => p.name).sort()).toEqual([
'linear',
'slack',
]);
await syncApplication({
manifest: buildManifest({
connectionProviders: [
{
universalIdentifier: TEST_PROVIDER_ID,
name: 'linear',
displayName: 'Linear',
type: 'oauth',
oauth: {
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
scopes: ['read'],
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
},
},
],
}),
expectToFail: false,
});
const providersAfterSecondSync =
await findConnectionProvidersByApplication(TEST_APP_ID);
expect(providersAfterSecondSync).toHaveLength(1);
expect(providersAfterSecondSync[0].name).toBe('linear');
}, 60000);
it('should hard-delete connection providers (no soft-delete behaviour)', async () => {
await syncApplication({
manifest: buildManifest({
connectionProviders: [
{
universalIdentifier: TEST_PROVIDER_ID,
name: 'linear',
displayName: 'Linear',
type: 'oauth',
oauth: {
authorizationEndpoint: 'https://linear.app/oauth/authorize',
tokenEndpoint: 'https://api.linear.app/oauth/token',
scopes: ['read'],
clientIdVariable: 'LINEAR_CLIENT_ID',
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
},
},
],
}),
expectToFail: false,
});
await syncApplication({
manifest: buildManifest({ connectionProviders: [] }),
expectToFail: false,
});
// Bypass the helper's `JOIN application` so a soft-deleted row would
// still surface here if it existed.
const rawRows = await globalThis.testDataSource.query(
`SELECT id FROM core."connectionProvider" WHERE "universalIdentifier" = $1`,
[TEST_PROVIDER_ID],
);
expect(rawRows).toHaveLength(0);
}, 60000);
});

Some files were not shown because too many files have changed in this diff Show More