@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
@@ -25,19 +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,
33
35
  deleteJiraInstallationByConnectionId,
34
36
  disconnectJiraInstallation: disconnectJiraInstallationRecords,
35
37
  getJiraInstallationByCloudId,
38
+ getJiraInstallationByConnectionId,
36
39
  jiraSecretsNamespace,
40
+ jiraWebhookUrl,
37
41
  migrationsPath,
42
+ prepareJiraWebhookDeregistration,
38
43
  upsertJiraInstallation,
39
44
  withJiraRefreshLockAndWait,
45
+ withJiraWebhookRegistrationLock,
40
46
  } = await import('@shipfox/api-integration-jira');
47
+ const jira = createJiraApiClient();
41
48
 
42
49
  async function getExistingJiraConnection(input: {
43
50
  cloudId: string;
@@ -94,19 +101,40 @@ async function loadJiraModuleParts(
94
101
  );
95
102
  }
96
103
 
97
- async function disconnectJiraInstallation(input: {connectionId: string}): Promise<void> {
98
- await disconnectJiraInstallationRecords<IntegrationTx>({
99
- connectionId: input.connectionId,
100
- getConnection: getIntegrationConnectionById,
101
- deleteSecrets: (params) =>
102
- options.secrets?.jira?.deleteSecrets({
103
- ...params,
104
- namespace: jiraNamespaceSuffix(params.namespace),
105
- }) ?? Promise.resolve(0),
106
- transaction: (fn) => db().transaction((tx) => fn(tx)),
107
- deleteConnection: (params, transactionOptions) =>
108
- deleteIntegrationConnection({id: params.connectionId}, transactionOptions),
109
- });
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);
110
138
  }
111
139
 
112
140
  const fallbackSecrets: JiraSecretsStore & JiraPendingSelectionSecretsStore = {
@@ -134,6 +162,7 @@ async function loadJiraModuleParts(
134
162
  }
135
163
  : fallbackSecrets;
136
164
  const tokenStore = createJiraTokenStore({
165
+ client: jira,
137
166
  resolveConnection: getIntegrationConnectionById,
138
167
  secrets,
139
168
  markConnectionError: async ({connectionId}) => {
@@ -146,8 +175,25 @@ async function loadJiraModuleParts(
146
175
  const pendingStore = createJiraPendingSelectionStore({secrets});
147
176
 
148
177
  const integrationProvider = createJiraIntegrationProvider({
178
+ jira,
149
179
  agentTools: {tokenStore},
150
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
+ },
151
197
  deleteConnectionRecords: async (connection, {tx}) => {
152
198
  await deleteJiraInstallationByConnectionId(connection.id, {tx});
153
199
  },
@@ -199,8 +245,10 @@ async function loadJiraModuleParts(
199
245
  provider: integrationProvider,
200
246
  workers: [
201
247
  createJiraMaintenanceWorker({
248
+ jira,
202
249
  tokenStore,
203
250
  resolveConnection: getIntegrationConnectionById,
251
+ webhookUrlForConnection: jiraWebhookUrl,
204
252
  }),
205
253
  ],
206
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
  }
@@ -1,3 +1,5 @@
1
1
  export const INTEGRATIONS_MAINTENANCE_TASK_QUEUE = 'integrations-maintenance';
2
2
 
3
3
  export const WEBHOOK_DELIVERY_RETENTION_DAYS = 30;
4
+
5
+ export const CLEANUP_SECRETS_ACTIVITY_TIMEOUT_MS = 5 * 60 * 1_000;
@@ -0,0 +1,20 @@
1
+ import {log, proxyActivities} from '@temporalio/workflow';
2
+ import type {createIntegrationsMaintenanceActivities} from '../activities/index.js';
3
+ import {CLEANUP_SECRETS_ACTIVITY_TIMEOUT_MS} from '../constants.js';
4
+
5
+ const {cleanupIntegrationSecretsActivity} = proxyActivities<
6
+ ReturnType<typeof createIntegrationsMaintenanceActivities>
7
+ >({
8
+ startToCloseTimeout: CLEANUP_SECRETS_ACTIVITY_TIMEOUT_MS,
9
+ heartbeatTimeout: '1 minute',
10
+ // The activity records a per-row retry schedule. A later cron run retries the
11
+ // row without replaying the whole sweep in Temporal.
12
+ retry: {maximumAttempts: 1},
13
+ });
14
+
15
+ export async function cleanupIntegrationSecretsCron(): Promise<void> {
16
+ const result = await cleanupIntegrationSecretsActivity();
17
+ if (result.claimed > 0) {
18
+ log.info('Completed integration connection secret cleanup sweep', {...result});
19
+ }
20
+ }
@@ -1 +1,2 @@
1
+ export {cleanupIntegrationSecretsCron} from './cleanup-integration-secrets-cron.js';
1
2
  export {pruneWebhookDeliveriesCron} from './prune-webhook-deliveries-cron.js';
package/test/env.ts CHANGED
@@ -32,3 +32,6 @@ process.env.GITEA_BASE_URL = 'https://gitea.example.com';
32
32
  process.env.GITEA_SERVICE_USERNAME = 'shipfox-bot';
33
33
  process.env.GITEA_SERVICE_TOKEN = 'test-service-token';
34
34
  process.env.GITEA_WEBHOOK_SECRET = 'test-webhook-secret';
35
+ process.env.SENTRY_APP_CLIENT_ID = 'test-client-id';
36
+ process.env.SENTRY_APP_CLIENT_SECRET = 'test-client-secret';
37
+ process.env.SENTRY_APP_SLUG = 'shipfox-test';
@@ -15,6 +15,7 @@ export async function setup() {
15
15
  await runMigrations(db(), migrationsPath, '__drizzle_migrations_integrations');
16
16
  await runMigrations(githubDb(), githubMigrationsPath, '__drizzle_migrations_integrations_github');
17
17
  await db().execute(sql`TRUNCATE integrations_connections CASCADE`);
18
+ await db().execute(sql`TRUNCATE integrations_secret_cleanups CASCADE`);
18
19
  await db().execute(sql`TRUNCATE integrations_webhook_deliveries CASCADE`);
19
20
 
20
21
  closeDb();
@@ -6,7 +6,9 @@ import {
6
6
  } from '@shipfox/api-auth-context';
7
7
  import {type AuthMethod, ClientError, closeApp, createApp} from '@shipfox/node-fastify';
8
8
  import {afterEach, beforeEach} from '@shipfox/vitest/vi';
9
+ import {sql} from 'drizzle-orm';
9
10
  import type {FastifyInstance, FastifyRequest} from 'fastify';
11
+ import {db} from '#db/db.js';
10
12
  import {createIntegrationsModule, type IntegrationProvider} from '#index.js';
11
13
 
12
14
  let authenticatedMemberships: UserContextMembership[] = [];
@@ -95,6 +97,7 @@ export function useIntegrationRouteTest() {
95
97
 
96
98
  afterEach(async () => {
97
99
  await closeApp();
100
+ await db().execute(sql`TRUNCATE integrations_secret_cleanups CASCADE`);
98
101
  });
99
102
 
100
103
  return {
package/test/setup.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  import './env.js';
2
+ import {closeDb as closeGiteaDb} from '@shipfox/api-integration-gitea';
2
3
  import {closeDb as closeGithubDb} from '@shipfox/api-integration-github';
4
+ import {closeDb as closeJiraDb} from '@shipfox/api-integration-jira';
5
+ import {closeDb as closeLinearDb} from '@shipfox/api-integration-linear';
6
+ import {closeDb as closeSentryDb} from '@shipfox/api-integration-sentry';
7
+ import {closeDb as closeSlackDb} from '@shipfox/api-integration-slack';
3
8
  import {closePostgresClient, createPostgresClient} from '@shipfox/node-postgres';
4
9
  import {afterAll, afterEach, beforeAll, vi} from '@shipfox/vitest/vi';
5
10
  import {closeDb} from '#db/db.js';
@@ -15,5 +20,12 @@ afterEach(() => {
15
20
  afterAll(async () => {
16
21
  closeDb();
17
22
  closeGithubDb();
23
+ // isolate:false shares provider module state across files, so every memoized
24
+ // Drizzle handle must be cleared before the shared pool is ended.
25
+ closeLinearDb();
26
+ closeSlackDb();
27
+ closeJiraDb();
28
+ closeSentryDb();
29
+ closeGiteaDb();
18
30
  await closePostgresClient();
19
31
  });