@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.
- package/.turbo/turbo-build.log +11 -8
- package/.turbo/turbo-type.log +0 -1
- package/CHANGELOG.md +44 -0
- package/dist/core/secret-cleanup.d.ts +20 -0
- package/dist/core/secret-cleanup.d.ts.map +1 -0
- package/dist/core/secret-cleanup.js +178 -0
- package/dist/core/secret-cleanup.js.map +1 -0
- package/dist/db/connections.d.ts.map +1 -1
- package/dist/db/connections.js +66 -13
- package/dist/db/connections.js.map +1 -1
- package/dist/db/db.d.ts +562 -0
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/db.js +2 -0
- package/dist/db/db.js.map +1 -1
- package/dist/db/schema/secret-cleanups.d.ts +284 -0
- package/dist/db/schema/secret-cleanups.d.ts.map +1 -0
- package/dist/db/schema/secret-cleanups.js +39 -0
- package/dist/db/schema/secret-cleanups.js.map +1 -0
- package/dist/db/secret-cleanups.d.ts +52 -0
- package/dist/db/secret-cleanups.d.ts.map +1 -0
- package/dist/db/secret-cleanups.js +105 -0
- package/dist/db/secret-cleanups.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/presentation/routes/manage-connections.d.ts.map +1 -1
- package/dist/presentation/routes/manage-connections.js +57 -18
- package/dist/presentation/routes/manage-connections.js.map +1 -1
- package/dist/providers/jira.d.ts.map +1 -1
- package/dist/providers/jira.js +67 -14
- package/dist/providers/jira.js.map +1 -1
- package/dist/temporal/activities/index.d.ts +5 -1
- package/dist/temporal/activities/index.d.ts.map +1 -1
- package/dist/temporal/activities/index.js +8 -2
- package/dist/temporal/activities/index.js.map +1 -1
- package/dist/temporal/constants.d.ts +1 -0
- package/dist/temporal/constants.d.ts.map +1 -1
- package/dist/temporal/constants.js +1 -0
- package/dist/temporal/constants.js.map +1 -1
- package/dist/temporal/workflows/cleanup-integration-secrets-cron.d.ts +2 -0
- package/dist/temporal/workflows/cleanup-integration-secrets-cron.d.ts.map +1 -0
- package/dist/temporal/workflows/cleanup-integration-secrets-cron.js +21 -0
- package/dist/temporal/workflows/cleanup-integration-secrets-cron.js.map +1 -0
- package/dist/temporal/workflows/index.bundle.js +64 -3
- package/dist/temporal/workflows/index.d.ts +1 -0
- package/dist/temporal/workflows/index.d.ts.map +1 -1
- package/dist/temporal/workflows/index.js +1 -0
- package/dist/temporal/workflows/index.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0001_durable_secret_cleanup.sql +22 -0
- package/drizzle/meta/0001_snapshot.json +180 -2
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +18 -17
- package/src/core/secret-cleanup.test.ts +129 -0
- package/src/core/secret-cleanup.ts +229 -0
- package/src/db/connections.test.ts +90 -0
- package/src/db/connections.ts +92 -13
- package/src/db/db.ts +2 -0
- package/src/db/schema/secret-cleanups.ts +42 -0
- package/src/db/secret-cleanups.test.ts +113 -0
- package/src/db/secret-cleanups.ts +209 -0
- package/src/index.ts +6 -1
- package/src/presentation/routes/manage-connections.test.ts +232 -0
- package/src/presentation/routes/manage-connections.ts +45 -11
- package/src/providers/jira.test.ts +133 -0
- package/src/providers/jira.ts +78 -13
- package/src/temporal/activities/index.ts +11 -1
- package/src/temporal/constants.ts +2 -0
- package/src/temporal/workflows/cleanup-integration-secrets-cron.ts +20 -0
- package/src/temporal/workflows/index.ts +1 -0
- package/test/env.ts +3 -0
- package/test/globalSetup.ts +1 -0
- package/test/route-utils.ts +3 -0
- package/test/setup.ts +12 -0
- 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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
});
|
package/src/providers/jira.ts
CHANGED
|
@@ -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: {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
}
|