feat(slack): release the Slack team claim when the connection goes away (#23753)

Closes the loop on
https://github.com/twentyhq/twenty/pull/22984#discussion_r3673946334,
where the Slack app claimed a `team_id` on connect and never released
it. The `onDisconnect` hook it waited on merged in #23538, and
`twenty-sdk` 2.27.0 ships the `onDisconnectLogicFunction` manifest
field, so this bumps the app to it.

## Disconnect

`slack-team-release` releases the server-scoped `slack-team:{teamId}`
claim, so another Twenty workspace can connect that Slack team
afterwards. `kv.delete` on a SERVER key only clears a claim the calling
workspace owns, and the result reports a team only when that delete
actually removed something.

The connection is already deleted when the hook runs, so `getConnection`
cannot resolve the `team_id` anymore. `claimSlackTeam` records `teamId`
under a workspace-scoped
`slack-connected-account-team:{connectedAccountId}` key, written before
the claim itself so a failure between the two cannot leave a claim
nothing can resolve, and the release reads it back from there. That also
retires the TODO the review comment pointed at.

The release is skipped when a live connection still maps to the same
team, excluding the disconnecting account explicitly. That covers a
reconnect that re-claimed the team while the job was queued, and a
second connection to the same Slack workspace, whose claim would
otherwise have been dropped along with the first. The recorded team is
cleared either way, so the disconnecting account leaves nothing behind.

## Uninstall

Uninstalling the app drops its connections through the
`connectionProvider` and `application` cascades, which never reach the
disconnect hook, and the same operation deletes the app's logic
functions so the hook could not run anyway. The app declares an
`uninstallLogicFunction`: it runs before the app's metadata is
deleted, lists the remaining Slack connections, and releases each claim
through the same `releaseSlackTeamClaim` util. It deliberately skips the
live-connection check, since at that point every connection is still
listed.

## Known limits

A disconnect immediately followed by a reconnect has a window where the
queued release can clear a claim the reconnect just took. It self-heals,
because the reconnect's own onConnect re-claims the same key moments
later. Closing it properly needs a compare-and-set on the claim, which
the key-value store does not expose.

No unit tests are added for the release path. Earlier revisions of this
branch had specs for the disconnect guard, the uninstall sweep and the
no-op-release result; they were dropped, so those branches carry no
regression coverage. The app's existing 34 tests still pass, along
with lint, typecheck and `twenty dev:build`, the last being what used to
fail with `Duplicate universal identifiers` on the older SDK.
This commit is contained in:
Abdul Rahman
2026-08-06 19:27:33 +05:30
committed by GitHub
parent 5f9419589e
commit 577cd68cb5
20 changed files with 197 additions and 19 deletions
+1 -1
View File
@@ -72,7 +72,7 @@ The assistant reuses the same Slack connection — no second bot identity.
- **Thread memory.** After a successful reply the bot stays active in that thread, so follow-ups need no mention. Channel threads stay active for 24 hours after the last reply (each reply renews it); DM threads never expire. - **Thread memory.** After a successful reply the bot stays active in that thread, so follow-ups need no mention. Channel threads stay active for 24 hours after the last reply (each reply renews it); DM threads never expire.
- **Channel welcome.** With `member_joined_channel` subscribed, the bot posts a short introduction the first time it is added to a channel, with the details (what to ask it, what it reads, and the shared-role caveat from step 4 above) in a thread reply so the channel itself stays quiet. It fires once per channel for 30 days, and only for the bot's own join — humans joining afterwards trigger nothing. Skip the subscription if you would rather it arrived silently. - **Channel welcome.** With `member_joined_channel` subscribed, the bot posts a short introduction the first time it is added to a channel, with the details (what to ask it, what it reads, and the shared-role caveat from step 4 above) in a thread reply so the channel itself stays quiet. It fires once per channel for 30 days, and only for the bot's own join — humans joining afterwards trigger nothing. Skip the subscription if you would rather it arrived silently.
- **One Slack workspace per Twenty workspace.** Connecting Slack claims that Slack team for the connecting Twenty workspace. On the same server, a second Twenty workspace connecting the same Slack team is rejected. The claim is not released on disconnect yet, so moving a Slack workspace needs a server admin. - **One Slack workspace per Twenty workspace.** Connecting Slack claims that Slack team for the connecting Twenty workspace. On the same server, a second Twenty workspace connecting the same Slack team is rejected. Removing the connection releases the claim, so another Twenty workspace can then connect that Slack team. Uninstalling the app releases it too.
## Workflow field names (for step authors) ## Workflow field names (for step authors)
@@ -31,8 +31,8 @@
"oxlint": "^0.16.0", "oxlint": "^0.16.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"twenty-client-sdk": "^2.26.0", "twenty-client-sdk": "^2.27.0",
"twenty-sdk": "^2.26.0", "twenty-sdk": "^2.27.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite-tsconfig-paths": "^4.2.1", "vite-tsconfig-paths": "^4.2.1",
"vitest": "^4.0.0" "vitest": "^4.0.0"
@@ -3,6 +3,7 @@ import { defineConnectionProvider } from 'twenty-sdk/define';
import { import {
SLACK_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER, SLACK_CONNECTION_PROVIDER_UNIVERSAL_IDENTIFIER,
SLACK_REGISTER_CONNECTION_UNIVERSAL_IDENTIFIER, SLACK_REGISTER_CONNECTION_UNIVERSAL_IDENTIFIER,
SLACK_TEAM_RELEASE_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
} from 'src/constants/universal-identifiers'; } from 'src/constants/universal-identifiers';
export default defineConnectionProvider({ export default defineConnectionProvider({
@@ -13,6 +14,9 @@ export default defineConnectionProvider({
onConnectLogicFunction: { onConnectLogicFunction: {
universalIdentifier: SLACK_REGISTER_CONNECTION_UNIVERSAL_IDENTIFIER, universalIdentifier: SLACK_REGISTER_CONNECTION_UNIVERSAL_IDENTIFIER,
}, },
onDisconnectLogicFunction: {
universalIdentifier: SLACK_TEAM_RELEASE_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
},
oauth: { oauth: {
authorizationEndpoint: 'https://slack.com/oauth/v2/authorize', authorizationEndpoint: 'https://slack.com/oauth/v2/authorize',
tokenEndpoint: 'https://slack.com/api/oauth.v2.access', tokenEndpoint: 'https://slack.com/api/oauth.v2.access',
@@ -58,6 +58,12 @@ export const SLACK_ASSISTANT_WORKER_UNIVERSAL_IDENTIFIER =
export const SLACK_REGISTER_CONNECTION_UNIVERSAL_IDENTIFIER = export const SLACK_REGISTER_CONNECTION_UNIVERSAL_IDENTIFIER =
'a29ae15d-dd16-4b99-bb6c-079842da55ab'; 'a29ae15d-dd16-4b99-bb6c-079842da55ab';
export const SLACK_TEAM_RELEASE_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'4470aba8-5ff5-4800-88db-2a427cd8677c';
export const SLACK_APP_UNINSTALL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER =
'0c89ed56-7e69-40e4-b15a-919a4912ed9b';
export const SLACK_ASSISTANT_REQUEST_OBJECT_UNIVERSAL_IDENTIFIER = export const SLACK_ASSISTANT_REQUEST_OBJECT_UNIVERSAL_IDENTIFIER =
'4dfdd6c7-9042-4278-8d3e-8172a8a5e15f'; '4dfdd6c7-9042-4278-8d3e-8172a8a5e15f';
@@ -0,0 +1,3 @@
import { releaseAllSlackTeams } from 'src/logic-functions/utils/release-all-slack-teams';
export const slackAppUninstallHandler = () => releaseAllSlackTeams();
@@ -1,8 +1,8 @@
import { type SlackRegisterConnectionPayload } from 'src/logic-functions/types/slack-register-connection-payload.type'; import { type SlackConnectionHookPayload } from 'src/logic-functions/types/slack-connection-hook-payload.type';
import { registerSlackConnection } from 'src/logic-functions/utils/register-slack-connection'; import { registerSlackConnection } from 'src/logic-functions/utils/register-slack-connection';
export const slackRegisterConnectionHandler = ( export const slackRegisterConnectionHandler = (
payload: SlackRegisterConnectionPayload, payload: SlackConnectionHookPayload,
) => ) =>
registerSlackConnection({ registerSlackConnection({
connectedAccountId: payload.connectedAccountId, connectedAccountId: payload.connectedAccountId,
@@ -0,0 +1,7 @@
import { type SlackConnectionHookPayload } from 'src/logic-functions/types/slack-connection-hook-payload.type';
import { releaseSlackTeamOnDisconnect } from 'src/logic-functions/utils/release-slack-team-on-disconnect';
export const slackTeamReleaseHandler = (payload: SlackConnectionHookPayload) =>
releaseSlackTeamOnDisconnect({
connectedAccountId: payload.connectedAccountId,
});
@@ -0,0 +1,13 @@
import { defineUninstallLogicFunction } from 'twenty-sdk/define';
import { SLACK_APP_UNINSTALL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { slackAppUninstallHandler } from 'src/logic-functions/handlers/slack-app-uninstall-handler';
export default defineUninstallLogicFunction({
universalIdentifier: SLACK_APP_UNINSTALL_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'slack-app-uninstall',
description:
'Runs when the app is uninstalled, before its metadata is deleted. Releases the server-scoped slack-team claim of every remaining Slack connection, since uninstalling cascade-deletes the connections without going through the onDisconnect hook.',
timeoutSeconds: 30,
handler: slackAppUninstallHandler,
});
@@ -7,7 +7,7 @@ export default defineLogicFunction({
universalIdentifier: SLACK_REGISTER_CONNECTION_UNIVERSAL_IDENTIFIER, universalIdentifier: SLACK_REGISTER_CONNECTION_UNIVERSAL_IDENTIFIER,
name: 'slack-register-connection', name: 'slack-register-connection',
description: description:
'Runs when a Slack connection is established (via the connection provider onConnect hook). Resolves the Slack team_id for the just-created connection via auth.test and stores this workspace id under the server-scoped slack-team:<team_id> key so inbound Slack events route here. Caches the bot user id from the same auth.test under slack-bot-user-id so the channel welcome can recognise the bot without calling Slack on every join event.', 'Runs when a Slack connection is established (via the connection provider onConnect hook). Resolves the Slack team_id for the just-created connection via auth.test and stores this workspace id under the server-scoped slack-team:<team_id> key so inbound Slack events route here. Records the same team under slack-connected-account-team:<connected_account_id> so the disconnect hook can release the claim once the connection is gone. Caches the bot user id from the same auth.test under slack-bot-user-id so the channel welcome can recognise the bot without calling Slack on every join event.',
timeoutSeconds: 30, timeoutSeconds: 30,
handler: slackRegisterConnectionHandler, handler: slackRegisterConnectionHandler,
}); });
@@ -0,0 +1,13 @@
import { defineLogicFunction } from 'twenty-sdk/define';
import { SLACK_TEAM_RELEASE_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
import { slackTeamReleaseHandler } from 'src/logic-functions/handlers/slack-team-release-handler';
export default defineLogicFunction({
universalIdentifier: SLACK_TEAM_RELEASE_LOGIC_FUNCTION_UNIVERSAL_IDENTIFIER,
name: 'slack-team-release',
description:
'Runs when a Slack connection is removed (via the connection provider onDisconnect hook). Releases the server-scoped slack-team:<team_id> claim this workspace took on connect so another workspace can connect the same Slack team.',
timeoutSeconds: 30,
handler: slackTeamReleaseHandler,
});
@@ -0,0 +1,4 @@
export type ReleaseSlackTeamClaimResult = {
ok: true;
releasedTeamId: string | null;
};
@@ -1,4 +1,4 @@
export type SlackRegisterConnectionPayload = { export type SlackConnectionHookPayload = {
connectionProviderId: string; connectionProviderId: string;
connectionProviderName: string; connectionProviderName: string;
connectedAccountId: string; connectedAccountId: string;
@@ -0,0 +1,3 @@
export const getSlackConnectedAccountTeamKvKey = (
connectedAccountId: string,
): string => `slack-connected-account-team:${connectedAccountId}`;
@@ -0,0 +1,8 @@
import { kv } from 'twenty-sdk/logic-function';
import { getSlackConnectedAccountTeamKvKey } from 'src/logic-functions/utils/get-slack-connected-account-team-kv-key';
export const getSlackConnectedAccountTeam = async (
connectedAccountId: string,
): Promise<string | null> =>
kv.get<string>(getSlackConnectedAccountTeamKvKey(connectedAccountId));
@@ -0,0 +1,27 @@
import { listConnections } from 'twenty-sdk/logic-function';
import { getSlackConnectedAccountTeam } from 'src/logic-functions/utils/get-slack-connected-account-team';
type IsSlackTeamClaimedByAnotherConnectionArgs = {
teamId: string;
excludedConnectedAccountId: string;
};
export const isSlackTeamClaimedByAnotherConnection = async ({
teamId,
excludedConnectedAccountId,
}: IsSlackTeamClaimedByAnotherConnectionArgs): Promise<boolean> => {
const connections = await listConnections({ providerName: 'slack' });
const otherConnections = connections.filter(
(connection) => connection.id !== excludedConnectedAccountId,
);
const claimedTeamIds = await Promise.all(
otherConnections.map((connection) =>
getSlackConnectedAccountTeam(connection.id),
),
);
return claimedTeamIds.includes(teamId);
};
@@ -3,6 +3,7 @@ import { isNonEmptyString } from '@sniptt/guards';
import { getConnection, kv } from 'twenty-sdk/logic-function'; import { getConnection, kv } from 'twenty-sdk/logic-function';
import { cacheSlackBotUserId } from 'src/logic-functions/utils/cache-slack-bot-user-id'; import { cacheSlackBotUserId } from 'src/logic-functions/utils/cache-slack-bot-user-id';
import { getSlackConnectedAccountTeamKvKey } from 'src/logic-functions/utils/get-slack-connected-account-team-kv-key';
import { getSlackTeamKvKey } from 'src/logic-functions/utils/get-slack-team-kv-key'; import { getSlackTeamKvKey } from 'src/logic-functions/utils/get-slack-team-kv-key';
type RegisterSlackConnectionArgs = { type RegisterSlackConnectionArgs = {
@@ -35,7 +36,7 @@ export const registerSlackConnection = async ({
throw new Error('Slack auth.test returned no user_id for the bot'); throw new Error('Slack auth.test returned no user_id for the bot');
} }
// TODO: release the claim on disconnect once connection providers expose an onDisconnect hook. await kv.set(getSlackConnectedAccountTeamKvKey(connectedAccountId), teamId);
await kv.set(getSlackTeamKvKey(teamId), null, { scope: 'SERVER' }); await kv.set(getSlackTeamKvKey(teamId), null, { scope: 'SERVER' });
await cacheSlackBotUserId(botUserId); await cacheSlackBotUserId(botUserId);
@@ -0,0 +1,31 @@
import { isNonEmptyString } from '@sniptt/guards';
import { listConnections } from 'twenty-sdk/logic-function';
import { getSlackConnectedAccountTeam } from 'src/logic-functions/utils/get-slack-connected-account-team';
import { releaseSlackTeamClaim } from 'src/logic-functions/utils/release-slack-team-claim';
type ReleaseAllSlackTeamsResult = {
ok: true;
releasedTeamIds: string[];
};
export const releaseAllSlackTeams =
async (): Promise<ReleaseAllSlackTeamsResult> => {
const connections = await listConnections({ providerName: 'slack' });
const results = await Promise.all(
connections.map(async (connection) =>
releaseSlackTeamClaim({
connectedAccountId: connection.id,
teamId: await getSlackConnectedAccountTeam(connection.id),
}),
),
);
return {
ok: true,
releasedTeamIds: results
.map((result) => result.releasedTeamId)
.filter((teamId): teamId is string => isNonEmptyString(teamId)),
};
};
@@ -0,0 +1,24 @@
import { isNonEmptyString } from '@sniptt/guards';
import { kv } from 'twenty-sdk/logic-function';
import { type ReleaseSlackTeamClaimResult } from 'src/logic-functions/types/release-slack-team-claim-result.type';
import { getSlackConnectedAccountTeamKvKey } from 'src/logic-functions/utils/get-slack-connected-account-team-kv-key';
import { getSlackTeamKvKey } from 'src/logic-functions/utils/get-slack-team-kv-key';
type ReleaseSlackTeamClaimArgs = {
connectedAccountId: string;
teamId: string | null;
};
export const releaseSlackTeamClaim = async ({
connectedAccountId,
teamId,
}: ReleaseSlackTeamClaimArgs): Promise<ReleaseSlackTeamClaimResult> => {
const hasReleasedClaim = isNonEmptyString(teamId)
? await kv.delete(getSlackTeamKvKey(teamId), { scope: 'SERVER' })
: false;
await kv.delete(getSlackConnectedAccountTeamKvKey(connectedAccountId));
return { ok: true, releasedTeamId: hasReleasedClaim ? teamId : null };
};
@@ -0,0 +1,34 @@
import { isNonEmptyString } from '@sniptt/guards';
import { type ReleaseSlackTeamClaimResult } from 'src/logic-functions/types/release-slack-team-claim-result.type';
import { getSlackConnectedAccountTeam } from 'src/logic-functions/utils/get-slack-connected-account-team';
import { isSlackTeamClaimedByAnotherConnection } from 'src/logic-functions/utils/is-slack-team-claimed-by-another-connection';
import { releaseSlackTeamClaim } from 'src/logic-functions/utils/release-slack-team-claim';
type ReleaseSlackTeamOnDisconnectArgs = {
connectedAccountId: string;
};
export const releaseSlackTeamOnDisconnect = async ({
connectedAccountId,
}: ReleaseSlackTeamOnDisconnectArgs): Promise<ReleaseSlackTeamClaimResult> => {
if (!isNonEmptyString(connectedAccountId)) {
throw new Error(
'Slack team release failed: onDisconnect payload is missing connectedAccountId',
);
}
const teamId = await getSlackConnectedAccountTeam(connectedAccountId);
const isClaimedByAnotherConnection =
isNonEmptyString(teamId) &&
(await isSlackTeamClaimedByAnotherConnection({
teamId,
excludedConnectedAccountId: connectedAccountId,
}));
return releaseSlackTeamClaim({
connectedAccountId,
teamId: isClaimedByAnotherConnection ? null : teamId,
});
};
+11 -11
View File
@@ -990,8 +990,8 @@ __metadata:
oxlint: "npm:^0.16.0" oxlint: "npm:^0.16.0"
react: "npm:^18.2.0" react: "npm:^18.2.0"
react-dom: "npm:^18.2.0" react-dom: "npm:^18.2.0"
twenty-client-sdk: "npm:^2.26.0" twenty-client-sdk: "npm:^2.27.0"
twenty-sdk: "npm:^2.26.0" twenty-sdk: "npm:^2.27.0"
typescript: "npm:^5.9.3" typescript: "npm:^5.9.3"
vite-tsconfig-paths: "npm:^4.2.1" vite-tsconfig-paths: "npm:^4.2.1"
vitest: "npm:^4.0.0" vitest: "npm:^4.0.0"
@@ -3003,22 +3003,22 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"twenty-client-sdk@npm:2.26.0, twenty-client-sdk@npm:^2.26.0": "twenty-client-sdk@npm:2.27.0, twenty-client-sdk@npm:^2.27.0":
version: 2.26.0 version: 2.27.0
resolution: "twenty-client-sdk@npm:2.26.0" resolution: "twenty-client-sdk@npm:2.27.0"
dependencies: dependencies:
"@genql/runtime": "npm:^2.10.0" "@genql/runtime": "npm:^2.10.0"
esbuild: "npm:^0.28.1" esbuild: "npm:^0.28.1"
graphql: "npm:^16.8.1" graphql: "npm:^16.8.1"
lodash: "npm:^4.17.21" lodash: "npm:^4.17.21"
prettier: "npm:^3.8.3" prettier: "npm:^3.8.3"
checksum: 10c0/f5d184567877d10175f887e5ee81510d43c8c5c4d98ad73fbc757a28547da8299ccbe3072d9de33f8c9e9a41eafe06f335b3706196f4a92fd33b0cdcc5d47acf checksum: 10c0/7b9e90246fc17a0856867b30a7acf083144005e9ba684e92492b92efdf36792f2deeb55499d4bd7564932f2853fe4eb27bad6539ff93ea536e33119e78c032a3
languageName: node languageName: node
linkType: hard linkType: hard
"twenty-sdk@npm:^2.26.0": "twenty-sdk@npm:^2.27.0":
version: 2.26.0 version: 2.27.0
resolution: "twenty-sdk@npm:2.26.0" resolution: "twenty-sdk@npm:2.27.0"
dependencies: dependencies:
"@sniptt/guards": "npm:^0.2.0" "@sniptt/guards": "npm:^0.2.0"
axios: "npm:^1.16.0" axios: "npm:^1.16.0"
@@ -3037,12 +3037,12 @@ __metadata:
semver: "npm:7.6.3" semver: "npm:7.6.3"
sharp: "npm:^0.34.5" sharp: "npm:^0.34.5"
tinyglobby: "npm:^0.2.15" tinyglobby: "npm:^0.2.15"
twenty-client-sdk: "npm:2.26.0" twenty-client-sdk: "npm:2.27.0"
typescript: "npm:^5.9.3" typescript: "npm:^5.9.3"
uuid: "npm:^13.0.2" uuid: "npm:^13.0.2"
bin: bin:
twenty: dist/cli.cjs twenty: dist/cli.cjs
checksum: 10c0/e3edaced59a93bfa3579dc863f799693c9361fc4993eec6942f711a394a678dff21a9c790a3ad6b1683f71ed3f80ec7e6bcc4172d123f538912f88ff7ca32cfa checksum: 10c0/aa88f4c0359f03a99e0a30092f27d3739d176749f9fed9e1f50b9e99f6c4a21fea7149258973dfe23cf2c706c026800026b5d65e67bd5e5a3c868f33502dc1d3
languageName: node languageName: node
linkType: hard linkType: hard