@shipfox/api-integration-core 12.2.0 → 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/CHANGELOG.md +18 -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/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 +51 -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 +11 -10
- package/src/core/secret-cleanup.test.ts +129 -0
- package/src/core/secret-cleanup.ts +229 -0
- 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.ts +61 -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
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
CREATE TABLE "integrations_secret_cleanups" (
|
|
2
|
+
"id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL,
|
|
3
|
+
"workspace_id" uuid NOT NULL,
|
|
4
|
+
"provider" text NOT NULL,
|
|
5
|
+
"connection_id" uuid NOT NULL,
|
|
6
|
+
"external_account_id" text NOT NULL,
|
|
7
|
+
"slug" text NOT NULL,
|
|
8
|
+
"display_name" text NOT NULL,
|
|
9
|
+
"lifecycle_status" text NOT NULL,
|
|
10
|
+
"connection_created_at" timestamp with time zone NOT NULL,
|
|
11
|
+
"connection_updated_at" timestamp with time zone NOT NULL,
|
|
12
|
+
"attempt_count" integer DEFAULT 0 NOT NULL,
|
|
13
|
+
"next_attempt_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
14
|
+
"lease_token" uuid,
|
|
15
|
+
"lease_expires_at" timestamp with time zone,
|
|
16
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
17
|
+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
18
|
+
);
|
|
19
|
+
--> statement-breakpoint
|
|
20
|
+
CREATE UNIQUE INDEX "integrations_secret_cleanups_provider_connection_unique" ON "integrations_secret_cleanups" USING btree ("provider","connection_id");--> statement-breakpoint
|
|
21
|
+
CREATE INDEX "integrations_secret_cleanups_connection_id_idx" ON "integrations_secret_cleanups" USING btree ("connection_id");--> statement-breakpoint
|
|
22
|
+
CREATE INDEX "integrations_secret_cleanups_pending_idx" ON "integrations_secret_cleanups" USING btree ("next_attempt_at","created_at","id");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"id": "
|
|
3
|
-
"prevId": "
|
|
2
|
+
"id": "baeb90ab-7ec4-4a2f-8875-37d820097a48",
|
|
3
|
+
"prevId": "a808bfbe-960e-418d-a72a-d88ddca95096",
|
|
4
4
|
"version": "7",
|
|
5
5
|
"dialect": "postgresql",
|
|
6
6
|
"tables": {
|
|
@@ -267,6 +267,184 @@
|
|
|
267
267
|
"checkConstraints": {},
|
|
268
268
|
"isRLSEnabled": false
|
|
269
269
|
},
|
|
270
|
+
"public.integrations_secret_cleanups": {
|
|
271
|
+
"name": "integrations_secret_cleanups",
|
|
272
|
+
"schema": "",
|
|
273
|
+
"columns": {
|
|
274
|
+
"id": {
|
|
275
|
+
"name": "id",
|
|
276
|
+
"type": "uuid",
|
|
277
|
+
"primaryKey": true,
|
|
278
|
+
"notNull": true,
|
|
279
|
+
"default": "uuidv7()"
|
|
280
|
+
},
|
|
281
|
+
"workspace_id": {
|
|
282
|
+
"name": "workspace_id",
|
|
283
|
+
"type": "uuid",
|
|
284
|
+
"primaryKey": false,
|
|
285
|
+
"notNull": true
|
|
286
|
+
},
|
|
287
|
+
"provider": {
|
|
288
|
+
"name": "provider",
|
|
289
|
+
"type": "text",
|
|
290
|
+
"primaryKey": false,
|
|
291
|
+
"notNull": true
|
|
292
|
+
},
|
|
293
|
+
"connection_id": {
|
|
294
|
+
"name": "connection_id",
|
|
295
|
+
"type": "uuid",
|
|
296
|
+
"primaryKey": false,
|
|
297
|
+
"notNull": true
|
|
298
|
+
},
|
|
299
|
+
"external_account_id": {
|
|
300
|
+
"name": "external_account_id",
|
|
301
|
+
"type": "text",
|
|
302
|
+
"primaryKey": false,
|
|
303
|
+
"notNull": true
|
|
304
|
+
},
|
|
305
|
+
"slug": {
|
|
306
|
+
"name": "slug",
|
|
307
|
+
"type": "text",
|
|
308
|
+
"primaryKey": false,
|
|
309
|
+
"notNull": true
|
|
310
|
+
},
|
|
311
|
+
"display_name": {
|
|
312
|
+
"name": "display_name",
|
|
313
|
+
"type": "text",
|
|
314
|
+
"primaryKey": false,
|
|
315
|
+
"notNull": true
|
|
316
|
+
},
|
|
317
|
+
"lifecycle_status": {
|
|
318
|
+
"name": "lifecycle_status",
|
|
319
|
+
"type": "text",
|
|
320
|
+
"primaryKey": false,
|
|
321
|
+
"notNull": true
|
|
322
|
+
},
|
|
323
|
+
"connection_created_at": {
|
|
324
|
+
"name": "connection_created_at",
|
|
325
|
+
"type": "timestamp with time zone",
|
|
326
|
+
"primaryKey": false,
|
|
327
|
+
"notNull": true
|
|
328
|
+
},
|
|
329
|
+
"connection_updated_at": {
|
|
330
|
+
"name": "connection_updated_at",
|
|
331
|
+
"type": "timestamp with time zone",
|
|
332
|
+
"primaryKey": false,
|
|
333
|
+
"notNull": true
|
|
334
|
+
},
|
|
335
|
+
"attempt_count": {
|
|
336
|
+
"name": "attempt_count",
|
|
337
|
+
"type": "integer",
|
|
338
|
+
"primaryKey": false,
|
|
339
|
+
"notNull": true,
|
|
340
|
+
"default": 0
|
|
341
|
+
},
|
|
342
|
+
"next_attempt_at": {
|
|
343
|
+
"name": "next_attempt_at",
|
|
344
|
+
"type": "timestamp with time zone",
|
|
345
|
+
"primaryKey": false,
|
|
346
|
+
"notNull": true,
|
|
347
|
+
"default": "now()"
|
|
348
|
+
},
|
|
349
|
+
"lease_token": {
|
|
350
|
+
"name": "lease_token",
|
|
351
|
+
"type": "uuid",
|
|
352
|
+
"primaryKey": false,
|
|
353
|
+
"notNull": false
|
|
354
|
+
},
|
|
355
|
+
"lease_expires_at": {
|
|
356
|
+
"name": "lease_expires_at",
|
|
357
|
+
"type": "timestamp with time zone",
|
|
358
|
+
"primaryKey": false,
|
|
359
|
+
"notNull": false
|
|
360
|
+
},
|
|
361
|
+
"created_at": {
|
|
362
|
+
"name": "created_at",
|
|
363
|
+
"type": "timestamp with time zone",
|
|
364
|
+
"primaryKey": false,
|
|
365
|
+
"notNull": true,
|
|
366
|
+
"default": "now()"
|
|
367
|
+
},
|
|
368
|
+
"updated_at": {
|
|
369
|
+
"name": "updated_at",
|
|
370
|
+
"type": "timestamp with time zone",
|
|
371
|
+
"primaryKey": false,
|
|
372
|
+
"notNull": true,
|
|
373
|
+
"default": "now()"
|
|
374
|
+
}
|
|
375
|
+
},
|
|
376
|
+
"indexes": {
|
|
377
|
+
"integrations_secret_cleanups_provider_connection_unique": {
|
|
378
|
+
"name": "integrations_secret_cleanups_provider_connection_unique",
|
|
379
|
+
"columns": [
|
|
380
|
+
{
|
|
381
|
+
"expression": "provider",
|
|
382
|
+
"isExpression": false,
|
|
383
|
+
"asc": true,
|
|
384
|
+
"nulls": "last"
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
"expression": "connection_id",
|
|
388
|
+
"isExpression": false,
|
|
389
|
+
"asc": true,
|
|
390
|
+
"nulls": "last"
|
|
391
|
+
}
|
|
392
|
+
],
|
|
393
|
+
"isUnique": true,
|
|
394
|
+
"concurrently": false,
|
|
395
|
+
"method": "btree",
|
|
396
|
+
"with": {}
|
|
397
|
+
},
|
|
398
|
+
"integrations_secret_cleanups_connection_id_idx": {
|
|
399
|
+
"name": "integrations_secret_cleanups_connection_id_idx",
|
|
400
|
+
"columns": [
|
|
401
|
+
{
|
|
402
|
+
"expression": "connection_id",
|
|
403
|
+
"isExpression": false,
|
|
404
|
+
"asc": true,
|
|
405
|
+
"nulls": "last"
|
|
406
|
+
}
|
|
407
|
+
],
|
|
408
|
+
"isUnique": false,
|
|
409
|
+
"concurrently": false,
|
|
410
|
+
"method": "btree",
|
|
411
|
+
"with": {}
|
|
412
|
+
},
|
|
413
|
+
"integrations_secret_cleanups_pending_idx": {
|
|
414
|
+
"name": "integrations_secret_cleanups_pending_idx",
|
|
415
|
+
"columns": [
|
|
416
|
+
{
|
|
417
|
+
"expression": "next_attempt_at",
|
|
418
|
+
"isExpression": false,
|
|
419
|
+
"asc": true,
|
|
420
|
+
"nulls": "last"
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
"expression": "created_at",
|
|
424
|
+
"isExpression": false,
|
|
425
|
+
"asc": true,
|
|
426
|
+
"nulls": "last"
|
|
427
|
+
},
|
|
428
|
+
{
|
|
429
|
+
"expression": "id",
|
|
430
|
+
"isExpression": false,
|
|
431
|
+
"asc": true,
|
|
432
|
+
"nulls": "last"
|
|
433
|
+
}
|
|
434
|
+
],
|
|
435
|
+
"isUnique": false,
|
|
436
|
+
"concurrently": false,
|
|
437
|
+
"method": "btree",
|
|
438
|
+
"with": {}
|
|
439
|
+
}
|
|
440
|
+
},
|
|
441
|
+
"foreignKeys": {},
|
|
442
|
+
"compositePrimaryKeys": {},
|
|
443
|
+
"uniqueConstraints": {},
|
|
444
|
+
"policies": {},
|
|
445
|
+
"checkConstraints": {},
|
|
446
|
+
"isRLSEnabled": false
|
|
447
|
+
},
|
|
270
448
|
"public.integrations_webhook_deliveries": {
|
|
271
449
|
"name": "integrations_webhook_deliveries",
|
|
272
450
|
"schema": "",
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/api-integration-core",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "12.
|
|
4
|
+
"version": "12.3.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -19,23 +19,24 @@
|
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@modelcontextprotocol/sdk": "1.29.0",
|
|
22
|
+
"@temporalio/activity": "1.18.1",
|
|
22
23
|
"@temporalio/workflow": "1.18.1",
|
|
23
24
|
"ajv": "^8.20.0",
|
|
24
25
|
"drizzle-orm": "^0.45.2",
|
|
25
26
|
"zod": "^4.4.3",
|
|
26
27
|
"@shipfox/api-agent-dto": "12.2.0",
|
|
27
28
|
"@shipfox/api-auth-context": "12.2.0",
|
|
28
|
-
"@shipfox/api-workflows-dto": "12.
|
|
29
|
+
"@shipfox/api-workflows-dto": "12.3.0",
|
|
29
30
|
"@shipfox/api-integration-core-dto": "12.2.0",
|
|
30
|
-
"@shipfox/api-integration-spi": "1.0
|
|
31
|
+
"@shipfox/api-integration-spi": "1.1.0",
|
|
31
32
|
"@shipfox/api-workspaces-dto": "12.0.0",
|
|
32
|
-
"@shipfox/api-integration-gitea": "12.
|
|
33
|
-
"@shipfox/api-integration-github": "12.
|
|
34
|
-
"@shipfox/api-integration-jira": "12.
|
|
35
|
-
"@shipfox/api-integration-linear": "12.
|
|
36
|
-
"@shipfox/api-integration-sentry": "12.
|
|
37
|
-
"@shipfox/api-integration-slack": "12.
|
|
38
|
-
"@shipfox/api-integration-webhook": "12.
|
|
33
|
+
"@shipfox/api-integration-gitea": "12.3.0",
|
|
34
|
+
"@shipfox/api-integration-github": "12.3.0",
|
|
35
|
+
"@shipfox/api-integration-jira": "12.3.0",
|
|
36
|
+
"@shipfox/api-integration-linear": "12.3.0",
|
|
37
|
+
"@shipfox/api-integration-sentry": "12.3.0",
|
|
38
|
+
"@shipfox/api-integration-slack": "12.3.0",
|
|
39
|
+
"@shipfox/api-integration-webhook": "12.3.0",
|
|
39
40
|
"@shipfox/config": "1.2.4",
|
|
40
41
|
"@shipfox/inter-module": "0.2.3",
|
|
41
42
|
"@shipfox/node-drizzle": "0.3.5",
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import {afterEach} from '@shipfox/vitest/vi';
|
|
2
|
+
import {sql} from 'drizzle-orm';
|
|
3
|
+
import {createIntegrationProviderRegistry} from '#core/providers/registry.js';
|
|
4
|
+
import {upsertIntegrationConnection} from '#db/connections.js';
|
|
5
|
+
import {db} from '#db/db.js';
|
|
6
|
+
import {
|
|
7
|
+
enqueueIntegrationSecretCleanup,
|
|
8
|
+
listIntegrationSecretCleanups,
|
|
9
|
+
} from '#db/secret-cleanups.js';
|
|
10
|
+
import {processIntegrationSecretCleanups} from './secret-cleanup.js';
|
|
11
|
+
|
|
12
|
+
afterEach(async () => {
|
|
13
|
+
await db().execute(sql`TRUNCATE integrations_secret_cleanups CASCADE`);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('processIntegrationSecretCleanups', () => {
|
|
17
|
+
it('retries rows for providers that are not loaded', async () => {
|
|
18
|
+
const connection = await createCleanupConnection();
|
|
19
|
+
const now = cleanupNow();
|
|
20
|
+
|
|
21
|
+
await expect(
|
|
22
|
+
processIntegrationSecretCleanups({
|
|
23
|
+
registry: createIntegrationProviderRegistry([]),
|
|
24
|
+
now,
|
|
25
|
+
}),
|
|
26
|
+
).resolves.toEqual({claimed: 1, completed: 0, failed: 0, unavailable: 1, unacknowledged: 0});
|
|
27
|
+
|
|
28
|
+
const [pending] = await listIntegrationSecretCleanups({connectionId: connection.id});
|
|
29
|
+
expect(pending).toMatchObject({
|
|
30
|
+
attemptCount: 1,
|
|
31
|
+
leaseToken: null,
|
|
32
|
+
leaseExpiresAt: null,
|
|
33
|
+
});
|
|
34
|
+
if (!pending) throw new Error('Expected the cleanup to remain pending');
|
|
35
|
+
expect(pending.nextAttemptAt.getTime()).toBeGreaterThan(now.getTime());
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('counts a retry whose lease is lost before acknowledgement', async () => {
|
|
39
|
+
const connection = await createCleanupConnection();
|
|
40
|
+
const provider = {
|
|
41
|
+
provider: 'slack',
|
|
42
|
+
displayName: 'Slack',
|
|
43
|
+
deleteConnectionSecrets: vi.fn(async () => {
|
|
44
|
+
await db().execute(sql`
|
|
45
|
+
UPDATE integrations_secret_cleanups
|
|
46
|
+
SET lease_expires_at = now() - interval '1 second'
|
|
47
|
+
WHERE connection_id = ${connection.id}
|
|
48
|
+
`);
|
|
49
|
+
throw new Error('transient failure');
|
|
50
|
+
}),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
await expect(
|
|
54
|
+
processIntegrationSecretCleanups({
|
|
55
|
+
registry: createIntegrationProviderRegistry([provider]),
|
|
56
|
+
now: cleanupNow(),
|
|
57
|
+
}),
|
|
58
|
+
).resolves.toEqual({claimed: 1, completed: 0, failed: 1, unavailable: 0, unacknowledged: 1});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('acknowledges a cleanup when a loaded provider has no secret hook', async () => {
|
|
62
|
+
const connection = await createCleanupConnection({provider: 'gitea'});
|
|
63
|
+
const provider = {provider: 'gitea', displayName: 'Gitea'};
|
|
64
|
+
|
|
65
|
+
await expect(
|
|
66
|
+
processIntegrationSecretCleanups({
|
|
67
|
+
registry: createIntegrationProviderRegistry([provider]),
|
|
68
|
+
now: cleanupNow(),
|
|
69
|
+
}),
|
|
70
|
+
).resolves.toEqual({claimed: 1, completed: 1, failed: 0, unavailable: 0, unacknowledged: 0});
|
|
71
|
+
await expect(listIntegrationSecretCleanups({connectionId: connection.id})).resolves.toEqual([]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('continues a batch after one provider cleanup fails', async () => {
|
|
75
|
+
const failedConnection = await createCleanupConnection({externalAccountId: 'failed'});
|
|
76
|
+
const completedConnection = await createCleanupConnection({externalAccountId: 'completed'});
|
|
77
|
+
const deleteConnectionSecrets = vi.fn((connection: {id: string}) => {
|
|
78
|
+
if (connection.id === failedConnection.id) {
|
|
79
|
+
return Promise.reject(new Error('transient failure'));
|
|
80
|
+
}
|
|
81
|
+
return Promise.resolve();
|
|
82
|
+
});
|
|
83
|
+
const provider = {
|
|
84
|
+
provider: 'slack',
|
|
85
|
+
displayName: 'Slack',
|
|
86
|
+
deleteConnectionSecrets,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
await expect(
|
|
90
|
+
processIntegrationSecretCleanups({
|
|
91
|
+
registry: createIntegrationProviderRegistry([provider]),
|
|
92
|
+
limit: 2,
|
|
93
|
+
now: cleanupNow(),
|
|
94
|
+
}),
|
|
95
|
+
).resolves.toEqual({claimed: 2, completed: 1, failed: 1, unavailable: 0, unacknowledged: 0});
|
|
96
|
+
expect(deleteConnectionSecrets).toHaveBeenCalledWith({
|
|
97
|
+
id: completedConnection.id,
|
|
98
|
+
workspaceId: completedConnection.workspaceId,
|
|
99
|
+
provider: completedConnection.provider,
|
|
100
|
+
externalAccountId: completedConnection.externalAccountId,
|
|
101
|
+
slug: completedConnection.slug,
|
|
102
|
+
displayName: completedConnection.displayName,
|
|
103
|
+
lifecycleStatus: completedConnection.lifecycleStatus,
|
|
104
|
+
createdAt: completedConnection.createdAt,
|
|
105
|
+
updatedAt: completedConnection.updatedAt,
|
|
106
|
+
});
|
|
107
|
+
await expect(
|
|
108
|
+
listIntegrationSecretCleanups({connectionId: failedConnection.id}),
|
|
109
|
+
).resolves.toHaveLength(1);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
async function createCleanupConnection(
|
|
114
|
+
overrides: {externalAccountId?: string; provider?: string} = {},
|
|
115
|
+
) {
|
|
116
|
+
const connection = await upsertIntegrationConnection({
|
|
117
|
+
workspaceId: crypto.randomUUID(),
|
|
118
|
+
provider: overrides.provider ?? 'slack',
|
|
119
|
+
externalAccountId: overrides.externalAccountId ?? crypto.randomUUID(),
|
|
120
|
+
slug: `${overrides.provider ?? 'slack'}_${crypto.randomUUID()}`,
|
|
121
|
+
displayName: overrides.provider ?? 'Slack',
|
|
122
|
+
});
|
|
123
|
+
await enqueueIntegrationSecretCleanup({connection});
|
|
124
|
+
return connection;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function cleanupNow(): Date {
|
|
128
|
+
return new Date(Date.now() + 60 * 1_000);
|
|
129
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import {reportError} from '@shipfox/node-error-monitoring';
|
|
2
|
+
import {logger} from '@shipfox/node-opentelemetry';
|
|
3
|
+
import type {IntegrationConnection} from '#core/entities/connection.js';
|
|
4
|
+
import type {IntegrationProviderRegistry} from '#core/providers/registry.js';
|
|
5
|
+
import {
|
|
6
|
+
claimIntegrationSecretCleanups,
|
|
7
|
+
completeIntegrationSecretCleanup,
|
|
8
|
+
type IntegrationSecretCleanup,
|
|
9
|
+
retryIntegrationSecretCleanup,
|
|
10
|
+
} from '#db/secret-cleanups.js';
|
|
11
|
+
|
|
12
|
+
const RETRY_BASE_DELAY_MS = 60 * 1_000;
|
|
13
|
+
const RETRY_MAX_DELAY_MS = 60 * 60 * 1_000;
|
|
14
|
+
const DEFAULT_BATCH_SIZE = 100;
|
|
15
|
+
const HEARTBEAT_INTERVAL_MS = 15_000;
|
|
16
|
+
const STUCK_CLEANUP_ALERT_CACHE_LIMIT = 10_000;
|
|
17
|
+
// Attempts, not hours: the backoff caps at RETRY_MAX_DELAY_MS, so a stuck row is retried
|
|
18
|
+
// hourly and this threshold lands about a week out. Revisit it alongside that cap.
|
|
19
|
+
const STUCK_CLEANUP_ATTEMPT_COUNT = 7 * 24;
|
|
20
|
+
const reportedStuckCleanupIds = new Set<string>();
|
|
21
|
+
|
|
22
|
+
export interface ProcessIntegrationSecretCleanupsOptions {
|
|
23
|
+
registry: IntegrationProviderRegistry;
|
|
24
|
+
connectionId?: string | undefined;
|
|
25
|
+
connection?: IntegrationConnection | undefined;
|
|
26
|
+
limit?: number | undefined;
|
|
27
|
+
now?: Date | undefined;
|
|
28
|
+
heartbeat?: (() => void) | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ProcessIntegrationSecretCleanupsResult {
|
|
32
|
+
claimed: number;
|
|
33
|
+
completed: number;
|
|
34
|
+
failed: number;
|
|
35
|
+
unavailable: number;
|
|
36
|
+
/** Rows whose lease was taken over by another sweep before this one acknowledged them. */
|
|
37
|
+
unacknowledged: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type CleanupRetryReason = 'provider-unavailable' | 'cleanup-failed';
|
|
41
|
+
|
|
42
|
+
export async function processIntegrationSecretCleanups(
|
|
43
|
+
options: ProcessIntegrationSecretCleanupsOptions,
|
|
44
|
+
): Promise<ProcessIntegrationSecretCleanupsResult> {
|
|
45
|
+
const cleanups = await claimIntegrationSecretCleanups({
|
|
46
|
+
connectionId: options.connectionId,
|
|
47
|
+
limit: options.limit ?? DEFAULT_BATCH_SIZE,
|
|
48
|
+
now: options.now ?? new Date(),
|
|
49
|
+
});
|
|
50
|
+
const result: ProcessIntegrationSecretCleanupsResult = {
|
|
51
|
+
claimed: cleanups.length,
|
|
52
|
+
completed: 0,
|
|
53
|
+
failed: 0,
|
|
54
|
+
unavailable: 0,
|
|
55
|
+
unacknowledged: 0,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
for (const cleanup of cleanups) {
|
|
59
|
+
// Keep heartbeating through the provider call itself. Beating only between rows would
|
|
60
|
+
// let one slow deletion trip the activity's heartbeat timeout and kill the whole sweep.
|
|
61
|
+
options.heartbeat?.();
|
|
62
|
+
const heartbeatInterval = setInterval(() => options.heartbeat?.(), HEARTBEAT_INTERVAL_MS);
|
|
63
|
+
// A single row must never strand the rest of the claimed batch under its lease.
|
|
64
|
+
try {
|
|
65
|
+
const provider = options.registry
|
|
66
|
+
.list()
|
|
67
|
+
.find((candidate) => candidate.provider === cleanup.provider);
|
|
68
|
+
if (!provider) {
|
|
69
|
+
result.unavailable += 1;
|
|
70
|
+
if (!(await scheduleRetry(cleanup, 'provider-unavailable'))) {
|
|
71
|
+
result.unacknowledged += 1;
|
|
72
|
+
}
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// A loaded provider without the hook has no secrets to delete, so the intent is
|
|
77
|
+
// already satisfied and the row can retire.
|
|
78
|
+
await provider.deleteConnectionSecrets?.(getCleanupConnection(cleanup, options.connection));
|
|
79
|
+
const acknowledged = await completeIntegrationSecretCleanup({
|
|
80
|
+
id: cleanup.id,
|
|
81
|
+
leaseToken: requireLeaseToken(cleanup),
|
|
82
|
+
});
|
|
83
|
+
if (acknowledged) {
|
|
84
|
+
result.completed += 1;
|
|
85
|
+
} else {
|
|
86
|
+
result.unacknowledged += 1;
|
|
87
|
+
logLeaseLost(cleanup, 'complete');
|
|
88
|
+
}
|
|
89
|
+
} catch (error) {
|
|
90
|
+
result.failed += 1;
|
|
91
|
+
if (!(await scheduleRetry(cleanup, 'cleanup-failed', error))) {
|
|
92
|
+
result.unacknowledged += 1;
|
|
93
|
+
}
|
|
94
|
+
} finally {
|
|
95
|
+
clearInterval(heartbeatInterval);
|
|
96
|
+
options.heartbeat?.();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function getCleanupConnection(
|
|
104
|
+
cleanup: IntegrationSecretCleanup,
|
|
105
|
+
connection: IntegrationConnection | undefined,
|
|
106
|
+
): IntegrationConnection {
|
|
107
|
+
if (connection?.id === cleanup.connectionId) return connection;
|
|
108
|
+
return {
|
|
109
|
+
id: cleanup.connectionId,
|
|
110
|
+
workspaceId: cleanup.workspaceId,
|
|
111
|
+
provider: cleanup.provider,
|
|
112
|
+
externalAccountId: cleanup.externalAccountId,
|
|
113
|
+
slug: cleanup.slug,
|
|
114
|
+
displayName: cleanup.displayName,
|
|
115
|
+
lifecycleStatus: cleanup.lifecycleStatus,
|
|
116
|
+
createdAt: cleanup.connectionCreatedAt,
|
|
117
|
+
updatedAt: cleanup.connectionUpdatedAt,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function scheduleRetry(
|
|
122
|
+
cleanup: IntegrationSecretCleanup,
|
|
123
|
+
reason: CleanupRetryReason,
|
|
124
|
+
error?: unknown,
|
|
125
|
+
): Promise<boolean> {
|
|
126
|
+
const delayMs = retryDelayMs(cleanup.attemptCount);
|
|
127
|
+
logCleanupRetry(cleanup, reason, delayMs, error);
|
|
128
|
+
if (error !== undefined) {
|
|
129
|
+
reportError(error, {
|
|
130
|
+
boundary: 'integration.secret-cleanup',
|
|
131
|
+
operation: 'delete-connection-secrets',
|
|
132
|
+
tags: {provider: cleanup.provider},
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
// Keep escalating past the threshold. A stuck row means secrets may still exist, and
|
|
136
|
+
// one missed alert would hide that forever. The bounded per-cleanup cache suppresses
|
|
137
|
+
// duplicate reports in this worker while allowing a fresh process to re-alert.
|
|
138
|
+
if (cleanup.attemptCount >= STUCK_CLEANUP_ATTEMPT_COUNT) {
|
|
139
|
+
const stuckErrorMessage = 'Integration connection secret cleanup exceeded its retry threshold';
|
|
140
|
+
logger().error(
|
|
141
|
+
{
|
|
142
|
+
provider: cleanup.provider,
|
|
143
|
+
connectionId: cleanup.connectionId,
|
|
144
|
+
attempt: cleanup.attemptCount,
|
|
145
|
+
},
|
|
146
|
+
stuckErrorMessage,
|
|
147
|
+
);
|
|
148
|
+
if (!reportedStuckCleanupIds.has(cleanup.id)) {
|
|
149
|
+
const eventId = reportError(new Error(stuckErrorMessage), {
|
|
150
|
+
boundary: 'integration.secret-cleanup',
|
|
151
|
+
operation: 'stuck-cleanup',
|
|
152
|
+
tags: {provider: cleanup.provider},
|
|
153
|
+
extra: {cleanupId: cleanup.id},
|
|
154
|
+
});
|
|
155
|
+
if (eventId) {
|
|
156
|
+
reportedStuckCleanupIds.add(cleanup.id);
|
|
157
|
+
if (reportedStuckCleanupIds.size > STUCK_CLEANUP_ALERT_CACHE_LIMIT) {
|
|
158
|
+
const oldestCleanupId = reportedStuckCleanupIds.values().next().value;
|
|
159
|
+
if (oldestCleanupId !== undefined) reportedStuckCleanupIds.delete(oldestCleanupId);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
const rescheduled = await retryIntegrationSecretCleanup({
|
|
167
|
+
id: cleanup.id,
|
|
168
|
+
leaseToken: requireLeaseToken(cleanup),
|
|
169
|
+
delayMs,
|
|
170
|
+
});
|
|
171
|
+
if (!rescheduled) {
|
|
172
|
+
logLeaseLost(cleanup, 'retry');
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
return true;
|
|
176
|
+
} catch (retryError) {
|
|
177
|
+
// The lease expires on its own, so a later sweep still picks this row up.
|
|
178
|
+
logger().error(
|
|
179
|
+
{provider: cleanup.provider, connectionId: cleanup.connectionId, err: retryError},
|
|
180
|
+
'Failed to reschedule integration connection secret cleanup',
|
|
181
|
+
);
|
|
182
|
+
reportError(retryError, {
|
|
183
|
+
boundary: 'integration.secret-cleanup',
|
|
184
|
+
operation: 'reschedule-cleanup',
|
|
185
|
+
tags: {provider: cleanup.provider},
|
|
186
|
+
});
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function retryDelayMs(attemptCount: number): number {
|
|
192
|
+
return Math.min(RETRY_BASE_DELAY_MS * 2 ** Math.max(0, attemptCount - 1), RETRY_MAX_DELAY_MS);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function requireLeaseToken(cleanup: IntegrationSecretCleanup): string {
|
|
196
|
+
if (!cleanup.leaseToken) throw new Error('Claimed integration secret cleanup has no lease token');
|
|
197
|
+
return cleanup.leaseToken;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function logCleanupRetry(
|
|
201
|
+
cleanup: IntegrationSecretCleanup,
|
|
202
|
+
reason: CleanupRetryReason,
|
|
203
|
+
delayMs: number,
|
|
204
|
+
error: unknown,
|
|
205
|
+
): void {
|
|
206
|
+
logger().warn(
|
|
207
|
+
{
|
|
208
|
+
provider: cleanup.provider,
|
|
209
|
+
connectionId: cleanup.connectionId,
|
|
210
|
+
attempt: cleanup.attemptCount,
|
|
211
|
+
reason,
|
|
212
|
+
delayMs,
|
|
213
|
+
...(error === undefined ? {} : {err: error}),
|
|
214
|
+
},
|
|
215
|
+
'Integration connection secret cleanup will be retried',
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function logLeaseLost(cleanup: IntegrationSecretCleanup, operation: 'complete' | 'retry'): void {
|
|
220
|
+
logger().warn(
|
|
221
|
+
{
|
|
222
|
+
provider: cleanup.provider,
|
|
223
|
+
connectionId: cleanup.connectionId,
|
|
224
|
+
attempt: cleanup.attemptCount,
|
|
225
|
+
operation,
|
|
226
|
+
},
|
|
227
|
+
'Integration connection secret cleanup lease was lost before acknowledgement',
|
|
228
|
+
);
|
|
229
|
+
}
|
package/src/db/db.ts
CHANGED
|
@@ -2,10 +2,12 @@ import {drizzle, type NodePgDatabase} from '@shipfox/node-drizzle';
|
|
|
2
2
|
import {pgClient} from '@shipfox/node-postgres';
|
|
3
3
|
import {integrationConnections} from './schema/connections.js';
|
|
4
4
|
import {integrationsOutbox} from './schema/outbox.js';
|
|
5
|
+
import {integrationSecretCleanups} from './schema/secret-cleanups.js';
|
|
5
6
|
import {integrationsWebhookDeliveries} from './schema/webhook-deliveries.js';
|
|
6
7
|
|
|
7
8
|
export const schema = {
|
|
8
9
|
integrationConnections,
|
|
10
|
+
integrationSecretCleanups,
|
|
9
11
|
integrationsOutbox,
|
|
10
12
|
integrationsWebhookDeliveries,
|
|
11
13
|
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
|
|
2
|
+
import {index, integer, text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
|
|
3
|
+
import type {IntegrationConnectionLifecycleStatus} from '#core/entities/connection.js';
|
|
4
|
+
import {pgTable} from './common.js';
|
|
5
|
+
|
|
6
|
+
export const integrationSecretCleanups = pgTable(
|
|
7
|
+
'secret_cleanups',
|
|
8
|
+
{
|
|
9
|
+
id: uuidv7PrimaryKey(),
|
|
10
|
+
workspaceId: uuid('workspace_id').notNull(),
|
|
11
|
+
provider: text('provider').notNull(),
|
|
12
|
+
connectionId: uuid('connection_id').notNull(),
|
|
13
|
+
externalAccountId: text('external_account_id').notNull(),
|
|
14
|
+
slug: text('slug').notNull(),
|
|
15
|
+
displayName: text('display_name').notNull(),
|
|
16
|
+
lifecycleStatus: text('lifecycle_status')
|
|
17
|
+
.$type<IntegrationConnectionLifecycleStatus>()
|
|
18
|
+
.notNull(),
|
|
19
|
+
connectionCreatedAt: timestamp('connection_created_at', {withTimezone: true}).notNull(),
|
|
20
|
+
connectionUpdatedAt: timestamp('connection_updated_at', {withTimezone: true}).notNull(),
|
|
21
|
+
attemptCount: integer('attempt_count').notNull().default(0),
|
|
22
|
+
nextAttemptAt: timestamp('next_attempt_at', {withTimezone: true}).notNull().defaultNow(),
|
|
23
|
+
leaseToken: uuid('lease_token'),
|
|
24
|
+
leaseExpiresAt: timestamp('lease_expires_at', {withTimezone: true}),
|
|
25
|
+
createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
|
|
26
|
+
updatedAt: timestamp('updated_at', {withTimezone: true}).notNull().defaultNow(),
|
|
27
|
+
},
|
|
28
|
+
(table) => [
|
|
29
|
+
uniqueIndex('integrations_secret_cleanups_provider_connection_unique').on(
|
|
30
|
+
table.provider,
|
|
31
|
+
table.connectionId,
|
|
32
|
+
),
|
|
33
|
+
index('integrations_secret_cleanups_connection_id_idx').on(table.connectionId),
|
|
34
|
+
index('integrations_secret_cleanups_pending_idx').on(
|
|
35
|
+
table.nextAttemptAt,
|
|
36
|
+
table.createdAt,
|
|
37
|
+
table.id,
|
|
38
|
+
),
|
|
39
|
+
],
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
export type IntegrationSecretCleanupDb = typeof integrationSecretCleanups.$inferSelect;
|