@shipfox/api-integration-core 4.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 (39) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +35 -0
  3. package/dist/db/webhook-deliveries.d.ts +4 -0
  4. package/dist/db/webhook-deliveries.d.ts.map +1 -1
  5. package/dist/db/webhook-deliveries.js +10 -2
  6. package/dist/db/webhook-deliveries.js.map +1 -1
  7. package/dist/index.d.ts +2 -2
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +1 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/presentation/routes/index.js +1 -1
  12. package/dist/presentation/routes/index.js.map +1 -1
  13. package/dist/presentation/routes/manage-connections.d.ts +1 -1
  14. package/dist/presentation/routes/manage-connections.d.ts.map +1 -1
  15. package/dist/presentation/routes/manage-connections.js +28 -3
  16. package/dist/presentation/routes/manage-connections.js.map +1 -1
  17. package/dist/providers/jira.d.ts.map +1 -1
  18. package/dist/providers/jira.js +110 -3
  19. package/dist/providers/jira.js.map +1 -1
  20. package/dist/providers/linear.d.ts.map +1 -1
  21. package/dist/providers/linear.js +15 -1
  22. package/dist/providers/linear.js.map +1 -1
  23. package/dist/providers/slack.d.ts.map +1 -1
  24. package/dist/providers/slack.js +17 -2
  25. package/dist/providers/slack.js.map +1 -1
  26. package/dist/tsconfig.test.tsbuildinfo +1 -1
  27. package/package.json +28 -38
  28. package/src/db/webhook-deliveries.test.ts +11 -0
  29. package/src/db/webhook-deliveries.ts +12 -3
  30. package/src/index.ts +2 -1
  31. package/src/presentation/routes/index.ts +1 -1
  32. package/src/presentation/routes/manage-connections.test.ts +113 -0
  33. package/src/presentation/routes/manage-connections.ts +26 -2
  34. package/src/providers/jira.ts +152 -7
  35. package/src/providers/linear.test.ts +136 -0
  36. package/src/providers/linear.ts +14 -0
  37. package/src/providers/slack.test.ts +139 -0
  38. package/src/providers/slack.ts +20 -1
  39. package/tsconfig.build.tsbuildinfo +1 -1
@@ -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,
@@ -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
 
@@ -135,12 +141,25 @@ async function loadSlackModuleParts(
135
141
  return {
136
142
  provider: createSlackIntegrationProvider({
137
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
+ },
138
156
  routes: {
139
157
  tokenStore,
140
158
  getExistingSlackConnection,
141
159
  connectSlackInstallation,
142
160
  disconnectSlackInstallation,
143
161
  coreDb: db,
162
+ claimWebhookDelivery,
144
163
  publishIntegrationEventReceived,
145
164
  recordDeliveryOnly,
146
165
  getIntegrationConnectionById,