@shipfox/api-integration-core 12.1.1 → 12.3.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 (75) hide show
  1. package/.turbo/turbo-build.log +11 -8
  2. package/.turbo/turbo-type.log +0 -1
  3. package/CHANGELOG.md +44 -0
  4. package/dist/core/secret-cleanup.d.ts +20 -0
  5. package/dist/core/secret-cleanup.d.ts.map +1 -0
  6. package/dist/core/secret-cleanup.js +178 -0
  7. package/dist/core/secret-cleanup.js.map +1 -0
  8. package/dist/db/connections.d.ts.map +1 -1
  9. package/dist/db/connections.js +66 -13
  10. package/dist/db/connections.js.map +1 -1
  11. package/dist/db/db.d.ts +562 -0
  12. package/dist/db/db.d.ts.map +1 -1
  13. package/dist/db/db.js +2 -0
  14. package/dist/db/db.js.map +1 -1
  15. package/dist/db/schema/secret-cleanups.d.ts +284 -0
  16. package/dist/db/schema/secret-cleanups.d.ts.map +1 -0
  17. package/dist/db/schema/secret-cleanups.js +39 -0
  18. package/dist/db/schema/secret-cleanups.js.map +1 -0
  19. package/dist/db/secret-cleanups.d.ts +52 -0
  20. package/dist/db/secret-cleanups.d.ts.map +1 -0
  21. package/dist/db/secret-cleanups.js +105 -0
  22. package/dist/db/secret-cleanups.js.map +1 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +8 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/presentation/routes/manage-connections.d.ts.map +1 -1
  27. package/dist/presentation/routes/manage-connections.js +57 -18
  28. package/dist/presentation/routes/manage-connections.js.map +1 -1
  29. package/dist/providers/jira.d.ts.map +1 -1
  30. package/dist/providers/jira.js +67 -14
  31. package/dist/providers/jira.js.map +1 -1
  32. package/dist/temporal/activities/index.d.ts +5 -1
  33. package/dist/temporal/activities/index.d.ts.map +1 -1
  34. package/dist/temporal/activities/index.js +8 -2
  35. package/dist/temporal/activities/index.js.map +1 -1
  36. package/dist/temporal/constants.d.ts +1 -0
  37. package/dist/temporal/constants.d.ts.map +1 -1
  38. package/dist/temporal/constants.js +1 -0
  39. package/dist/temporal/constants.js.map +1 -1
  40. package/dist/temporal/workflows/cleanup-integration-secrets-cron.d.ts +2 -0
  41. package/dist/temporal/workflows/cleanup-integration-secrets-cron.d.ts.map +1 -0
  42. package/dist/temporal/workflows/cleanup-integration-secrets-cron.js +21 -0
  43. package/dist/temporal/workflows/cleanup-integration-secrets-cron.js.map +1 -0
  44. package/dist/temporal/workflows/index.bundle.js +64 -3
  45. package/dist/temporal/workflows/index.d.ts +1 -0
  46. package/dist/temporal/workflows/index.d.ts.map +1 -1
  47. package/dist/temporal/workflows/index.js +1 -0
  48. package/dist/temporal/workflows/index.js.map +1 -1
  49. package/dist/tsconfig.test.tsbuildinfo +1 -1
  50. package/drizzle/0001_durable_secret_cleanup.sql +22 -0
  51. package/drizzle/meta/0001_snapshot.json +180 -2
  52. package/drizzle/meta/_journal.json +7 -0
  53. package/package.json +18 -17
  54. package/src/core/secret-cleanup.test.ts +129 -0
  55. package/src/core/secret-cleanup.ts +229 -0
  56. package/src/db/connections.test.ts +90 -0
  57. package/src/db/connections.ts +92 -13
  58. package/src/db/db.ts +2 -0
  59. package/src/db/schema/secret-cleanups.ts +42 -0
  60. package/src/db/secret-cleanups.test.ts +113 -0
  61. package/src/db/secret-cleanups.ts +209 -0
  62. package/src/index.ts +6 -1
  63. package/src/presentation/routes/manage-connections.test.ts +232 -0
  64. package/src/presentation/routes/manage-connections.ts +45 -11
  65. package/src/providers/jira.test.ts +133 -0
  66. package/src/providers/jira.ts +78 -13
  67. package/src/temporal/activities/index.ts +11 -1
  68. package/src/temporal/constants.ts +2 -0
  69. package/src/temporal/workflows/cleanup-integration-secrets-cron.ts +20 -0
  70. package/src/temporal/workflows/index.ts +1 -0
  71. package/test/env.ts +3 -0
  72. package/test/globalSetup.ts +1 -0
  73. package/test/route-utils.ts +3 -0
  74. package/test/setup.ts +12 -0
  75. package/tsconfig.build.tsbuildinfo +1 -1
package/src/index.ts CHANGED
@@ -267,13 +267,18 @@ export async function createIntegrationsContext(
267
267
  {
268
268
  taskQueue: INTEGRATIONS_MAINTENANCE_TASK_QUEUE,
269
269
  workflowsPath: maintenanceWorkflowsPath,
270
- activities: createIntegrationsMaintenanceActivities,
270
+ activities: () => createIntegrationsMaintenanceActivities({registry}),
271
271
  workflows: [
272
272
  {
273
273
  name: 'pruneWebhookDeliveriesCron',
274
274
  id: 'integrations-prune-webhook-deliveries',
275
275
  cronSchedule: '0 3 * * *',
276
276
  },
277
+ {
278
+ name: 'cleanupIntegrationSecretsCron',
279
+ id: 'integrations-cleanup-secret-namespaces',
280
+ cronSchedule: '*/5 * * * *',
281
+ },
277
282
  ],
278
283
  },
279
284
  ...parts.flatMap((part) => part.workers ?? []),
@@ -1,4 +1,7 @@
1
+ import {createIntegrationProviderRegistry} from '#core/providers/registry.js';
2
+ import {processIntegrationSecretCleanups} from '#core/secret-cleanup.js';
1
3
  import {getIntegrationConnectionById, upsertIntegrationConnection} from '#db/connections.js';
4
+ import {listIntegrationSecretCleanups} from '#db/secret-cleanups.js';
2
5
  import {createTestApp, sourceProvider, useIntegrationRouteTest} from '#test/route-utils.js';
3
6
 
4
7
  describe('PATCH /integration-connections/:connectionId', () => {
@@ -111,6 +114,7 @@ describe('DELETE /integration-connections/:connectionId', () => {
111
114
  });
112
115
 
113
116
  it('runs provider cleanup hooks while retaining ownership of the core row', async () => {
117
+ const deleteConnectionRemoteResources = vi.fn(() => Promise.resolve(undefined));
114
118
  const deleteConnectionRecords = vi.fn(() => Promise.resolve());
115
119
  const deleteConnectionSecrets = vi.fn(() => Promise.resolve());
116
120
  const app = await createTestApp([
@@ -118,6 +122,7 @@ describe('DELETE /integration-connections/:connectionId', () => {
118
122
  provider: 'slack',
119
123
  displayName: 'Slack',
120
124
  adapters: {},
125
+ deleteConnectionRemoteResources,
121
126
  deleteConnectionRecords,
122
127
  deleteConnectionSecrets,
123
128
  }),
@@ -137,6 +142,7 @@ describe('DELETE /integration-connections/:connectionId', () => {
137
142
  });
138
143
 
139
144
  expect(res.statusCode).toBe(204);
145
+ expect(deleteConnectionRemoteResources).toHaveBeenCalledWith(connection);
140
146
  expect(deleteConnectionRecords).toHaveBeenCalledWith(connection, {
141
147
  tx: expect.anything(),
142
148
  });
@@ -144,6 +150,103 @@ describe('DELETE /integration-connections/:connectionId', () => {
144
150
  await expect(getIntegrationConnectionById(connection.id)).resolves.toBeUndefined();
145
151
  });
146
152
 
153
+ it('runs prepared remote cleanup after the local deletion commits', async () => {
154
+ const events: string[] = [];
155
+ const deleteConnectionRemoteResources = vi.fn(() => {
156
+ events.push('prepare');
157
+ return Promise.resolve(async () => {
158
+ events.push('remote');
159
+ await expect(getIntegrationConnectionById(connection.id)).resolves.toBeUndefined();
160
+ });
161
+ });
162
+ const deleteConnectionRecords = vi.fn(() => {
163
+ events.push('records');
164
+ return Promise.resolve();
165
+ });
166
+ const deleteConnectionSecrets = vi.fn(() => {
167
+ events.push('secrets');
168
+ return Promise.resolve();
169
+ });
170
+ const app = await createTestApp([
171
+ sourceProvider({
172
+ provider: 'slack',
173
+ displayName: 'Slack',
174
+ adapters: {},
175
+ deleteConnectionRemoteResources,
176
+ deleteConnectionRecords,
177
+ deleteConnectionSecrets,
178
+ }),
179
+ ]);
180
+ const connection = await upsertIntegrationConnection({
181
+ workspaceId: context.workspaceId,
182
+ provider: 'slack',
183
+ externalAccountId: 'T123',
184
+ slug: 'slack_acme',
185
+ displayName: 'Slack Acme',
186
+ });
187
+
188
+ const res = await app.inject({
189
+ method: 'DELETE',
190
+ url: `/integration-connections/${connection.id}`,
191
+ headers: {authorization: 'Bearer user'},
192
+ });
193
+
194
+ expect(res.statusCode).toBe(204);
195
+ expect(events).toEqual(['prepare', 'records', 'remote', 'secrets']);
196
+ await expect(getIntegrationConnectionById(connection.id)).resolves.toBeUndefined();
197
+ });
198
+
199
+ it('holds the provider deletion lock across local and remote cleanup', async () => {
200
+ const events: string[] = [];
201
+ const withConnectionDeletionLock = vi.fn(
202
+ async (_connection: unknown, fn: () => Promise<void>) => {
203
+ events.push('lock-enter');
204
+ await fn();
205
+ events.push('lock-exit');
206
+ },
207
+ );
208
+ const app = await createTestApp([
209
+ sourceProvider({
210
+ provider: 'slack',
211
+ displayName: 'Slack',
212
+ adapters: {},
213
+ withConnectionDeletionLock,
214
+ deleteConnectionRemoteResources: vi.fn(() => {
215
+ events.push('prepare');
216
+ return Promise.resolve(() => {
217
+ events.push('remote');
218
+ return Promise.resolve();
219
+ });
220
+ }),
221
+ deleteConnectionRecords: vi.fn(() => {
222
+ events.push('records');
223
+ return Promise.resolve();
224
+ }),
225
+ deleteConnectionSecrets: vi.fn(() => {
226
+ events.push('secrets');
227
+ return Promise.resolve();
228
+ }),
229
+ }),
230
+ ]);
231
+ const connection = await upsertIntegrationConnection({
232
+ workspaceId: context.workspaceId,
233
+ provider: 'slack',
234
+ externalAccountId: 'T123',
235
+ slug: 'slack_acme',
236
+ displayName: 'Slack Acme',
237
+ });
238
+
239
+ const res = await app.inject({
240
+ method: 'DELETE',
241
+ url: `/integration-connections/${connection.id}`,
242
+ headers: {authorization: 'Bearer user'},
243
+ });
244
+
245
+ expect(res.statusCode).toBe(204);
246
+ expect(events).toEqual(['lock-enter', 'prepare', 'records', 'remote', 'secrets', 'lock-exit']);
247
+ expect(withConnectionDeletionLock).toHaveBeenCalledWith(connection, expect.any(Function));
248
+ });
249
+
147
250
  it('keeps the connection when provider record cleanup fails', async () => {
148
251
  const deleteConnectionSecrets = vi.fn(() => Promise.resolve());
149
252
  const app = await createTestApp([
@@ -176,6 +279,68 @@ describe('DELETE /integration-connections/:connectionId', () => {
176
279
  expect(deleteConnectionSecrets).not.toHaveBeenCalled();
177
280
  });
178
281
 
282
+ it('continues connection deletion when remote cleanup preparation fails', async () => {
283
+ const deleteConnectionRemoteResources = vi.fn(() =>
284
+ Promise.reject(new Error('remote cleanup failed')),
285
+ );
286
+ const app = await createTestApp([
287
+ sourceProvider({
288
+ provider: 'slack',
289
+ displayName: 'Slack',
290
+ adapters: {},
291
+ deleteConnectionRemoteResources,
292
+ }),
293
+ ]);
294
+ const connection = await upsertIntegrationConnection({
295
+ workspaceId: context.workspaceId,
296
+ provider: 'slack',
297
+ externalAccountId: 'T123',
298
+ slug: 'slack_acme',
299
+ displayName: 'Slack Acme',
300
+ });
301
+
302
+ const res = await app.inject({
303
+ method: 'DELETE',
304
+ url: `/integration-connections/${connection.id}`,
305
+ headers: {authorization: 'Bearer user'},
306
+ });
307
+
308
+ expect(res.statusCode).toBe(204);
309
+ expect(deleteConnectionRemoteResources).toHaveBeenCalledWith(connection);
310
+ await expect(getIntegrationConnectionById(connection.id)).resolves.toBeUndefined();
311
+ });
312
+
313
+ it('continues connection deletion when post-commit remote cleanup fails', async () => {
314
+ const deleteConnectionRemoteResources = vi.fn(() =>
315
+ Promise.resolve(() => Promise.reject(new Error('remote cleanup failed'))),
316
+ );
317
+ const app = await createTestApp([
318
+ sourceProvider({
319
+ provider: 'slack',
320
+ displayName: 'Slack',
321
+ adapters: {},
322
+ deleteConnectionRemoteResources,
323
+ }),
324
+ ]);
325
+ const connection = await upsertIntegrationConnection({
326
+ workspaceId: context.workspaceId,
327
+ provider: 'slack',
328
+ externalAccountId: 'T123',
329
+ slug: 'slack_acme',
330
+ displayName: 'Slack Acme',
331
+ });
332
+
333
+ const res = await app.inject({
334
+ method: 'DELETE',
335
+ url: `/integration-connections/${connection.id}`,
336
+ headers: {authorization: 'Bearer user'},
337
+ });
338
+
339
+ expect(res.statusCode).toBe(204);
340
+ expect(deleteConnectionRemoteResources).toHaveBeenCalledWith(connection);
341
+ await expect(getIntegrationConnectionById(connection.id)).resolves.toBeUndefined();
342
+ });
343
+
179
344
  it('deletes the connection when provider secret cleanup fails after commit', async () => {
180
345
  const app = await createTestApp([
181
346
  sourceProvider({
@@ -201,6 +366,73 @@ describe('DELETE /integration-connections/:connectionId', () => {
201
366
 
202
367
  expect(res.statusCode).toBe(204);
203
368
  await expect(getIntegrationConnectionById(connection.id)).resolves.toBeUndefined();
369
+ await expect(
370
+ listIntegrationSecretCleanups({connectionId: connection.id}),
371
+ ).resolves.toMatchObject([
372
+ {
373
+ provider: 'slack',
374
+ connectionId: connection.id,
375
+ attemptCount: 1,
376
+ leaseToken: null,
377
+ },
378
+ ]);
379
+ });
380
+
381
+ it('retries provider secret cleanup from a durable post-commit record', async () => {
382
+ const deleteConnectionSecrets = vi
383
+ .fn<() => Promise<void>>()
384
+ .mockRejectedValueOnce(new Error('transient cleanup failure'))
385
+ .mockResolvedValue(undefined);
386
+ const provider = sourceProvider({
387
+ provider: 'slack',
388
+ displayName: 'Slack',
389
+ adapters: {},
390
+ deleteConnectionSecrets,
391
+ });
392
+ const app = await createTestApp([provider]);
393
+ const connection = await upsertIntegrationConnection({
394
+ workspaceId: context.workspaceId,
395
+ provider: 'slack',
396
+ externalAccountId: 'T123',
397
+ slug: 'slack_acme',
398
+ displayName: 'Slack Acme',
399
+ });
400
+
401
+ const res = await app.inject({
402
+ method: 'DELETE',
403
+ url: `/integration-connections/${connection.id}`,
404
+ headers: {authorization: 'Bearer user'},
405
+ });
406
+
407
+ const pending = await listIntegrationSecretCleanups({connectionId: connection.id});
408
+ expect(res.statusCode).toBe(204);
409
+ expect(pending).toHaveLength(1);
410
+ expect(pending[0]).toMatchObject({
411
+ workspaceId: context.workspaceId,
412
+ provider: 'slack',
413
+ connectionId: connection.id,
414
+ attemptCount: 1,
415
+ leaseToken: null,
416
+ });
417
+
418
+ const result = await processIntegrationSecretCleanups({
419
+ registry: createIntegrationProviderRegistry([provider]),
420
+ connectionId: connection.id,
421
+ now: new Date(Date.now() + 5 * 60 * 1_000),
422
+ limit: 1,
423
+ });
424
+
425
+ expect(result).toEqual({
426
+ claimed: 1,
427
+ completed: 1,
428
+ failed: 0,
429
+ unavailable: 0,
430
+ unacknowledged: 0,
431
+ });
432
+ await expect(listIntegrationSecretCleanups({connectionId: connection.id})).resolves.toEqual([]);
433
+ expect(deleteConnectionSecrets).toHaveBeenCalledTimes(2);
434
+ expect(deleteConnectionSecrets).toHaveBeenNthCalledWith(1, connection);
435
+ expect(deleteConnectionSecrets).toHaveBeenNthCalledWith(2, connection);
204
436
  });
205
437
 
206
438
  it('deletes an unregistered provider connection without provider cleanup', async () => {
@@ -6,12 +6,14 @@ import {
6
6
  import {ClientError, defineRoute} from '@shipfox/node-fastify';
7
7
  import {z} from 'zod';
8
8
  import type {IntegrationProviderRegistry} from '#core/providers/registry.js';
9
+ import {processIntegrationSecretCleanups} from '#core/secret-cleanup.js';
9
10
  import {
10
11
  deleteIntegrationConnection,
11
12
  getIntegrationConnectionById,
12
13
  updateIntegrationConnectionLifecycleStatus,
13
14
  } from '#db/connections.js';
14
15
  import {db} from '#db/db.js';
16
+ import {enqueueIntegrationSecretCleanup} from '#db/secret-cleanups.js';
15
17
  import {toIntegrationConnectionDto} from '#presentation/dto/integrations.js';
16
18
 
17
19
  const connectionParamsSchema = z.object({
@@ -75,6 +77,7 @@ export function createDeleteIntegrationConnectionRoute(registry: IntegrationProv
75
77
  .list()
76
78
  .find((candidate) => candidate.provider === connection.provider);
77
79
  const hasCleanupHooks =
80
+ provider?.deleteConnectionRemoteResources !== undefined ||
78
81
  provider?.deleteConnectionRecords !== undefined ||
79
82
  provider?.deleteConnectionSecrets !== undefined;
80
83
  if (!hasCleanupHooks) {
@@ -83,17 +86,48 @@ export function createDeleteIntegrationConnectionRoute(registry: IntegrationProv
83
86
  'Deleting integration connection without provider cleanup',
84
87
  );
85
88
  }
86
- await db().transaction(async (tx) => {
87
- await provider?.deleteConnectionRecords?.(connection, {tx});
88
- await deleteIntegrationConnection({id: connection.id}, {tx});
89
- });
90
- try {
91
- await provider?.deleteConnectionSecrets?.(connection);
92
- } catch (error) {
93
- request.log.error(
94
- {connectionId: connection.id, provider: connection.provider, err: error},
95
- 'Integration connection secret cleanup failed after connection deletion',
96
- );
89
+ const deleteConnection = async (): Promise<void> => {
90
+ let deleteRemoteResources: (() => Promise<void>) | undefined;
91
+ try {
92
+ deleteRemoteResources = await provider?.deleteConnectionRemoteResources?.(connection);
93
+ } catch (error) {
94
+ request.log.error(
95
+ {connectionId: connection.id, provider: connection.provider, err: error},
96
+ 'Integration connection remote cleanup preparation failed',
97
+ );
98
+ }
99
+ await db().transaction(async (tx) => {
100
+ await provider?.deleteConnectionRecords?.(connection, {tx});
101
+ await enqueueIntegrationSecretCleanup({connection}, {tx});
102
+ await deleteIntegrationConnection({id: connection.id}, {tx});
103
+ });
104
+ try {
105
+ await deleteRemoteResources?.();
106
+ } catch (error) {
107
+ request.log.error(
108
+ {connectionId: connection.id, provider: connection.provider, err: error},
109
+ 'Integration connection remote cleanup failed after deletion',
110
+ );
111
+ }
112
+ try {
113
+ await processIntegrationSecretCleanups({
114
+ registry,
115
+ connectionId: connection.id,
116
+ connection,
117
+ limit: 1,
118
+ });
119
+ } catch (error) {
120
+ // The durable cleanup row survives, so a later sweep retries this connection.
121
+ request.log.error(
122
+ {connectionId: connection.id, provider: connection.provider, err: error},
123
+ 'Integration connection secret cleanup failed after connection deletion',
124
+ );
125
+ }
126
+ };
127
+ if (provider?.withConnectionDeletionLock) {
128
+ await provider.withConnectionDeletionLock(connection, deleteConnection);
129
+ } else {
130
+ await deleteConnection();
97
131
  }
98
132
  reply.status(204);
99
133
  },
@@ -1,16 +1,65 @@
1
1
  import {
2
2
  getJiraInstallationByConnectionId,
3
3
  upsertJiraInstallation,
4
+ withJiraRefreshLock,
4
5
  } from '@shipfox/api-integration-jira';
5
6
  import {runMigrations} from '@shipfox/node-drizzle';
6
7
  import {getIntegrationConnectionById, upsertIntegrationConnection} from '#db/connections.js';
8
+ import {db} from '#db/db.js';
9
+ import {createTestApp, useIntegrationRouteTest} from '#test/route-utils.js';
7
10
 
8
11
  describe('jiraProviderModule', () => {
12
+ const context = useIntegrationRouteTest();
13
+
9
14
  afterEach(() => {
10
15
  vi.unstubAllEnvs();
11
16
  vi.resetModules();
12
17
  });
13
18
 
19
+ async function createJiraCleanupFixture() {
20
+ vi.stubEnv('INTEGRATIONS_ENABLE_JIRA_PROVIDER', 'true');
21
+ vi.resetModules();
22
+ const deleteSecrets = vi.fn(() => Promise.resolve(2));
23
+ const scopedSecrets = {
24
+ getSecret: vi.fn(() => Promise.resolve(null)),
25
+ setSecrets: vi.fn(() => Promise.resolve()),
26
+ deleteSecrets,
27
+ };
28
+ const {createPostgresClient} = await import('@shipfox/node-postgres');
29
+ createPostgresClient();
30
+ const {loadEnabledProviderModules} = await import('#providers/modules.js');
31
+ const parts = await loadEnabledProviderModules({
32
+ secrets: {jira: scopedSecrets, deleteSecrets},
33
+ });
34
+ const jiraPart = parts.find((part) => part.provider.provider === 'jira');
35
+ if (!jiraPart?.database) throw new Error('Jira provider database is not configured');
36
+ const cloudId = crypto.randomUUID();
37
+
38
+ await runMigrations(
39
+ jiraPart.database.db(),
40
+ jiraPart.database.migrationsPath,
41
+ `__drizzle_migrations_${jiraPart.database.databaseNamespace}`,
42
+ );
43
+ const connection = await upsertIntegrationConnection({
44
+ workspaceId: context.workspaceId,
45
+ provider: 'jira',
46
+ externalAccountId: cloudId,
47
+ slug: 'jira_acme',
48
+ displayName: 'Jira Acme',
49
+ });
50
+ await upsertJiraInstallation({
51
+ connectionId: connection.id,
52
+ cloudId,
53
+ siteUrl: 'https://acme.atlassian.net',
54
+ siteName: 'Acme',
55
+ authorizingAccountId: 'user-1',
56
+ scopes: ['read:jira-work'],
57
+ status: 'installed',
58
+ });
59
+
60
+ return {connection, deleteSecrets, jiraPart, cloudId};
61
+ }
62
+
14
63
  it('loads its database descriptor and persists a core connection with its Jira installation', async () => {
15
64
  vi.stubEnv('INTEGRATIONS_ENABLE_JIRA_PROVIDER', 'true');
16
65
  vi.resetModules();
@@ -59,4 +108,88 @@ describe('jiraProviderModule', () => {
59
108
  cloudId,
60
109
  });
61
110
  });
111
+
112
+ it('waits for refresh before deleting tokens and allowing a reinstall', async () => {
113
+ const {cloudId, connection, deleteSecrets, jiraPart} = await createJiraCleanupFixture();
114
+ const app = await createTestApp([jiraPart.provider]);
115
+
116
+ let releaseRefreshLock!: () => void;
117
+ let refreshLockEntered!: () => void;
118
+ const refreshLockReady = new Promise<void>((resolve) => {
119
+ refreshLockEntered = resolve;
120
+ });
121
+ const refreshLockReleased = new Promise<void>((resolve) => {
122
+ releaseRefreshLock = resolve;
123
+ });
124
+ const refreshLock = withJiraRefreshLock(connection.id, async () => {
125
+ refreshLockEntered();
126
+ await refreshLockReleased;
127
+ });
128
+ await refreshLockReady;
129
+
130
+ const deletion = app.inject({
131
+ method: 'DELETE',
132
+ url: `/integration-connections/${connection.id}`,
133
+ headers: {authorization: 'Bearer user'},
134
+ });
135
+
136
+ await vi.waitFor(async () => {
137
+ await expect(getIntegrationConnectionById(connection.id)).resolves.toBeUndefined();
138
+ });
139
+ let res!: Awaited<ReturnType<typeof app.inject>>;
140
+ try {
141
+ expect(deleteSecrets).not.toHaveBeenCalled();
142
+ } finally {
143
+ releaseRefreshLock();
144
+ await expect(refreshLock).resolves.toMatchObject({acquired: true});
145
+ res = await deletion;
146
+ }
147
+
148
+ expect(res.statusCode).toBe(204);
149
+ await expect(getIntegrationConnectionById(connection.id)).resolves.toBeUndefined();
150
+ await expect(getJiraInstallationByConnectionId(connection.id)).resolves.toBeUndefined();
151
+ expect(deleteSecrets).toHaveBeenCalledWith({
152
+ workspaceId: context.workspaceId,
153
+ namespace: connection.id,
154
+ });
155
+
156
+ const replacement = await upsertIntegrationConnection({
157
+ workspaceId: context.workspaceId,
158
+ provider: 'jira',
159
+ externalAccountId: cloudId,
160
+ slug: 'jira_acme_again',
161
+ displayName: 'Jira Acme',
162
+ });
163
+ await upsertJiraInstallation({
164
+ connectionId: replacement.id,
165
+ cloudId,
166
+ siteUrl: 'https://acme.atlassian.net',
167
+ siteName: 'Acme',
168
+ authorizingAccountId: 'user-1',
169
+ scopes: ['read:jira-work'],
170
+ status: 'installed',
171
+ });
172
+
173
+ await expect(getJiraInstallationByConnectionId(replacement.id)).resolves.toMatchObject({
174
+ cloudId,
175
+ });
176
+ });
177
+
178
+ it('rolls back provider record cleanup when its transaction fails', async () => {
179
+ const {connection, jiraPart} = await createJiraCleanupFixture();
180
+
181
+ await expect(
182
+ db().transaction(async (tx) => {
183
+ await jiraPart.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(getJiraInstallationByConnectionId(connection.id)).resolves.toMatchObject({
192
+ connectionId: connection.id,
193
+ });
194
+ });
62
195
  });
@@ -25,16 +25,26 @@ async function loadJiraModuleParts(
25
25
  options: Parameters<IntegrationProviderModule['load']>[0] = {},
26
26
  ): Promise<IntegrationModuleParts> {
27
27
  const {
28
+ createJiraApiClient,
28
29
  createJiraIntegrationProvider,
29
30
  createJiraMaintenanceWorker,
30
31
  createJiraPendingSelectionStore,
31
32
  createJiraTokenStore,
32
33
  db: jiraDb,
34
+ deregisterJiraWebhooks,
35
+ deleteJiraInstallationByConnectionId,
33
36
  disconnectJiraInstallation: disconnectJiraInstallationRecords,
34
37
  getJiraInstallationByCloudId,
38
+ getJiraInstallationByConnectionId,
39
+ jiraSecretsNamespace,
40
+ jiraWebhookUrl,
35
41
  migrationsPath,
42
+ prepareJiraWebhookDeregistration,
36
43
  upsertJiraInstallation,
44
+ withJiraRefreshLockAndWait,
45
+ withJiraWebhookRegistrationLock,
37
46
  } = await import('@shipfox/api-integration-jira');
47
+ const jira = createJiraApiClient();
38
48
 
39
49
  async function getExistingJiraConnection(input: {
40
50
  cloudId: string;
@@ -91,19 +101,40 @@ async function loadJiraModuleParts(
91
101
  );
92
102
  }
93
103
 
94
- async function disconnectJiraInstallation(input: {connectionId: string}): Promise<void> {
95
- await disconnectJiraInstallationRecords<IntegrationTx>({
96
- connectionId: input.connectionId,
97
- getConnection: getIntegrationConnectionById,
98
- deleteSecrets: (params) =>
99
- options.secrets?.jira?.deleteSecrets({
100
- ...params,
101
- namespace: jiraNamespaceSuffix(params.namespace),
102
- }) ?? Promise.resolve(0),
103
- transaction: (fn) => db().transaction((tx) => fn(tx)),
104
- deleteConnection: (params, transactionOptions) =>
105
- deleteIntegrationConnection({id: params.connectionId}, transactionOptions),
106
- });
104
+ async function disconnectJiraInstallation(input: {
105
+ connectionId: string;
106
+ lockAlreadyHeld?: boolean | undefined;
107
+ }): Promise<void> {
108
+ const disconnect = () =>
109
+ disconnectJiraInstallationRecords<IntegrationTx>({
110
+ connectionId: input.connectionId,
111
+ getConnection: getIntegrationConnectionById,
112
+ deleteSecrets: (params) =>
113
+ options.secrets?.jira?.deleteSecrets({
114
+ ...params,
115
+ namespace: jiraNamespaceSuffix(params.namespace),
116
+ }) ?? Promise.resolve(0),
117
+ deregisterWebhooks: () =>
118
+ deregisterJiraWebhooks({
119
+ connectionId: input.connectionId,
120
+ getInstallation: getJiraInstallationByConnectionId,
121
+ tokenStore,
122
+ jira,
123
+ }),
124
+ transaction: (fn) => db().transaction((tx) => fn(tx)),
125
+ deleteConnection: (params, transactionOptions) =>
126
+ deleteIntegrationConnection({id: params.connectionId}, transactionOptions),
127
+ });
128
+ if (input.lockAlreadyHeld) {
129
+ await disconnect();
130
+ return;
131
+ }
132
+ const installation = await getJiraInstallationByConnectionId(input.connectionId);
133
+ if (!installation) {
134
+ await disconnect();
135
+ return;
136
+ }
137
+ await withJiraWebhookRegistrationLock(installation.cloudId, disconnect);
107
138
  }
108
139
 
109
140
  const fallbackSecrets: JiraSecretsStore & JiraPendingSelectionSecretsStore = {
@@ -131,6 +162,7 @@ async function loadJiraModuleParts(
131
162
  }
132
163
  : fallbackSecrets;
133
164
  const tokenStore = createJiraTokenStore({
165
+ client: jira,
134
166
  resolveConnection: getIntegrationConnectionById,
135
167
  secrets,
136
168
  markConnectionError: async ({connectionId}) => {
@@ -143,7 +175,38 @@ async function loadJiraModuleParts(
143
175
  const pendingStore = createJiraPendingSelectionStore({secrets});
144
176
 
145
177
  const integrationProvider = createJiraIntegrationProvider({
178
+ jira,
146
179
  agentTools: {tokenStore},
180
+ cleanup: {
181
+ deleteConnectionRemoteResources: async (connection) => {
182
+ return await prepareJiraWebhookDeregistration({
183
+ connectionId: connection.id,
184
+ getInstallation: getJiraInstallationByConnectionId,
185
+ tokenStore,
186
+ jira,
187
+ });
188
+ },
189
+ withConnectionDeletionLock: async (connection, fn) => {
190
+ const installation = await getJiraInstallationByConnectionId(connection.id);
191
+ if (!installation) {
192
+ await fn();
193
+ return;
194
+ }
195
+ await withJiraWebhookRegistrationLock(installation.cloudId, fn);
196
+ },
197
+ deleteConnectionRecords: async (connection, {tx}) => {
198
+ await deleteJiraInstallationByConnectionId(connection.id, {tx});
199
+ },
200
+ deleteConnectionSecrets: async (connection) => {
201
+ await withJiraRefreshLockAndWait(connection.id, async () => {
202
+ // Scoped secrets accept the provider-local suffix, after this helper validates its prefix.
203
+ await (options.secrets?.jira?.deleteSecrets({
204
+ workspaceId: connection.workspaceId,
205
+ namespace: jiraNamespaceSuffix(jiraSecretsNamespace(connection.id)),
206
+ }) ?? Promise.resolve());
207
+ });
208
+ },
209
+ },
147
210
  routes: {
148
211
  tokenStore,
149
212
  pendingStore,
@@ -182,8 +245,10 @@ async function loadJiraModuleParts(
182
245
  provider: integrationProvider,
183
246
  workers: [
184
247
  createJiraMaintenanceWorker({
248
+ jira,
185
249
  tokenStore,
186
250
  resolveConnection: getIntegrationConnectionById,
251
+ webhookUrlForConnection: jiraWebhookUrl,
187
252
  }),
188
253
  ],
189
254
  webhookProcessors: integrationProvider.webhookProcessors,
@@ -1,7 +1,17 @@
1
+ import {Context} from '@temporalio/activity';
2
+ import type {IntegrationProviderRegistry} from '#core/providers/registry.js';
3
+ import {processIntegrationSecretCleanups} from '#core/secret-cleanup.js';
1
4
  import {pruneWebhookDeliveriesActivity} from './prune-webhook-deliveries.js';
2
5
 
3
- export function createIntegrationsMaintenanceActivities() {
6
+ export function createIntegrationsMaintenanceActivities(options: {
7
+ registry: IntegrationProviderRegistry;
8
+ }) {
4
9
  return {
5
10
  pruneWebhookDeliveriesActivity,
11
+ cleanupIntegrationSecretsActivity: () =>
12
+ processIntegrationSecretCleanups({
13
+ registry: options.registry,
14
+ heartbeat: () => Context.current().heartbeat(),
15
+ }),
6
16
  };
7
17
  }