@shipfox/api-integration-core 3.0.0 → 5.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 (57) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +62 -0
  3. package/dist/config.d.ts +2 -0
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +8 -0
  6. package/dist/config.js.map +1 -1
  7. package/dist/db/webhook-deliveries.d.ts +4 -0
  8. package/dist/db/webhook-deliveries.d.ts.map +1 -1
  9. package/dist/db/webhook-deliveries.js +10 -2
  10. package/dist/db/webhook-deliveries.js.map +1 -1
  11. package/dist/index.d.ts +2 -2
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +1 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/presentation/routes/index.js +1 -1
  16. package/dist/presentation/routes/index.js.map +1 -1
  17. package/dist/presentation/routes/manage-connections.d.ts +1 -1
  18. package/dist/presentation/routes/manage-connections.d.ts.map +1 -1
  19. package/dist/presentation/routes/manage-connections.js +28 -3
  20. package/dist/presentation/routes/manage-connections.js.map +1 -1
  21. package/dist/providers/jira.d.ts +3 -0
  22. package/dist/providers/jira.d.ts.map +1 -0
  23. package/dist/providers/jira.js +127 -0
  24. package/dist/providers/jira.js.map +1 -0
  25. package/dist/providers/linear.d.ts.map +1 -1
  26. package/dist/providers/linear.js +15 -1
  27. package/dist/providers/linear.js.map +1 -1
  28. package/dist/providers/modules.d.ts.map +1 -1
  29. package/dist/providers/modules.js +4 -0
  30. package/dist/providers/modules.js.map +1 -1
  31. package/dist/providers/slack.d.ts +3 -0
  32. package/dist/providers/slack.d.ts.map +1 -0
  33. package/dist/providers/slack.js +149 -0
  34. package/dist/providers/slack.js.map +1 -0
  35. package/dist/providers/types.d.ts +2 -0
  36. package/dist/providers/types.d.ts.map +1 -1
  37. package/dist/providers/types.js.map +1 -1
  38. package/dist/tsconfig.test.tsbuildinfo +1 -1
  39. package/package.json +28 -36
  40. package/src/config.ts +8 -0
  41. package/src/db/webhook-deliveries.test.ts +11 -0
  42. package/src/db/webhook-deliveries.ts +12 -3
  43. package/src/index.ts +2 -1
  44. package/src/presentation/routes/index.ts +1 -1
  45. package/src/presentation/routes/manage-connections.test.ts +113 -0
  46. package/src/presentation/routes/manage-connections.ts +26 -2
  47. package/src/providers/jira.test.ts +57 -0
  48. package/src/providers/jira.ts +172 -0
  49. package/src/providers/linear.test.ts +136 -0
  50. package/src/providers/linear.ts +14 -0
  51. package/src/providers/modules.test.ts +26 -0
  52. package/src/providers/modules.ts +4 -0
  53. package/src/providers/slack.test.ts +196 -0
  54. package/src/providers/slack.ts +196 -0
  55. package/src/providers/types.ts +2 -0
  56. package/test/env.ts +12 -0
  57. package/tsconfig.build.tsbuildinfo +1 -1
@@ -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');
@@ -137,6 +139,18 @@ async function loadLinearModuleParts(
137
139
  return {
138
140
  provider: createLinearIntegrationProvider({
139
141
  agentTools: {tokenStore, endpoint: linearConfig.LINEAR_MCP_ENDPOINT},
142
+ cleanup: {
143
+ deleteConnectionRecords: async (connection, {tx}) => {
144
+ await deleteLinearInstallationByConnectionId(connection.id, {tx});
145
+ },
146
+ deleteConnectionSecrets: async (connection) => {
147
+ // Scoped secrets accept the provider-local suffix, after this helper validates its prefix.
148
+ await (options.secrets?.linear?.deleteSecrets({
149
+ workspaceId: connection.workspaceId,
150
+ namespace: linearNamespaceSuffix(linearSecretsNamespace(connection.id)),
151
+ }) ?? Promise.resolve());
152
+ },
153
+ },
140
154
  routes: {
141
155
  tokenStore,
142
156
  getExistingLinearConnection,
@@ -37,4 +37,30 @@ describe('loadEnabledProviderModules', () => {
37
37
  adapters: {},
38
38
  });
39
39
  });
40
+
41
+ it('loads Slack when the provider is enabled', async () => {
42
+ vi.stubEnv('INTEGRATIONS_ENABLE_SLACK_PROVIDER', 'true');
43
+ vi.resetModules();
44
+
45
+ const {loadEnabledProviderModules} = await import('#providers/modules.js');
46
+ const parts = await loadEnabledProviderModules();
47
+
48
+ expect(parts.map((part) => part.provider.provider)).toEqual(['slack', 'cron', 'webhook']);
49
+ expect(parts[0]?.provider).toMatchObject({
50
+ provider: 'slack',
51
+ displayName: 'Slack',
52
+ });
53
+ expect(parts[0]?.provider.adapters?.agent_tools).toBeDefined();
54
+ });
55
+
56
+ it('loads Jira when the provider is enabled', async () => {
57
+ vi.stubEnv('INTEGRATIONS_ENABLE_JIRA_PROVIDER', 'true');
58
+ vi.resetModules();
59
+
60
+ const {loadEnabledProviderModules} = await import('#providers/modules.js');
61
+ const parts = await loadEnabledProviderModules();
62
+
63
+ expect(parts.map((part) => part.provider.provider)).toEqual(['jira', 'cron', 'webhook']);
64
+ expect(parts[0]?.provider).toMatchObject({provider: 'jira', displayName: 'Jira'});
65
+ });
40
66
  });
@@ -1,8 +1,10 @@
1
1
  import {cronProviderModule} from '#providers/cron.js';
2
2
  import {giteaProviderModule} from '#providers/gitea.js';
3
3
  import {githubProviderModule} from '#providers/github.js';
4
+ import {jiraProviderModule} from '#providers/jira.js';
4
5
  import {linearProviderModule} from '#providers/linear.js';
5
6
  import {sentryProviderModule} from '#providers/sentry.js';
7
+ import {slackProviderModule} from '#providers/slack.js';
6
8
  import type {
7
9
  IntegrationModuleParts,
8
10
  IntegrationProviderModuleLoadOptions,
@@ -14,6 +16,8 @@ import {webhookProviderModule} from '#providers/webhook.js';
14
16
  const providerModules = [
15
17
  githubProviderModule,
16
18
  linearProviderModule,
19
+ slackProviderModule,
20
+ jiraProviderModule,
17
21
  sentryProviderModule,
18
22
  giteaProviderModule,
19
23
  cronProviderModule,
@@ -0,0 +1,196 @@
1
+ import {
2
+ getSlackInstallationByConnectionId,
3
+ upsertSlackInstallation,
4
+ } from '@shipfox/api-integration-slack';
5
+ import {runMigrations} from '@shipfox/node-drizzle';
6
+ import {getIntegrationConnectionById, upsertIntegrationConnection} from '#db/connections.js';
7
+ import {db} from '#db/db.js';
8
+ import {createTestApp, useIntegrationRouteTest} from '#test/route-utils.js';
9
+
10
+ describe('slackProviderModule', () => {
11
+ const context = useIntegrationRouteTest();
12
+
13
+ afterEach(() => {
14
+ vi.unstubAllEnvs();
15
+ vi.resetModules();
16
+ });
17
+
18
+ it('loads its database descriptor and persists a core connection with its Slack installation', async () => {
19
+ vi.stubEnv('INTEGRATIONS_ENABLE_SLACK_PROVIDER', 'true');
20
+ vi.resetModules();
21
+ const {createPostgresClient} = await import('@shipfox/node-postgres');
22
+ createPostgresClient();
23
+ const {loadEnabledProviderModules} = await import('#providers/modules.js');
24
+ const parts = await loadEnabledProviderModules();
25
+ const slackPart = parts.find((part) => part.provider.provider === 'slack');
26
+ if (!slackPart?.database) throw new Error('Slack provider database is not configured');
27
+ const workspaceId = crypto.randomUUID();
28
+ const teamId = `T${crypto.randomUUID()}`;
29
+
30
+ await runMigrations(
31
+ slackPart.database.db(),
32
+ slackPart.database.migrationsPath,
33
+ slackPart.database.migrationsTableName,
34
+ );
35
+ const connection = await upsertIntegrationConnection({
36
+ workspaceId,
37
+ provider: 'slack',
38
+ externalAccountId: teamId,
39
+ slug: `slack_${teamId}`,
40
+ displayName: 'Slack Acme',
41
+ });
42
+ await upsertSlackInstallation({
43
+ connectionId: connection.id,
44
+ teamId,
45
+ teamName: 'Acme',
46
+ appId: 'A123',
47
+ botUserId: 'U123',
48
+ scopes: ['app_mentions:read'],
49
+ status: 'installed',
50
+ });
51
+
52
+ await expect(getIntegrationConnectionById(connection.id)).resolves.toMatchObject({
53
+ id: connection.id,
54
+ provider: 'slack',
55
+ });
56
+ await expect(getSlackInstallationByConnectionId(connection.id)).resolves.toMatchObject({
57
+ connectionId: connection.id,
58
+ teamId,
59
+ });
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
+ });
196
+ });
@@ -0,0 +1,196 @@
1
+ import {
2
+ type IntegrationConnection as CoreIntegrationConnection,
3
+ slugifyConnectionSlug,
4
+ } from '@shipfox/api-integration-core-dto';
5
+ import type {
6
+ ConnectSlackInstallationInput,
7
+ SlackSecretsStore,
8
+ } from '@shipfox/api-integration-slack';
9
+ import {config} from '#config.js';
10
+ import {
11
+ deleteIntegrationConnection,
12
+ getIntegrationConnectionById,
13
+ resolveUniqueConnectionSlug,
14
+ upsertIntegrationConnection,
15
+ } from '#db/connections.js';
16
+ import {db} from '#db/db.js';
17
+ import {
18
+ claimWebhookDelivery,
19
+ publishIntegrationEventReceived,
20
+ recordDeliveryOnly,
21
+ } from '#db/webhook-deliveries.js';
22
+ import {retryConnectionSlugCollision} from '#providers/connection-slug.js';
23
+ import type {IntegrationModuleParts, IntegrationProviderModule} from '#providers/types.js';
24
+
25
+ const SLACK_MIGRATIONS_TABLE = '__drizzle_migrations_integrations_slack';
26
+ const SLACK_SECRETS_NAMESPACE_PREFIX = 'system/integrations/slack/';
27
+
28
+ type IntegrationDb = ReturnType<typeof db>;
29
+ type IntegrationTx = Parameters<Parameters<IntegrationDb['transaction']>[0]>[0];
30
+
31
+ async function loadSlackModuleParts(
32
+ options: Parameters<IntegrationProviderModule['load']>[0] = {},
33
+ ): Promise<IntegrationModuleParts> {
34
+ const {
35
+ createSlackE2eRoutes,
36
+ createSlackIntegrationProvider,
37
+ createSlackTokenStore,
38
+ db: slackDb,
39
+ deleteSlackInstallationByConnectionId,
40
+ disconnectSlackInstallation: disconnectSlackInstallationRecords,
41
+ getSlackInstallationByTeamId,
42
+ migrationsPath: slackMigrationsPath,
43
+ slackSecretsNamespace,
44
+ upsertSlackInstallation,
45
+ } = await import('@shipfox/api-integration-slack');
46
+
47
+ async function getExistingSlackConnection(input: {
48
+ teamId: string;
49
+ }): Promise<CoreIntegrationConnection<'slack'> | undefined> {
50
+ const installation = await getSlackInstallationByTeamId(input.teamId);
51
+ if (!installation) return undefined;
52
+ const connection = await getIntegrationConnectionById(installation.connectionId);
53
+ if (!connection) return undefined;
54
+ return connection as CoreIntegrationConnection<'slack'>;
55
+ }
56
+
57
+ async function connectSlackInstallation(
58
+ input: ConnectSlackInstallationInput,
59
+ ): Promise<CoreIntegrationConnection<'slack'>> {
60
+ return await retryConnectionSlugCollision(() =>
61
+ db().transaction(async (tx) => {
62
+ const baseSlug = slugifyConnectionSlug(`slack_${input.teamName || input.teamId}`, {
63
+ fallback: 'slack',
64
+ });
65
+ const slug = await resolveUniqueConnectionSlug(
66
+ {
67
+ workspaceId: input.workspaceId,
68
+ provider: 'slack',
69
+ externalAccountId: input.teamId,
70
+ baseSlug,
71
+ },
72
+ {tx},
73
+ );
74
+ const connection = await upsertIntegrationConnection(
75
+ {
76
+ workspaceId: input.workspaceId,
77
+ provider: 'slack',
78
+ externalAccountId: input.teamId,
79
+ slug,
80
+ displayName: input.displayName,
81
+ lifecycleStatus: 'active',
82
+ },
83
+ {tx},
84
+ );
85
+ await upsertSlackInstallation(
86
+ {
87
+ connectionId: connection.id,
88
+ teamId: input.teamId,
89
+ teamName: input.teamName,
90
+ appId: input.appId,
91
+ botUserId: input.botUserId,
92
+ scopes: input.scopes,
93
+ tokenExpiresAt: input.tokenExpiresAt,
94
+ status: 'installed',
95
+ },
96
+ {tx},
97
+ );
98
+ return connection as CoreIntegrationConnection<'slack'>;
99
+ }),
100
+ );
101
+ }
102
+
103
+ async function disconnectSlackInstallation(input: {connectionId: string}): Promise<void> {
104
+ await disconnectSlackInstallationRecords<IntegrationTx>({
105
+ connectionId: input.connectionId,
106
+ getConnection: getIntegrationConnectionById,
107
+ deleteSecrets: (params) =>
108
+ options.secrets?.slack?.deleteSecrets({
109
+ ...params,
110
+ namespace: slackNamespaceSuffix(params.namespace),
111
+ }) ?? Promise.resolve(0),
112
+ transaction: (fn) => db().transaction((tx) => fn(tx)),
113
+ deleteConnection: (params, options) =>
114
+ deleteIntegrationConnection({id: params.connectionId}, options),
115
+ });
116
+ }
117
+
118
+ const fallbackSecrets: SlackSecretsStore = {
119
+ getSecret: () => Promise.resolve(null),
120
+ setSecrets: () => Promise.reject(new Error('Slack token storage is not configured')),
121
+ };
122
+ const secrets: SlackSecretsStore = options.secrets?.slack
123
+ ? {
124
+ getSecret: (params: Parameters<SlackSecretsStore['getSecret']>[0]) =>
125
+ options.secrets?.slack?.getSecret({
126
+ ...params,
127
+ namespace: slackNamespaceSuffix(params.namespace),
128
+ }) ?? Promise.resolve(null),
129
+ setSecrets: (params: Parameters<SlackSecretsStore['setSecrets']>[0]) =>
130
+ options.secrets?.slack?.setSecrets({
131
+ ...params,
132
+ namespace: slackNamespaceSuffix(params.namespace),
133
+ }) ?? Promise.resolve(),
134
+ }
135
+ : fallbackSecrets;
136
+ const tokenStore = createSlackTokenStore({
137
+ resolveConnection: async (connectionId) => getIntegrationConnectionById(connectionId),
138
+ secrets,
139
+ });
140
+
141
+ return {
142
+ provider: createSlackIntegrationProvider({
143
+ agentTools: {tokenStore},
144
+ cleanup: {
145
+ deleteConnectionRecords: async (connection, {tx}) => {
146
+ await deleteSlackInstallationByConnectionId(connection.id, {tx});
147
+ },
148
+ deleteConnectionSecrets: async (connection) => {
149
+ // Scoped secrets accept the provider-local suffix, after this helper validates its prefix.
150
+ await (options.secrets?.slack?.deleteSecrets({
151
+ workspaceId: connection.workspaceId,
152
+ namespace: slackNamespaceSuffix(slackSecretsNamespace(connection.id)),
153
+ }) ?? Promise.resolve());
154
+ },
155
+ },
156
+ routes: {
157
+ tokenStore,
158
+ getExistingSlackConnection,
159
+ connectSlackInstallation,
160
+ disconnectSlackInstallation,
161
+ coreDb: db,
162
+ claimWebhookDelivery,
163
+ publishIntegrationEventReceived,
164
+ recordDeliveryOnly,
165
+ getIntegrationConnectionById,
166
+ },
167
+ }),
168
+ e2eRoutes: [
169
+ createSlackE2eRoutes({
170
+ tokenStore,
171
+ getExistingSlackConnection,
172
+ connectSlackInstallation,
173
+ disconnectSlackInstallation,
174
+ connectionCapabilities: ['agent_tools'],
175
+ }),
176
+ ],
177
+ database: {
178
+ db: slackDb,
179
+ migrationsPath: slackMigrationsPath,
180
+ migrationsTableName: SLACK_MIGRATIONS_TABLE,
181
+ },
182
+ };
183
+ }
184
+
185
+ export const slackProviderModule: IntegrationProviderModule = {
186
+ id: 'slack',
187
+ enabled: config.INTEGRATIONS_ENABLE_SLACK_PROVIDER,
188
+ load: loadSlackModuleParts,
189
+ };
190
+
191
+ function slackNamespaceSuffix(namespace: string): string {
192
+ if (!namespace.startsWith(SLACK_SECRETS_NAMESPACE_PREFIX)) {
193
+ throw new Error('Slack provider attempted to access an unscoped secret namespace');
194
+ }
195
+ return namespace.slice(SLACK_SECRETS_NAMESPACE_PREFIX.length);
196
+ }
@@ -36,7 +36,9 @@ export interface IntegrationProviderModuleLoadOptions {
36
36
 
37
37
  export interface IntegrationProviderSecrets {
38
38
  github?: IntegrationProviderScopedSecrets | undefined;
39
+ jira?: IntegrationProviderScopedSecrets | undefined;
39
40
  linear?: IntegrationProviderScopedSecrets | undefined;
41
+ slack?: IntegrationProviderScopedSecrets | undefined;
40
42
  deleteSecrets(params: {workspaceId: string; namespace: string}): Promise<number>;
41
43
  }
42
44
 
package/test/env.ts CHANGED
@@ -12,11 +12,23 @@ process.env.GITHUB_APP_CLIENT_SECRET = 'test-client-secret';
12
12
  process.env.GITHUB_APP_WEBHOOK_SECRET = 'test-webhook-secret';
13
13
  process.env.GITHUB_APP_SLUG = 'shipfox-test';
14
14
  process.env.GITHUB_INSTALL_STATE_SECRET = 'test-install-state-secret';
15
+ process.env.JIRA_OAUTH_CLIENT_ID = 'test-client-id';
16
+ process.env.JIRA_OAUTH_CLIENT_SECRET = 'test-client-secret';
17
+ process.env.JIRA_OAUTH_REDIRECT_URL = 'https://shipfox.example.com/integrations/jira/callback';
18
+ process.env.JIRA_WEBHOOK_SIGNING_SECRET = 'test-webhook-secret';
19
+ process.env.JIRA_WEBHOOK_BASE_URL = 'https://shipfox.example.com';
20
+ process.env.JIRA_API_BASE_URL = 'https://jira.example.com/api';
21
+ process.env.JIRA_AUTH_BASE_URL = 'https://jira.example.com/auth';
15
22
  process.env.LINEAR_OAUTH_CLIENT_ID = 'test-client-id';
16
23
  process.env.LINEAR_OAUTH_CLIENT_SECRET = 'test-client-secret';
17
24
  process.env.LINEAR_WEBHOOK_SIGNING_SECRET = 'test-webhook-secret';
18
25
  process.env.LINEAR_OAUTH_REDIRECT_URL = 'https://shipfox.example.com/integrations/linear/callback';
19
26
  process.env.LINEAR_MCP_ENDPOINT = 'https://mcp.linear.app/mcp';
27
+ process.env.SLACK_OAUTH_CLIENT_ID = 'test-client-id';
28
+ process.env.SLACK_OAUTH_CLIENT_SECRET = 'test-client-secret';
29
+ process.env.SLACK_SIGNING_SECRET = 'test-signing-secret';
30
+ process.env.SLACK_OAUTH_REDIRECT_URL = 'https://shipfox.example.com/integrations/slack/callback';
31
+ process.env.SLACK_API_BASE_URL = 'https://slack.example.com/api';
20
32
  process.env.GITEA_BASE_URL = 'https://gitea.example.com';
21
33
  process.env.GITEA_SERVICE_USERNAME = 'shipfox-bot';
22
34
  process.env.GITEA_SERVICE_TOKEN = 'test-service-token';