3a646ffcb0
Platform half of the follow-up to https://github.com/twentyhq/twenty/pull/22984#discussion_r3673946334. The Slack app claims a `team_id` on connect and had no way to release it, because connection providers only had an on-connect hook. Nothing here is Slack-specific, so it targets `main`. The app side is #23540, on top of `feat/slack-bot`, and waits on this plus an SDK release. ## What changes `defineConnectionProvider` accepts `onDisconnectLogicFunction` alongside `onConnectLogicFunction`. It is stored on `connectionProvider.onDisconnectLogicFunctionUniversalIdentifier` (fast instance command `2.26.0_...1785350000000`) and enqueued right after the `ConnectedAccount` row is deleted, in the disconnecting workspace, with the same payload as on-connect: ```ts type OnDisconnectPayload = { connectionProviderId: string; connectionProviderName: string; connectedAccountId: string; }; ``` The `ConnectedAccount` is gone by the time the hook runs, so `getConnection` no longer resolves. Anything the cleanup needs has to be in the key-value store, written at connect time and keyed by `connectedAccountId`. The docs section spells that out, along with the fact that uninstalling an app drops its connections through a cascade that never reaches this hook, where `uninstallLogicFunction` is the right tool instead. Both dispatches moved into a new `ConnectionProviderLifecycleHookService`, so `ConnectionProviderOAuthFlowService` no longer owns hook plumbing and `ConnectedAccountMetadataService.delete` can reuse it. On-connect behaviour is unchanged: best effort, never blocks the caller, failures go to Sentry. ## Tests - `connection-provider-lifecycle-hook.service.spec.ts`: the on-connect cases moved over, plus on-disconnect dispatch, no-hook, and missing-provider cases - `connection-provider-oauth-flow.service.spec.ts`: now asserts delegation to the lifecycle hook service - SDK validation, manifest duplicate-identifier, and manifest to flat converter specs extended Server unit tests and typecheck for shared, sdk and server pass locally.
269 lines
13 KiB
Plaintext
269 lines
13 KiB
Plaintext
---
|
|
title: Connections
|
|
description: Let your app act on a user's behalf in third-party services via OAuth.
|
|
icon: 'plug'
|
|
---
|
|
|
|
Connections are credentials a user holds for an external service (Linear, GitHub, Slack, ...). Your app declares **how** those credentials are obtained — a **connection provider** — and consumes them at runtime to make authenticated calls to the third-party API.
|
|
|
|
Today only OAuth 2.0 is supported. Future credential types (personal access tokens, API keys, basic auth) will plug into the same surface — apps already using `defineConnectionProvider({ type: 'oauth', ... })` won't need to migrate.
|
|
|
|
<AccordionGroup>
|
|
|
|
<Accordion title="defineConnectionProvider" description="Declare how your app's connections are obtained">
|
|
|
|
A connection provider describes the OAuth handshake your app needs. The user clicks "Add connection" in your app's settings, completes the provider's consent screen, and a `ConnectedAccount` row is created in their workspace.
|
|
|
|
A working setup needs **two files** — the connection provider, and a matching `serverVariables` declaration on `defineApplication` that holds the OAuth client credentials.
|
|
|
|
```ts src/connection-providers/linear-connection.ts
|
|
import { defineConnectionProvider } from 'twenty-sdk/define';
|
|
|
|
export default defineConnectionProvider({
|
|
universalIdentifier: '9c7d1f5e-6a0b-4d44-be0c-3f8b5a9d4e6f',
|
|
name: 'linear',
|
|
displayName: 'Linear',
|
|
icon: 'IconBrandLinear',
|
|
type: 'oauth',
|
|
oauth: {
|
|
authorizationEndpoint: 'https://linear.app/oauth/authorize',
|
|
tokenEndpoint: 'https://api.linear.app/oauth/token',
|
|
scopes: ['read', 'write'],
|
|
// These must match keys in `defineApplication.serverVariables` below.
|
|
clientIdVariable: 'LINEAR_CLIENT_ID',
|
|
clientSecretVariable: 'LINEAR_CLIENT_SECRET',
|
|
// Optional: defaults to 'json'. Some providers (Linear, Slack) want
|
|
// 'form-urlencoded' for the token request.
|
|
tokenRequestContentType: 'form-urlencoded',
|
|
// Optional: defaults to true. Disable only if the provider rejects PKCE.
|
|
usePkce: false,
|
|
// Optional: extra query params on the authorize URL.
|
|
// authorizationParams: { prompt: 'consent' },
|
|
// Optional: provider's RFC 7009 token revocation endpoint, called on disconnect.
|
|
// revokeEndpoint: 'https://example.com/oauth/revoke',
|
|
},
|
|
// Optional: a logic function in this app to run right after a connection is
|
|
// established. See "Run a logic function on connect".
|
|
// onConnectLogicFunction: { universalIdentifier: '3a2b1c0d-...-...' },
|
|
// Optional: a logic function in this app to run right after a connection is
|
|
// removed. See "Run a logic function on disconnect".
|
|
// onDisconnectLogicFunction: { universalIdentifier: '4d5e6f70-...-...' },
|
|
});
|
|
```
|
|
|
|
```ts src/application.config.ts
|
|
import { defineApplication } from 'twenty-sdk/define';
|
|
|
|
export default defineApplication({
|
|
universalIdentifier: '...',
|
|
displayName: 'Linear',
|
|
description: 'Connect Linear to Twenty.',
|
|
// OAuth client credentials live on the app registration (one OAuth app per
|
|
// Twenty server, configured by the admin) — not per-workspace. Declare them
|
|
// as serverVariables so the admin can fill them in once for all installs.
|
|
serverVariables: {
|
|
LINEAR_CLIENT_ID: {
|
|
description: 'OAuth client ID from your Linear OAuth application.',
|
|
isSecret: false,
|
|
isRequired: true,
|
|
},
|
|
LINEAR_CLIENT_SECRET: {
|
|
description: 'OAuth client secret from your Linear OAuth application.',
|
|
isSecret: true,
|
|
isRequired: true,
|
|
},
|
|
},
|
|
});
|
|
```
|
|
|
|
Key points:
|
|
|
|
- `name` is the unique identifier string used in `listConnections({ providerName })` (kebab-case, must match `^[a-z][a-z0-9-]*$`).
|
|
- `displayName` shows in the per-app settings tab and in the AI tool list.
|
|
- `clientIdVariable` / `clientSecretVariable` are **names**, not values — they must match keys declared in `defineApplication.serverVariables`. The actual `client_id` and `client_secret` are entered by the server admin through the app registration UI, never committed to your repo.
|
|
- Use `serverVariables` (not `applicationVariables`) — OAuth credentials are server-wide and one OAuth app per Twenty server.
|
|
- Until both `serverVariables` are filled in, the per-app settings tab shows a "needs server admin" hint and the "Add connection" button is disabled.
|
|
- `type: 'oauth'` is the only supported value today. The discriminator is forward-compatible: future types (`'pat'`, `'api-key'`, ...) will add new sub-config blocks alongside `oauth`.
|
|
|
|
The OAuth callback URL your provider needs to whitelist is:
|
|
|
|
```
|
|
https://<your-twenty-server>/auth/apps/callback
|
|
```
|
|
|
|
</Accordion>
|
|
|
|
<Accordion title="Run a logic function on connect" description="React the moment a connection is established">
|
|
|
|
Some providers hand you data at connect time that you need to persist before the connection is usable — the classic example is Slack, where the OAuth response identifies the workspace's `team_id` that inbound events will be keyed by. Set `onConnectLogicFunction` to reference a logic function in the same app (by its `universalIdentifier`), and it runs right after the `ConnectedAccount` is created.
|
|
|
|
```ts src/connection-providers/slack-connection.ts
|
|
export default defineConnectionProvider({
|
|
universalIdentifier: '...',
|
|
name: 'slack',
|
|
displayName: 'Slack',
|
|
type: 'oauth',
|
|
oauth: {
|
|
/* ... */
|
|
},
|
|
// Runs claimSlackTeam after every successful Slack connection.
|
|
onConnectLogicFunction: {
|
|
universalIdentifier: '3a2b1c0d-1111-4222-8333-444455556666',
|
|
},
|
|
});
|
|
```
|
|
|
|
The hook runs **asynchronously in the connecting workspace** (it is enqueued, not awaited), so a slow or failing hook never blocks or breaks the OAuth callback — make it idempotent and handle its own retries. The handler receives:
|
|
|
|
```ts
|
|
type OnConnectPayload = {
|
|
connectionProviderId: string;
|
|
connectionProviderName: string; // e.g. 'slack'
|
|
connectedAccountId: string;
|
|
};
|
|
```
|
|
|
|
From there use `getConnection(connectedAccountId)` to read the fresh access token and call the provider's API (e.g. Slack `auth.test`) or persist a mapping with the [key-value store](/developers/extend/apps/logic/key-value-store).
|
|
|
|
</Accordion>
|
|
|
|
<Accordion title="Run a logic function on disconnect" description="Clean up when a connection is removed">
|
|
|
|
Anything an app claims at connect time has to be released when the connection goes away. A Slack integration that claims a `team_id` on connect, for instance, has to release that claim so another workspace can connect the same Slack team. Set `onDisconnectLogicFunction` to reference a logic function in the same app, and it runs right after the `ConnectedAccount` is deleted.
|
|
|
|
```ts src/connection-providers/slack-connection.ts
|
|
export default defineConnectionProvider({
|
|
universalIdentifier: '...',
|
|
name: 'slack',
|
|
displayName: 'Slack',
|
|
type: 'oauth',
|
|
oauth: {
|
|
/* ... */
|
|
},
|
|
// Runs releaseSlackTeam after every Slack disconnection.
|
|
onDisconnectLogicFunction: {
|
|
universalIdentifier: '4470aba8-5ff5-4800-88db-2a427cd8677c',
|
|
},
|
|
});
|
|
```
|
|
|
|
Like the on-connect hook it runs **asynchronously in the disconnecting workspace** and never blocks the disconnect. The handler receives the same payload shape:
|
|
|
|
```ts
|
|
type OnDisconnectPayload = {
|
|
connectionProviderId: string;
|
|
connectionProviderName: string; // e.g. 'slack'
|
|
connectedAccountId: string;
|
|
};
|
|
```
|
|
|
|
The `ConnectedAccount` is already gone when the hook runs, so `getConnection(connectedAccountId)` no longer resolves. Anything the cleanup needs (a `team_id`, an external subscription id) must have been written to the [key-value store](/developers/extend/apps/logic/key-value-store) at connect time, keyed by `connectedAccountId`.
|
|
|
|
The hook fires when a connection is removed on its own. Uninstalling the app drops its connections through a database cascade instead, so the hook does not run there. Declare an `uninstallLogicFunction` on `defineApplication` for that path: it runs before the app's metadata is deleted, so it can still call `listConnections` and clean up whatever is left.
|
|
|
|
</Accordion>
|
|
|
|
<Accordion title="listConnections / getConnection" description="Use connections from a logic function">
|
|
|
|
Inside a logic function handler, `listConnections({ providerName })` returns this app's `ConnectedAccount` rows for the given provider, with refreshed access tokens.
|
|
|
|
```ts src/logic-functions/handlers/create-linear-issue-handler.ts
|
|
import { listConnections } from 'twenty-sdk/logic-function';
|
|
|
|
export const createLinearIssueHandler = async (input: {
|
|
teamId?: string;
|
|
title?: string;
|
|
}) => {
|
|
if (!input.teamId || !input.title) {
|
|
return { success: false, error: 'teamId and title are required' };
|
|
}
|
|
|
|
const connections = await listConnections({ providerName: 'linear' });
|
|
|
|
// Workspace-shared credentials win when present; fall back to the first
|
|
// user-visibility one. For HTTP-route triggers you typically pick the
|
|
// request user's connection via event.userWorkspaceId instead.
|
|
const connection =
|
|
connections.find((c) => c.visibility === 'workspace') ?? connections[0];
|
|
|
|
if (!connection) {
|
|
return {
|
|
success: false,
|
|
error:
|
|
'Linear is not connected. Open the app settings and click "Add connection".',
|
|
};
|
|
}
|
|
|
|
// Use connection.accessToken to call the third-party API.
|
|
const response = await fetch('https://api.linear.app/graphql', {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${connection.accessToken}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
query: `mutation { issueCreate(input: { teamId: "${input.teamId}", title: "${input.title}" }) { success } }`,
|
|
}),
|
|
});
|
|
|
|
return { success: response.ok };
|
|
};
|
|
```
|
|
|
|
Each connection has:
|
|
|
|
| Field | Description |
|
|
| ----------------- | -------------------------------------------------------------------------------------------------------- |
|
|
| `id` | Unique row id; pass to `getConnection(id)` to refetch a single one |
|
|
| `visibility` | `'user'` (private to one workspace member) or `'workspace'` (shared with all members) |
|
|
| `scopes` | OAuth permissions granted by the upstream provider (distinct from `visibility` — those are unrelated) |
|
|
| `userWorkspaceId` | The owner's userWorkspace id — useful for picking "the request user's connection" in HTTP-route triggers |
|
|
| `accessToken` | Fresh OAuth access token (refreshed automatically if expired) |
|
|
| `name` / `handle` | The connection's display name (auto-derived at OAuth callback, user-renameable) |
|
|
| `authFailedAt` | Set when the most recent refresh failed; the user must reconnect |
|
|
|
|
Key points:
|
|
|
|
- Pass `{ providerName }` to filter by provider; omit it to get all connections this app owns across all providers.
|
|
- The server transparently refreshes the access token before returning. Your handler always sees a usable token (or `authFailedAt` set).
|
|
- `getConnection(id)` is the single-row equivalent.
|
|
|
|
</Accordion>
|
|
|
|
<Accordion title="Per-user vs workspace-shared visibility" description="How users choose between private and shared credentials">
|
|
|
|
When a user clicks "Add connection," they're prompted to pick a visibility:
|
|
|
|
- **Just for me** — the credential is private to the connecting user. Any logic function called on their behalf (HTTP-route trigger with `isAuthRequired: true`) sees it; cron triggers and database events do not.
|
|
- **Workspace shared** — any workspace member can use the credential. Cron / database triggers also see it, since they have no request user.
|
|
|
|
Use the right one for each handler:
|
|
|
|
```ts
|
|
// HTTP-route trigger — prefer the request user's own connection.
|
|
const conn =
|
|
connections.find((c) => c.userWorkspaceId === event.userWorkspaceId) ??
|
|
connections.find((c) => c.visibility === 'workspace');
|
|
|
|
// Cron trigger — no request user; only shared credentials are sensible.
|
|
const conn = connections.find((c) => c.visibility === 'workspace');
|
|
```
|
|
|
|
Multiple connections per (user, provider) are allowed, so the same user can hold "Personal Linear" and "Work Linear" side by side.
|
|
|
|
</Accordion>
|
|
|
|
<Accordion title="One-time provider setup" description="Register your OAuth app with the third-party service">
|
|
|
|
For each connection provider, the server admin needs to register an OAuth app at the third party first.
|
|
|
|
1. Go to the provider's developer settings (e.g. https://linear.app/settings/api/applications/new).
|
|
2. Set the **Redirect URI** to `<SERVER_URL>/auth/apps/callback`.
|
|
3. Copy the generated **Client ID** and **Client Secret**.
|
|
4. Open the installed app in Twenty as a server admin → set the values on the corresponding `serverVariables`.
|
|
5. Workspace members can then add connections from the per-app **Connections** section.
|
|
|
|
</Accordion>
|
|
|
|
</AccordionGroup>
|