@shipfox/api-integration-core 4.0.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +100 -0
  3. package/dist/core/entities/provider.d.ts +7 -2
  4. package/dist/core/entities/provider.d.ts.map +1 -1
  5. package/dist/core/entities/provider.js.map +1 -1
  6. package/dist/core/errors.d.ts +7 -0
  7. package/dist/core/errors.d.ts.map +1 -1
  8. package/dist/core/errors.js +8 -2
  9. package/dist/core/errors.js.map +1 -1
  10. package/dist/db/webhook-deliveries.d.ts +4 -0
  11. package/dist/db/webhook-deliveries.d.ts.map +1 -1
  12. package/dist/db/webhook-deliveries.js +10 -2
  13. package/dist/db/webhook-deliveries.js.map +1 -1
  14. package/dist/index.d.ts +19 -6
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +51 -6
  17. package/dist/index.js.map +1 -1
  18. package/dist/presentation/inter-module.d.ts +9 -0
  19. package/dist/presentation/inter-module.d.ts.map +1 -0
  20. package/dist/presentation/inter-module.js +121 -0
  21. package/dist/presentation/inter-module.js.map +1 -0
  22. package/dist/presentation/routes/agent-tools-gateway/index.d.ts +1 -0
  23. package/dist/presentation/routes/agent-tools-gateway/index.d.ts.map +1 -1
  24. package/dist/presentation/routes/agent-tools-gateway/index.js +1 -0
  25. package/dist/presentation/routes/agent-tools-gateway/index.js.map +1 -1
  26. package/dist/presentation/routes/agent-tools-gateway/resolve-authorized-tools.d.ts +5 -2
  27. package/dist/presentation/routes/agent-tools-gateway/resolve-authorized-tools.d.ts.map +1 -1
  28. package/dist/presentation/routes/agent-tools-gateway/resolve-authorized-tools.js +53 -17
  29. package/dist/presentation/routes/agent-tools-gateway/resolve-authorized-tools.js.map +1 -1
  30. package/dist/presentation/routes/index.d.ts +2 -3
  31. package/dist/presentation/routes/index.d.ts.map +1 -1
  32. package/dist/presentation/routes/index.js +3 -3
  33. package/dist/presentation/routes/index.js.map +1 -1
  34. package/dist/presentation/routes/manage-connections.d.ts +1 -1
  35. package/dist/presentation/routes/manage-connections.d.ts.map +1 -1
  36. package/dist/presentation/routes/manage-connections.js +28 -3
  37. package/dist/presentation/routes/manage-connections.js.map +1 -1
  38. package/dist/providers/gitea.d.ts.map +1 -1
  39. package/dist/providers/gitea.js +10 -8
  40. package/dist/providers/gitea.js.map +1 -1
  41. package/dist/providers/github.d.ts.map +1 -1
  42. package/dist/providers/github.js +18 -13
  43. package/dist/providers/github.js.map +1 -1
  44. package/dist/providers/jira.d.ts.map +1 -1
  45. package/dist/providers/jira.js +113 -3
  46. package/dist/providers/jira.js.map +1 -1
  47. package/dist/providers/linear.d.ts.map +1 -1
  48. package/dist/providers/linear.js +35 -16
  49. package/dist/providers/linear.js.map +1 -1
  50. package/dist/providers/sentry.d.ts.map +1 -1
  51. package/dist/providers/sentry.js +13 -11
  52. package/dist/providers/sentry.js.map +1 -1
  53. package/dist/providers/slack.d.ts.map +1 -1
  54. package/dist/providers/slack.js +36 -16
  55. package/dist/providers/slack.js.map +1 -1
  56. package/dist/providers/types.d.ts +12 -0
  57. package/dist/providers/types.d.ts.map +1 -1
  58. package/dist/providers/types.js.map +1 -1
  59. package/dist/providers/webhook.d.ts.map +1 -1
  60. package/dist/providers/webhook.js +11 -9
  61. package/dist/providers/webhook.js.map +1 -1
  62. package/dist/tsconfig.test.tsbuildinfo +1 -1
  63. package/package.json +33 -38
  64. package/src/core/entities/provider.ts +7 -1
  65. package/src/core/errors.ts +12 -2
  66. package/src/db/webhook-deliveries.test.ts +11 -0
  67. package/src/db/webhook-deliveries.ts +12 -3
  68. package/src/index.test.ts +112 -0
  69. package/src/index.ts +104 -10
  70. package/src/presentation/inter-module.ts +155 -0
  71. package/src/presentation/routes/agent-tools-gateway/index.ts +1 -0
  72. package/src/presentation/routes/agent-tools-gateway/resolve-authorized-tools.test.ts +36 -0
  73. package/src/presentation/routes/agent-tools-gateway/resolve-authorized-tools.ts +64 -22
  74. package/src/presentation/routes/index.ts +5 -6
  75. package/src/presentation/routes/manage-connections.test.ts +113 -0
  76. package/src/presentation/routes/manage-connections.ts +26 -2
  77. package/src/providers/gitea.ts +11 -8
  78. package/src/providers/github.ts +17 -11
  79. package/src/providers/jira.ts +155 -7
  80. package/src/providers/linear.test.ts +136 -0
  81. package/src/providers/linear.ts +33 -13
  82. package/src/providers/sentry.ts +15 -12
  83. package/src/providers/slack.test.ts +139 -0
  84. package/src/providers/slack.ts +39 -14
  85. package/src/providers/types.ts +15 -0
  86. package/src/providers/webhook.ts +11 -9
  87. package/tsconfig.build.tsbuildinfo +1 -1
@@ -1,22 +1,163 @@
1
+ import {
2
+ type IntegrationConnection as CoreIntegrationConnection,
3
+ slugifyConnectionSlug,
4
+ } from '@shipfox/api-integration-core-dto';
5
+ import type {
6
+ ConnectJiraInstallationInput,
7
+ JiraPendingSelectionSecretsStore,
8
+ JiraSecretsStore,
9
+ } from '@shipfox/api-integration-jira';
1
10
  import {config} from '#config.js';
11
+ import {
12
+ deleteIntegrationConnection,
13
+ getIntegrationConnectionById,
14
+ resolveUniqueConnectionSlug,
15
+ updateIntegrationConnectionLifecycleStatus,
16
+ upsertIntegrationConnection,
17
+ } from '#db/connections.js';
18
+ import {db} from '#db/db.js';
19
+ import {retryConnectionSlugCollision} from '#providers/connection-slug.js';
2
20
  import type {IntegrationModuleParts, IntegrationProviderModule} from '#providers/types.js';
3
21
 
4
22
  const JIRA_MIGRATIONS_TABLE = '__drizzle_migrations_integrations_jira';
23
+ const JIRA_SECRETS_NAMESPACE_PREFIX = 'system/integrations/jira/';
24
+ type IntegrationDb = ReturnType<typeof db>;
25
+ type IntegrationTx = Parameters<Parameters<IntegrationDb['transaction']>[0]>[0];
5
26
 
6
- async function loadJiraModuleParts(): Promise<IntegrationModuleParts> {
27
+ async function loadJiraModuleParts(
28
+ options: Parameters<IntegrationProviderModule['load']>[0] = {},
29
+ ): Promise<IntegrationModuleParts> {
7
30
  const {
8
31
  createJiraIntegrationProvider,
32
+ createJiraPendingSelectionStore,
33
+ createJiraTokenStore,
9
34
  db: jiraDb,
35
+ disconnectJiraInstallation: disconnectJiraInstallationRecords,
36
+ getJiraInstallationByCloudId,
10
37
  migrationsPath,
38
+ upsertJiraInstallation,
11
39
  } = await import('@shipfox/api-integration-jira');
12
40
 
13
- return {
14
- provider: createJiraIntegrationProvider(),
15
- database: {
16
- db: jiraDb,
17
- migrationsPath,
18
- migrationsTableName: JIRA_MIGRATIONS_TABLE,
41
+ async function getExistingJiraConnection(input: {
42
+ cloudId: string;
43
+ }): Promise<CoreIntegrationConnection<'jira'> | undefined> {
44
+ const installation = await getJiraInstallationByCloudId(input.cloudId);
45
+ if (!installation) return undefined;
46
+ return (await getIntegrationConnectionById(installation.connectionId)) as
47
+ | CoreIntegrationConnection<'jira'>
48
+ | undefined;
49
+ }
50
+
51
+ function connectJiraInstallation(
52
+ input: ConnectJiraInstallationInput,
53
+ ): Promise<CoreIntegrationConnection<'jira'>> {
54
+ return retryConnectionSlugCollision(() =>
55
+ db().transaction(async (tx) => {
56
+ const slug = await resolveUniqueConnectionSlug(
57
+ {
58
+ workspaceId: input.workspaceId,
59
+ provider: 'jira',
60
+ externalAccountId: input.cloudId,
61
+ baseSlug: slugifyConnectionSlug(`jira_${input.siteName || input.cloudId}`, {
62
+ fallback: 'jira',
63
+ }),
64
+ },
65
+ {tx},
66
+ );
67
+ const connection = await upsertIntegrationConnection(
68
+ {
69
+ workspaceId: input.workspaceId,
70
+ provider: 'jira',
71
+ externalAccountId: input.cloudId,
72
+ slug,
73
+ displayName: input.displayName,
74
+ lifecycleStatus: 'active',
75
+ },
76
+ {tx},
77
+ );
78
+ await upsertJiraInstallation(
79
+ {
80
+ connectionId: connection.id,
81
+ cloudId: input.cloudId,
82
+ siteUrl: input.siteUrl,
83
+ siteName: input.siteName,
84
+ authorizingAccountId: input.authorizingAccountId,
85
+ scopes: input.scopes,
86
+ status: 'installed',
87
+ tokenExpiresAt: input.tokenExpiresAt,
88
+ },
89
+ {tx},
90
+ );
91
+ return connection as CoreIntegrationConnection<'jira'>;
92
+ }),
93
+ );
94
+ }
95
+
96
+ async function disconnectJiraInstallation(input: {connectionId: string}): Promise<void> {
97
+ await disconnectJiraInstallationRecords<IntegrationTx>({
98
+ connectionId: input.connectionId,
99
+ getConnection: getIntegrationConnectionById,
100
+ deleteSecrets: (params) =>
101
+ options.secrets?.jira?.deleteSecrets({
102
+ ...params,
103
+ namespace: jiraNamespaceSuffix(params.namespace),
104
+ }) ?? Promise.resolve(0),
105
+ transaction: (fn) => db().transaction((tx) => fn(tx)),
106
+ deleteConnection: (params, transactionOptions) =>
107
+ deleteIntegrationConnection({id: params.connectionId}, transactionOptions),
108
+ });
109
+ }
110
+
111
+ const fallbackSecrets: JiraSecretsStore & JiraPendingSelectionSecretsStore = {
112
+ getSecret: () => Promise.resolve(null),
113
+ setSecrets: () => Promise.reject(new Error('Jira token storage is not configured')),
114
+ deleteSecrets: () => Promise.resolve(0),
115
+ };
116
+ const secrets: JiraSecretsStore & JiraPendingSelectionSecretsStore = options.secrets?.jira
117
+ ? {
118
+ getSecret: (params) =>
119
+ options.secrets?.jira?.getSecret({
120
+ ...params,
121
+ namespace: jiraNamespaceSuffix(params.namespace),
122
+ }) ?? Promise.resolve(null),
123
+ setSecrets: (params) =>
124
+ options.secrets?.jira?.setSecrets({
125
+ ...params,
126
+ namespace: jiraNamespaceSuffix(params.namespace),
127
+ }) ?? Promise.resolve(),
128
+ deleteSecrets: (params) =>
129
+ options.secrets?.jira?.deleteSecrets({
130
+ ...params,
131
+ namespace: jiraNamespaceSuffix(params.namespace),
132
+ }) ?? Promise.resolve(0),
133
+ }
134
+ : fallbackSecrets;
135
+ const tokenStore = createJiraTokenStore({
136
+ resolveConnection: getIntegrationConnectionById,
137
+ secrets,
138
+ markConnectionError: async ({connectionId}) => {
139
+ await updateIntegrationConnectionLifecycleStatus({
140
+ id: connectionId,
141
+ lifecycleStatus: 'error',
142
+ });
19
143
  },
144
+ });
145
+ const pendingStore = createJiraPendingSelectionStore({secrets});
146
+
147
+ return {
148
+ provider: createJiraIntegrationProvider({
149
+ routes: {
150
+ tokenStore,
151
+ pendingStore,
152
+ getExistingJiraConnection,
153
+ connectJiraInstallation,
154
+ disconnectJiraInstallation,
155
+ ...(options.requireActiveWorkspaceMembership
156
+ ? {requireActiveWorkspaceMembership: options.requireActiveWorkspaceMembership}
157
+ : {}),
158
+ },
159
+ }),
160
+ database: {db: jiraDb, migrationsPath, migrationsTableName: JIRA_MIGRATIONS_TABLE},
20
161
  };
21
162
  }
22
163
 
@@ -25,3 +166,10 @@ export const jiraProviderModule: IntegrationProviderModule = {
25
166
  enabled: config.INTEGRATIONS_ENABLE_JIRA_PROVIDER,
26
167
  load: loadJiraModuleParts,
27
168
  };
169
+
170
+ function jiraNamespaceSuffix(namespace: string): string {
171
+ if (!namespace.startsWith(JIRA_SECRETS_NAMESPACE_PREFIX)) {
172
+ throw new Error('Jira provider attempted to access an unscoped secret namespace');
173
+ }
174
+ return namespace.slice(JIRA_SECRETS_NAMESPACE_PREFIX.length);
175
+ }
@@ -0,0 +1,136 @@
1
+ import {
2
+ getLinearInstallationByConnectionId,
3
+ upsertLinearInstallation,
4
+ } from '@shipfox/api-integration-linear';
5
+ import {runMigrations} from '@shipfox/node-drizzle';
6
+ import {getIntegrationConnectionById, upsertIntegrationConnection} from '#db/connections.js';
7
+ import {db} from '#db/db.js';
8
+ import {linearProviderModule} from '#providers/linear.js';
9
+ import {createTestApp, useIntegrationRouteTest} from '#test/route-utils.js';
10
+
11
+ describe('linearProviderModule', () => {
12
+ const context = useIntegrationRouteTest();
13
+
14
+ it('deletes the installation and tokens through the generic route before allowing a reinstall', async () => {
15
+ const deleteSecrets = vi.fn(() => Promise.resolve(2));
16
+ const scopedSecrets = {
17
+ getSecret: vi.fn(() => Promise.resolve(null)),
18
+ setSecrets: vi.fn(() => Promise.resolve()),
19
+ deleteSecrets,
20
+ };
21
+ const linearPart = await linearProviderModule.load({
22
+ secrets: {linear: scopedSecrets, deleteSecrets},
23
+ });
24
+ if (!linearPart.database) throw new Error('Linear provider database is not configured');
25
+ const organizationId = crypto.randomUUID();
26
+
27
+ await runMigrations(
28
+ linearPart.database.db(),
29
+ linearPart.database.migrationsPath,
30
+ linearPart.database.migrationsTableName,
31
+ );
32
+ const app = await createTestApp([linearPart.provider]);
33
+ const connection = await upsertIntegrationConnection({
34
+ workspaceId: context.workspaceId,
35
+ provider: 'linear',
36
+ externalAccountId: organizationId,
37
+ slug: 'linear_acme',
38
+ displayName: 'Linear Acme',
39
+ });
40
+ await upsertLinearInstallation({
41
+ connectionId: connection.id,
42
+ organizationId,
43
+ organizationUrlKey: 'acme',
44
+ appUserId: 'user-1',
45
+ scopes: ['read'],
46
+ status: 'installed',
47
+ tokenExpiresAt: null,
48
+ });
49
+
50
+ const res = await app.inject({
51
+ method: 'DELETE',
52
+ url: `/integration-connections/${connection.id}`,
53
+ headers: {authorization: 'Bearer user'},
54
+ });
55
+
56
+ expect(res.statusCode).toBe(204);
57
+ await expect(getIntegrationConnectionById(connection.id)).resolves.toBeUndefined();
58
+ await expect(getLinearInstallationByConnectionId(connection.id)).resolves.toBeUndefined();
59
+ expect(deleteSecrets).toHaveBeenCalledWith({
60
+ workspaceId: context.workspaceId,
61
+ namespace: connection.id,
62
+ });
63
+
64
+ const replacement = await upsertIntegrationConnection({
65
+ workspaceId: context.workspaceId,
66
+ provider: 'linear',
67
+ externalAccountId: organizationId,
68
+ slug: 'linear_acme_again',
69
+ displayName: 'Linear Acme',
70
+ });
71
+ await upsertLinearInstallation({
72
+ connectionId: replacement.id,
73
+ organizationId,
74
+ organizationUrlKey: 'acme',
75
+ appUserId: 'user-1',
76
+ scopes: ['read'],
77
+ status: 'installed',
78
+ tokenExpiresAt: null,
79
+ });
80
+
81
+ await expect(getLinearInstallationByConnectionId(replacement.id)).resolves.toMatchObject({
82
+ organizationId,
83
+ });
84
+ });
85
+
86
+ it('rolls back provider record cleanup when its transaction fails', async () => {
87
+ const deleteSecrets = vi.fn(() => Promise.resolve(2));
88
+ const scopedSecrets = {
89
+ getSecret: vi.fn(() => Promise.resolve(null)),
90
+ setSecrets: vi.fn(() => Promise.resolve()),
91
+ deleteSecrets,
92
+ };
93
+ const linearPart = await linearProviderModule.load({
94
+ secrets: {linear: scopedSecrets, deleteSecrets},
95
+ });
96
+ if (!linearPart.database) throw new Error('Linear provider database is not configured');
97
+ const organizationId = crypto.randomUUID();
98
+
99
+ await runMigrations(
100
+ linearPart.database.db(),
101
+ linearPart.database.migrationsPath,
102
+ linearPart.database.migrationsTableName,
103
+ );
104
+ const connection = await upsertIntegrationConnection({
105
+ workspaceId: context.workspaceId,
106
+ provider: 'linear',
107
+ externalAccountId: organizationId,
108
+ slug: 'linear_acme',
109
+ displayName: 'Linear Acme',
110
+ });
111
+ await upsertLinearInstallation({
112
+ connectionId: connection.id,
113
+ organizationId,
114
+ organizationUrlKey: 'acme',
115
+ appUserId: 'user-1',
116
+ scopes: ['read'],
117
+ status: 'installed',
118
+ tokenExpiresAt: null,
119
+ });
120
+
121
+ await expect(
122
+ db().transaction(async (tx) => {
123
+ await linearPart.provider.deleteConnectionRecords?.(connection, {tx});
124
+ throw new Error('transaction failed');
125
+ }),
126
+ ).rejects.toThrow('transaction failed');
127
+
128
+ await expect(getIntegrationConnectionById(connection.id)).resolves.toMatchObject({
129
+ id: connection.id,
130
+ });
131
+ await expect(getLinearInstallationByConnectionId(connection.id)).resolves.toMatchObject({
132
+ connectionId: connection.id,
133
+ });
134
+ expect(deleteSecrets).not.toHaveBeenCalled();
135
+ });
136
+ });
@@ -32,9 +32,11 @@ async function loadLinearModuleParts(
32
32
  createLinearE2eRoutes,
33
33
  createLinearIntegrationProvider,
34
34
  config: linearConfig,
35
+ deleteLinearInstallationByConnectionId,
35
36
  disconnectLinearInstallation: disconnectLinearInstallationRecords,
36
37
  getLinearInstallationByOrganizationId,
37
38
  db: linearDb,
39
+ linearSecretsNamespace,
38
40
  migrationsPath: linearMigrationsPath,
39
41
  upsertLinearInstallation,
40
42
  } = await import('@shipfox/api-integration-linear');
@@ -134,20 +136,38 @@ async function loadLinearModuleParts(
134
136
  secrets,
135
137
  });
136
138
 
137
- return {
138
- provider: createLinearIntegrationProvider({
139
- agentTools: {tokenStore, endpoint: linearConfig.LINEAR_MCP_ENDPOINT},
140
- routes: {
141
- tokenStore,
142
- getExistingLinearConnection,
143
- connectLinearInstallation,
144
- disconnectLinearInstallation,
145
- publishIntegrationEventReceived,
146
- recordDeliveryOnly,
147
- getIntegrationConnectionById,
148
- coreDb: db,
139
+ const integrationProvider = createLinearIntegrationProvider({
140
+ agentTools: {tokenStore, endpoint: linearConfig.LINEAR_MCP_ENDPOINT},
141
+ cleanup: {
142
+ deleteConnectionRecords: async (connection, {tx}) => {
143
+ await deleteLinearInstallationByConnectionId(connection.id, {tx});
144
+ },
145
+ deleteConnectionSecrets: async (connection) => {
146
+ // Scoped secrets accept the provider-local suffix, after this helper validates its prefix.
147
+ await (options.secrets?.linear?.deleteSecrets({
148
+ workspaceId: connection.workspaceId,
149
+ namespace: linearNamespaceSuffix(linearSecretsNamespace(connection.id)),
150
+ }) ?? Promise.resolve());
149
151
  },
150
- }),
152
+ },
153
+ routes: {
154
+ tokenStore,
155
+ getExistingLinearConnection,
156
+ connectLinearInstallation,
157
+ disconnectLinearInstallation,
158
+ publishIntegrationEventReceived,
159
+ recordDeliveryOnly,
160
+ getIntegrationConnectionById,
161
+ coreDb: db,
162
+ ...(options.requireActiveWorkspaceMembership
163
+ ? {requireActiveWorkspaceMembership: options.requireActiveWorkspaceMembership}
164
+ : {}),
165
+ },
166
+ });
167
+
168
+ return {
169
+ provider: integrationProvider,
170
+ webhookProcessors: integrationProvider.webhookProcessors,
151
171
  e2eRoutes: [
152
172
  createLinearE2eRoutes({
153
173
  tokenStore,
@@ -84,19 +84,22 @@ async function loadSentryModuleParts(): Promise<IntegrationModuleParts> {
84
84
  );
85
85
  }
86
86
 
87
+ const integrationProvider = createSentryIntegrationProvider({
88
+ getSentryInstallation: ({installationUuid}) =>
89
+ getSentryInstallationByInstallationUuid(installationUuid),
90
+ getConnectionById,
91
+ connectSentryInstallation,
92
+ persistVerifiedUnclaimedInstallation,
93
+ coreDb: db,
94
+ publishIntegrationEventReceived,
95
+ recordDeliveryOnly,
96
+ getIntegrationConnectionById,
97
+ updateConnectionLifecycleStatus: updateIntegrationConnectionLifecycleStatus,
98
+ });
99
+
87
100
  return {
88
- provider: createSentryIntegrationProvider({
89
- getSentryInstallation: ({installationUuid}) =>
90
- getSentryInstallationByInstallationUuid(installationUuid),
91
- getConnectionById,
92
- connectSentryInstallation,
93
- persistVerifiedUnclaimedInstallation,
94
- coreDb: db,
95
- publishIntegrationEventReceived,
96
- recordDeliveryOnly,
97
- getIntegrationConnectionById,
98
- updateConnectionLifecycleStatus: updateIntegrationConnectionLifecycleStatus,
99
- }),
101
+ provider: integrationProvider,
102
+ webhookProcessors: integrationProvider.webhookProcessors,
100
103
  database: {
101
104
  db: sentryDb,
102
105
  migrationsPath: sentryMigrationsPath,
@@ -4,8 +4,12 @@ import {
4
4
  } from '@shipfox/api-integration-slack';
5
5
  import {runMigrations} from '@shipfox/node-drizzle';
6
6
  import {getIntegrationConnectionById, upsertIntegrationConnection} from '#db/connections.js';
7
+ import {db} from '#db/db.js';
8
+ import {createTestApp, useIntegrationRouteTest} from '#test/route-utils.js';
7
9
 
8
10
  describe('slackProviderModule', () => {
11
+ const context = useIntegrationRouteTest();
12
+
9
13
  afterEach(() => {
10
14
  vi.unstubAllEnvs();
11
15
  vi.resetModules();
@@ -54,4 +58,139 @@ describe('slackProviderModule', () => {
54
58
  teamId,
55
59
  });
56
60
  });
61
+
62
+ it('deletes the installation and token through the generic route before allowing a reinstall', async () => {
63
+ vi.stubEnv('INTEGRATIONS_ENABLE_SLACK_PROVIDER', 'true');
64
+ vi.resetModules();
65
+ const deleteSecrets = vi.fn(() => Promise.resolve(1));
66
+ const scopedSecrets = {
67
+ getSecret: vi.fn(() => Promise.resolve(null)),
68
+ setSecrets: vi.fn(() => Promise.resolve()),
69
+ deleteSecrets,
70
+ };
71
+ const {createPostgresClient} = await import('@shipfox/node-postgres');
72
+ createPostgresClient();
73
+ const {loadEnabledProviderModules} = await import('#providers/modules.js');
74
+ const parts = await loadEnabledProviderModules({
75
+ secrets: {slack: scopedSecrets, deleteSecrets},
76
+ });
77
+ const slackPart = parts.find((part) => part.provider.provider === 'slack');
78
+ if (!slackPart?.database) throw new Error('Slack provider database is not configured');
79
+ const teamId = `T${crypto.randomUUID()}`;
80
+
81
+ await runMigrations(
82
+ slackPart.database.db(),
83
+ slackPart.database.migrationsPath,
84
+ slackPart.database.migrationsTableName,
85
+ );
86
+ const app = await createTestApp([slackPart.provider]);
87
+ const connection = await upsertIntegrationConnection({
88
+ workspaceId: context.workspaceId,
89
+ provider: 'slack',
90
+ externalAccountId: teamId,
91
+ slug: `slack_${teamId}`,
92
+ displayName: 'Slack Acme',
93
+ });
94
+ await upsertSlackInstallation({
95
+ connectionId: connection.id,
96
+ teamId,
97
+ teamName: 'Acme',
98
+ appId: 'A123',
99
+ botUserId: 'U123',
100
+ scopes: ['app_mentions:read'],
101
+ status: 'installed',
102
+ });
103
+
104
+ const res = await app.inject({
105
+ method: 'DELETE',
106
+ url: `/integration-connections/${connection.id}`,
107
+ headers: {authorization: 'Bearer user'},
108
+ });
109
+
110
+ expect(res.statusCode).toBe(204);
111
+ await expect(getIntegrationConnectionById(connection.id)).resolves.toBeUndefined();
112
+ await expect(getSlackInstallationByConnectionId(connection.id)).resolves.toBeUndefined();
113
+ expect(deleteSecrets).toHaveBeenCalledWith({
114
+ workspaceId: context.workspaceId,
115
+ namespace: connection.id,
116
+ });
117
+
118
+ const replacement = await upsertIntegrationConnection({
119
+ workspaceId: context.workspaceId,
120
+ provider: 'slack',
121
+ externalAccountId: teamId,
122
+ slug: `slack_${teamId}_again`,
123
+ displayName: 'Slack Acme',
124
+ });
125
+ await upsertSlackInstallation({
126
+ connectionId: replacement.id,
127
+ teamId,
128
+ teamName: 'Acme',
129
+ appId: 'A123',
130
+ botUserId: 'U123',
131
+ scopes: ['app_mentions:read'],
132
+ status: 'installed',
133
+ });
134
+
135
+ await expect(getSlackInstallationByConnectionId(replacement.id)).resolves.toMatchObject({
136
+ teamId,
137
+ });
138
+ });
139
+
140
+ it('rolls back provider record cleanup when its transaction fails', async () => {
141
+ vi.stubEnv('INTEGRATIONS_ENABLE_SLACK_PROVIDER', 'true');
142
+ vi.resetModules();
143
+ const deleteSecrets = vi.fn(() => Promise.resolve(1));
144
+ const scopedSecrets = {
145
+ getSecret: vi.fn(() => Promise.resolve(null)),
146
+ setSecrets: vi.fn(() => Promise.resolve()),
147
+ deleteSecrets,
148
+ };
149
+ const {createPostgresClient} = await import('@shipfox/node-postgres');
150
+ createPostgresClient();
151
+ const {loadEnabledProviderModules} = await import('#providers/modules.js');
152
+ const parts = await loadEnabledProviderModules({
153
+ secrets: {slack: scopedSecrets, deleteSecrets},
154
+ });
155
+ const slackPart = parts.find((part) => part.provider.provider === 'slack');
156
+ if (!slackPart?.database) throw new Error('Slack provider database is not configured');
157
+ const teamId = `T${crypto.randomUUID()}`;
158
+
159
+ await runMigrations(
160
+ slackPart.database.db(),
161
+ slackPart.database.migrationsPath,
162
+ slackPart.database.migrationsTableName,
163
+ );
164
+ const connection = await upsertIntegrationConnection({
165
+ workspaceId: context.workspaceId,
166
+ provider: 'slack',
167
+ externalAccountId: teamId,
168
+ slug: `slack_${teamId}`,
169
+ displayName: 'Slack Acme',
170
+ });
171
+ await upsertSlackInstallation({
172
+ connectionId: connection.id,
173
+ teamId,
174
+ teamName: 'Acme',
175
+ appId: 'A123',
176
+ botUserId: 'U123',
177
+ scopes: ['app_mentions:read'],
178
+ status: 'installed',
179
+ });
180
+
181
+ await expect(
182
+ db().transaction(async (tx) => {
183
+ await slackPart.provider.deleteConnectionRecords?.(connection, {tx});
184
+ throw new Error('transaction failed');
185
+ }),
186
+ ).rejects.toThrow('transaction failed');
187
+
188
+ await expect(getIntegrationConnectionById(connection.id)).resolves.toMatchObject({
189
+ id: connection.id,
190
+ });
191
+ await expect(getSlackInstallationByConnectionId(connection.id)).resolves.toMatchObject({
192
+ connectionId: connection.id,
193
+ });
194
+ expect(deleteSecrets).not.toHaveBeenCalled();
195
+ });
57
196
  });
@@ -14,7 +14,11 @@ import {
14
14
  upsertIntegrationConnection,
15
15
  } from '#db/connections.js';
16
16
  import {db} from '#db/db.js';
17
- import {publishIntegrationEventReceived, recordDeliveryOnly} from '#db/webhook-deliveries.js';
17
+ import {
18
+ claimWebhookDelivery,
19
+ publishIntegrationEventReceived,
20
+ recordDeliveryOnly,
21
+ } from '#db/webhook-deliveries.js';
18
22
  import {retryConnectionSlugCollision} from '#providers/connection-slug.js';
19
23
  import type {IntegrationModuleParts, IntegrationProviderModule} from '#providers/types.js';
20
24
 
@@ -32,9 +36,11 @@ async function loadSlackModuleParts(
32
36
  createSlackIntegrationProvider,
33
37
  createSlackTokenStore,
34
38
  db: slackDb,
39
+ deleteSlackInstallationByConnectionId,
35
40
  disconnectSlackInstallation: disconnectSlackInstallationRecords,
36
41
  getSlackInstallationByTeamId,
37
42
  migrationsPath: slackMigrationsPath,
43
+ slackSecretsNamespace,
38
44
  upsertSlackInstallation,
39
45
  } = await import('@shipfox/api-integration-slack');
40
46
 
@@ -132,20 +138,39 @@ async function loadSlackModuleParts(
132
138
  secrets,
133
139
  });
134
140
 
135
- return {
136
- provider: createSlackIntegrationProvider({
137
- agentTools: {tokenStore},
138
- routes: {
139
- tokenStore,
140
- getExistingSlackConnection,
141
- connectSlackInstallation,
142
- disconnectSlackInstallation,
143
- coreDb: db,
144
- publishIntegrationEventReceived,
145
- recordDeliveryOnly,
146
- getIntegrationConnectionById,
141
+ const integrationProvider = createSlackIntegrationProvider({
142
+ agentTools: {tokenStore},
143
+ cleanup: {
144
+ deleteConnectionRecords: async (connection, {tx}) => {
145
+ await deleteSlackInstallationByConnectionId(connection.id, {tx});
147
146
  },
148
- }),
147
+ deleteConnectionSecrets: async (connection) => {
148
+ // Scoped secrets accept the provider-local suffix, after this helper validates its prefix.
149
+ await (options.secrets?.slack?.deleteSecrets({
150
+ workspaceId: connection.workspaceId,
151
+ namespace: slackNamespaceSuffix(slackSecretsNamespace(connection.id)),
152
+ }) ?? Promise.resolve());
153
+ },
154
+ },
155
+ routes: {
156
+ tokenStore,
157
+ getExistingSlackConnection,
158
+ connectSlackInstallation,
159
+ disconnectSlackInstallation,
160
+ coreDb: db,
161
+ claimWebhookDelivery,
162
+ publishIntegrationEventReceived,
163
+ recordDeliveryOnly,
164
+ getIntegrationConnectionById,
165
+ ...(options.requireActiveWorkspaceMembership
166
+ ? {requireActiveWorkspaceMembership: options.requireActiveWorkspaceMembership}
167
+ : {}),
168
+ },
169
+ });
170
+
171
+ return {
172
+ provider: integrationProvider,
173
+ webhookProcessors: integrationProvider.webhookProcessors,
149
174
  e2eRoutes: [
150
175
  createSlackE2eRoutes({
151
176
  tokenStore,
@@ -1,3 +1,5 @@
1
+ import type {UserContextMembership} from '@shipfox/api-auth-context';
2
+ import type {WebhookRequestProcessor, WebhookRouteId} from '@shipfox/api-integration-core-dto';
1
3
  import type {RouteExport} from '@shipfox/node-fastify';
2
4
  import type {ModuleDatabase, ModuleWorker} from '@shipfox/node-module';
3
5
  import type {IntegrationProvider} from '#core/entities/provider.js';
@@ -17,6 +19,12 @@ export interface IntegrationModuleParts {
17
19
  e2eRoutes?: RouteExport[] | undefined;
18
20
  workers?: ModuleWorker[] | undefined;
19
21
  startupTasks?: Array<() => Promise<void>> | undefined;
22
+ webhookProcessors?: WebhookProcessorRegistration[] | undefined;
23
+ }
24
+
25
+ export interface WebhookProcessorRegistration {
26
+ routeIds: readonly WebhookRouteId[];
27
+ processor: WebhookRequestProcessor;
20
28
  }
21
29
 
22
30
  /**
@@ -32,6 +40,13 @@ export interface IntegrationProviderModule {
32
40
 
33
41
  export interface IntegrationProviderModuleLoadOptions {
34
42
  secrets?: IntegrationProviderSecrets | undefined;
43
+ requireActiveWorkspaceMembership?:
44
+ | ((input: {
45
+ workspaceId: string;
46
+ userId: string;
47
+ memberships: ReadonlyArray<UserContextMembership>;
48
+ }) => Promise<unknown>)
49
+ | undefined;
35
50
  }
36
51
 
37
52
  export interface IntegrationProviderSecrets {