@shipfox/api-integration-core 12.2.0 → 12.4.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 (68) hide show
  1. package/.turbo/turbo-build.log +11 -8
  2. package/CHANGELOG.md +25 -0
  3. package/dist/core/secret-cleanup.d.ts +20 -0
  4. package/dist/core/secret-cleanup.d.ts.map +1 -0
  5. package/dist/core/secret-cleanup.js +178 -0
  6. package/dist/core/secret-cleanup.js.map +1 -0
  7. package/dist/db/db.d.ts +562 -0
  8. package/dist/db/db.d.ts.map +1 -1
  9. package/dist/db/db.js +2 -0
  10. package/dist/db/db.js.map +1 -1
  11. package/dist/db/schema/secret-cleanups.d.ts +284 -0
  12. package/dist/db/schema/secret-cleanups.d.ts.map +1 -0
  13. package/dist/db/schema/secret-cleanups.js +39 -0
  14. package/dist/db/schema/secret-cleanups.js.map +1 -0
  15. package/dist/db/secret-cleanups.d.ts +52 -0
  16. package/dist/db/secret-cleanups.d.ts.map +1 -0
  17. package/dist/db/secret-cleanups.js +105 -0
  18. package/dist/db/secret-cleanups.js.map +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +8 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/presentation/routes/manage-connections.d.ts.map +1 -1
  23. package/dist/presentation/routes/manage-connections.js +57 -18
  24. package/dist/presentation/routes/manage-connections.js.map +1 -1
  25. package/dist/providers/jira.d.ts.map +1 -1
  26. package/dist/providers/jira.js +51 -14
  27. package/dist/providers/jira.js.map +1 -1
  28. package/dist/temporal/activities/index.d.ts +5 -1
  29. package/dist/temporal/activities/index.d.ts.map +1 -1
  30. package/dist/temporal/activities/index.js +8 -2
  31. package/dist/temporal/activities/index.js.map +1 -1
  32. package/dist/temporal/constants.d.ts +1 -0
  33. package/dist/temporal/constants.d.ts.map +1 -1
  34. package/dist/temporal/constants.js +1 -0
  35. package/dist/temporal/constants.js.map +1 -1
  36. package/dist/temporal/workflows/cleanup-integration-secrets-cron.d.ts +2 -0
  37. package/dist/temporal/workflows/cleanup-integration-secrets-cron.d.ts.map +1 -0
  38. package/dist/temporal/workflows/cleanup-integration-secrets-cron.js +21 -0
  39. package/dist/temporal/workflows/cleanup-integration-secrets-cron.js.map +1 -0
  40. package/dist/temporal/workflows/index.bundle.js +64 -3
  41. package/dist/temporal/workflows/index.d.ts +1 -0
  42. package/dist/temporal/workflows/index.d.ts.map +1 -1
  43. package/dist/temporal/workflows/index.js +1 -0
  44. package/dist/temporal/workflows/index.js.map +1 -1
  45. package/dist/tsconfig.test.tsbuildinfo +1 -1
  46. package/drizzle/0001_durable_secret_cleanup.sql +22 -0
  47. package/drizzle/meta/0001_snapshot.json +180 -2
  48. package/drizzle/meta/_journal.json +7 -0
  49. package/package.json +11 -10
  50. package/src/core/secret-cleanup.test.ts +129 -0
  51. package/src/core/secret-cleanup.ts +229 -0
  52. package/src/db/db.ts +2 -0
  53. package/src/db/schema/secret-cleanups.ts +42 -0
  54. package/src/db/secret-cleanups.test.ts +113 -0
  55. package/src/db/secret-cleanups.ts +209 -0
  56. package/src/index.ts +6 -1
  57. package/src/presentation/routes/manage-connections.test.ts +232 -0
  58. package/src/presentation/routes/manage-connections.ts +45 -11
  59. package/src/providers/jira.ts +61 -13
  60. package/src/temporal/activities/index.ts +11 -1
  61. package/src/temporal/constants.ts +2 -0
  62. package/src/temporal/workflows/cleanup-integration-secrets-cron.ts +20 -0
  63. package/src/temporal/workflows/index.ts +1 -0
  64. package/test/env.ts +3 -0
  65. package/test/globalSetup.ts +1 -0
  66. package/test/route-utils.ts +3 -0
  67. package/test/setup.ts +12 -0
  68. package/tsconfig.build.tsbuildinfo +1 -1
@@ -0,0 +1,113 @@
1
+ import {afterEach} from '@shipfox/vitest/vi';
2
+ import {sql} from 'drizzle-orm';
3
+ import {CLEANUP_SECRETS_ACTIVITY_TIMEOUT_MS} from '#temporal/constants.js';
4
+ import {upsertIntegrationConnection} from './connections.js';
5
+ import {db} from './db.js';
6
+ import {
7
+ claimIntegrationSecretCleanups,
8
+ completeIntegrationSecretCleanup,
9
+ enqueueIntegrationSecretCleanup,
10
+ retryIntegrationSecretCleanup,
11
+ } from './secret-cleanups.js';
12
+
13
+ const now = new Date(Date.now() + 60 * 1_000);
14
+
15
+ afterEach(async () => {
16
+ await db().execute(sql`TRUNCATE integrations_secret_cleanups CASCADE`);
17
+ });
18
+
19
+ describe('integration secret cleanup persistence', () => {
20
+ it('keeps the default lease longer than the cleanup activity timeout', async () => {
21
+ await createCleanupConnection();
22
+
23
+ const [claimed] = await claimIntegrationSecretCleanups({limit: 1, now});
24
+
25
+ if (!claimed?.leaseExpiresAt) throw new Error('Expected the cleanup to have a lease');
26
+ expect(claimed.leaseExpiresAt.getTime() - now.getTime()).toBeGreaterThan(
27
+ CLEANUP_SECRETS_ACTIVITY_TIMEOUT_MS,
28
+ );
29
+ });
30
+
31
+ it('claims a due row once and reclaims it after its lease expires', async () => {
32
+ const connection = await createCleanupConnection();
33
+ const [queued] = await claimIntegrationSecretCleanups({limit: 1, now, leaseDurationMs: 1_000});
34
+
35
+ expect(queued).toMatchObject({
36
+ connectionId: connection.id,
37
+ attemptCount: 1,
38
+ leaseToken: expect.any(String),
39
+ });
40
+ await expect(claimIntegrationSecretCleanups({limit: 1, now})).resolves.toEqual([]);
41
+
42
+ const wrongLeaseToken = crypto.randomUUID();
43
+ await expect(
44
+ completeIntegrationSecretCleanup({
45
+ id: queued?.id ?? '',
46
+ leaseToken: wrongLeaseToken,
47
+ now,
48
+ }),
49
+ ).resolves.toBe(false);
50
+ await expect(
51
+ retryIntegrationSecretCleanup({
52
+ id: queued?.id ?? '',
53
+ leaseToken: wrongLeaseToken,
54
+ delayMs: 1_000,
55
+ now,
56
+ }),
57
+ ).resolves.toBe(false);
58
+
59
+ const [reclaimed] = await claimIntegrationSecretCleanups({
60
+ limit: 1,
61
+ now: new Date(now.getTime() + 1_001),
62
+ leaseDurationMs: 1_000,
63
+ });
64
+ expect(reclaimed).toMatchObject({
65
+ id: queued?.id,
66
+ attemptCount: 2,
67
+ leaseToken: expect.any(String),
68
+ });
69
+
70
+ await expect(
71
+ completeIntegrationSecretCleanup({
72
+ id: reclaimed?.id ?? '',
73
+ leaseToken: reclaimed?.leaseToken ?? '',
74
+ now: new Date(now.getTime() + 1_001),
75
+ }),
76
+ ).resolves.toBe(true);
77
+ });
78
+
79
+ it('honors the batch limit and retry backoff', async () => {
80
+ await createCleanupConnection({externalAccountId: 'first'});
81
+ await createCleanupConnection({externalAccountId: 'second'});
82
+
83
+ const firstClaim = await claimIntegrationSecretCleanups({limit: 1, now});
84
+ expect(firstClaim).toHaveLength(1);
85
+ const first = firstClaim[0];
86
+ if (!first?.leaseToken) throw new Error('Expected the first cleanup to have a lease');
87
+
88
+ await expect(
89
+ retryIntegrationSecretCleanup({
90
+ id: first.id,
91
+ leaseToken: first.leaseToken,
92
+ delayMs: 5_000,
93
+ now,
94
+ }),
95
+ ).resolves.toBe(true);
96
+
97
+ const secondClaim = await claimIntegrationSecretCleanups({limit: 2, now});
98
+ expect(secondClaim).toHaveLength(1);
99
+ expect(secondClaim[0]?.id).not.toBe(first.id);
100
+ });
101
+ });
102
+
103
+ async function createCleanupConnection(overrides: {externalAccountId?: string} = {}) {
104
+ const connection = await upsertIntegrationConnection({
105
+ workspaceId: crypto.randomUUID(),
106
+ provider: 'slack',
107
+ externalAccountId: overrides.externalAccountId ?? crypto.randomUUID(),
108
+ slug: `slack_${crypto.randomUUID()}`,
109
+ displayName: 'Slack',
110
+ });
111
+ await enqueueIntegrationSecretCleanup({connection});
112
+ return connection;
113
+ }
@@ -0,0 +1,209 @@
1
+ import {and, asc, eq, gt, inArray, isNull, lte, or, sql} from 'drizzle-orm';
2
+ import type {IntegrationConnection} from '#core/entities/connection.js';
3
+ import {db} from './db.js';
4
+ import {
5
+ type IntegrationSecretCleanupDb,
6
+ integrationSecretCleanups,
7
+ } from './schema/secret-cleanups.js';
8
+
9
+ // Keep the lease longer than cleanupIntegrationSecretsCron's 5-minute activity timeout so
10
+ // a timed-out sweep cannot have its claimed rows reclaimed while it is still running.
11
+ const DEFAULT_LEASE_DURATION_MS = 10 * 60 * 1_000;
12
+
13
+ export interface EnqueueIntegrationSecretCleanupParams {
14
+ connection: IntegrationConnection;
15
+ now?: Date | undefined;
16
+ }
17
+
18
+ export interface IntegrationSecretCleanup {
19
+ id: string;
20
+ workspaceId: string;
21
+ provider: string;
22
+ connectionId: string;
23
+ externalAccountId: string;
24
+ slug: string;
25
+ displayName: string;
26
+ lifecycleStatus: IntegrationConnection['lifecycleStatus'];
27
+ connectionCreatedAt: Date;
28
+ connectionUpdatedAt: Date;
29
+ attemptCount: number;
30
+ nextAttemptAt: Date;
31
+ leaseToken: string | null;
32
+ leaseExpiresAt: Date | null;
33
+ createdAt: Date;
34
+ updatedAt: Date;
35
+ }
36
+
37
+ type IntegrationDb = ReturnType<typeof db>;
38
+ type IntegrationTx = Parameters<Parameters<IntegrationDb['transaction']>[0]>[0];
39
+
40
+ export async function enqueueIntegrationSecretCleanup(
41
+ params: EnqueueIntegrationSecretCleanupParams,
42
+ options: {tx?: IntegrationDb | IntegrationTx | undefined} = {},
43
+ ): Promise<void> {
44
+ const executor = options.tx ?? db();
45
+ await executor
46
+ .insert(integrationSecretCleanups)
47
+ .values({
48
+ workspaceId: params.connection.workspaceId,
49
+ provider: params.connection.provider,
50
+ connectionId: params.connection.id,
51
+ externalAccountId: params.connection.externalAccountId,
52
+ slug: params.connection.slug,
53
+ displayName: params.connection.displayName,
54
+ lifecycleStatus: params.connection.lifecycleStatus,
55
+ connectionCreatedAt: params.connection.createdAt,
56
+ connectionUpdatedAt: params.connection.updatedAt,
57
+ // Claims compare against the application clock, so the first due time must come
58
+ // from that clock too. A database clock even milliseconds ahead would otherwise
59
+ // hide the row from the sweep that runs right after this insert commits.
60
+ nextAttemptAt: params.now ?? new Date(),
61
+ })
62
+ .onConflictDoNothing({
63
+ target: [integrationSecretCleanups.provider, integrationSecretCleanups.connectionId],
64
+ });
65
+ }
66
+
67
+ export interface ClaimIntegrationSecretCleanupsParams {
68
+ limit: number;
69
+ connectionId?: string | undefined;
70
+ now?: Date | undefined;
71
+ leaseDurationMs?: number | undefined;
72
+ }
73
+
74
+ export async function claimIntegrationSecretCleanups(
75
+ params: ClaimIntegrationSecretCleanupsParams,
76
+ ): Promise<IntegrationSecretCleanup[]> {
77
+ if (!Number.isInteger(params.limit) || params.limit <= 0) {
78
+ throw new Error('Secret cleanup claim limit must be a positive integer');
79
+ }
80
+ const now = params.now ?? new Date();
81
+ const leaseDurationMs = params.leaseDurationMs ?? DEFAULT_LEASE_DURATION_MS;
82
+ if (!Number.isInteger(leaseDurationMs) || leaseDurationMs <= 0) {
83
+ throw new Error('Secret cleanup lease duration must be a positive integer');
84
+ }
85
+
86
+ return await db().transaction(async (tx) => {
87
+ const conditions = [
88
+ lte(integrationSecretCleanups.nextAttemptAt, now),
89
+ or(
90
+ isNull(integrationSecretCleanups.leaseExpiresAt),
91
+ lte(integrationSecretCleanups.leaseExpiresAt, now),
92
+ ),
93
+ ...(params.connectionId
94
+ ? [eq(integrationSecretCleanups.connectionId, params.connectionId)]
95
+ : []),
96
+ ];
97
+ const candidates = await tx
98
+ .select({id: integrationSecretCleanups.id})
99
+ .from(integrationSecretCleanups)
100
+ .where(and(...conditions))
101
+ .orderBy(
102
+ asc(integrationSecretCleanups.nextAttemptAt),
103
+ asc(integrationSecretCleanups.createdAt),
104
+ asc(integrationSecretCleanups.id),
105
+ )
106
+ .limit(params.limit)
107
+ .for('update', {skipLocked: true});
108
+ if (candidates.length === 0) return [];
109
+
110
+ const rows = await tx
111
+ .update(integrationSecretCleanups)
112
+ .set({
113
+ attemptCount: sql`${integrationSecretCleanups.attemptCount} + 1`,
114
+ leaseToken: sql`gen_random_uuid()`,
115
+ leaseExpiresAt: new Date(now.getTime() + leaseDurationMs),
116
+ updatedAt: now,
117
+ })
118
+ .where(
119
+ inArray(
120
+ integrationSecretCleanups.id,
121
+ candidates.map((candidate) => candidate.id),
122
+ ),
123
+ )
124
+ .returning();
125
+ return rows.map(toIntegrationSecretCleanup);
126
+ });
127
+ }
128
+
129
+ export async function completeIntegrationSecretCleanup(params: {
130
+ id: string;
131
+ leaseToken: string;
132
+ now?: Date | undefined;
133
+ }): Promise<boolean> {
134
+ const now = params.now ?? new Date();
135
+ const result = await db()
136
+ .delete(integrationSecretCleanups)
137
+ .where(
138
+ and(
139
+ eq(integrationSecretCleanups.id, params.id),
140
+ eq(integrationSecretCleanups.leaseToken, params.leaseToken),
141
+ gt(integrationSecretCleanups.leaseExpiresAt, now),
142
+ ),
143
+ );
144
+ return (result.rowCount ?? 0) > 0;
145
+ }
146
+
147
+ export async function retryIntegrationSecretCleanup(params: {
148
+ id: string;
149
+ leaseToken: string;
150
+ delayMs: number;
151
+ now?: Date | undefined;
152
+ }): Promise<boolean> {
153
+ if (!Number.isInteger(params.delayMs) || params.delayMs < 0) {
154
+ throw new Error('Secret cleanup retry delay must be a non-negative integer');
155
+ }
156
+ const now = params.now ?? new Date();
157
+ const result = await db()
158
+ .update(integrationSecretCleanups)
159
+ .set({
160
+ nextAttemptAt: new Date(now.getTime() + params.delayMs),
161
+ leaseToken: null,
162
+ leaseExpiresAt: null,
163
+ updatedAt: now,
164
+ })
165
+ .where(
166
+ and(
167
+ eq(integrationSecretCleanups.id, params.id),
168
+ eq(integrationSecretCleanups.leaseToken, params.leaseToken),
169
+ gt(integrationSecretCleanups.leaseExpiresAt, now),
170
+ ),
171
+ );
172
+ return (result.rowCount ?? 0) > 0;
173
+ }
174
+
175
+ export async function listIntegrationSecretCleanups(
176
+ params: {connectionId?: string | undefined} = {},
177
+ ): Promise<IntegrationSecretCleanup[]> {
178
+ const rows = await db()
179
+ .select()
180
+ .from(integrationSecretCleanups)
181
+ .where(
182
+ params.connectionId
183
+ ? eq(integrationSecretCleanups.connectionId, params.connectionId)
184
+ : undefined,
185
+ )
186
+ .orderBy(asc(integrationSecretCleanups.createdAt), asc(integrationSecretCleanups.id));
187
+ return rows.map(toIntegrationSecretCleanup);
188
+ }
189
+
190
+ function toIntegrationSecretCleanup(row: IntegrationSecretCleanupDb): IntegrationSecretCleanup {
191
+ return {
192
+ id: row.id,
193
+ workspaceId: row.workspaceId,
194
+ provider: row.provider,
195
+ connectionId: row.connectionId,
196
+ externalAccountId: row.externalAccountId,
197
+ slug: row.slug,
198
+ displayName: row.displayName,
199
+ lifecycleStatus: row.lifecycleStatus,
200
+ connectionCreatedAt: row.connectionCreatedAt,
201
+ connectionUpdatedAt: row.connectionUpdatedAt,
202
+ attemptCount: row.attemptCount,
203
+ nextAttemptAt: row.nextAttemptAt,
204
+ leaseToken: row.leaseToken,
205
+ leaseExpiresAt: row.leaseExpiresAt,
206
+ createdAt: row.createdAt,
207
+ updatedAt: row.updatedAt,
208
+ };
209
+ }
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
  },