@shipfox/api-integration-core 4.0.0 → 5.0.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 (39) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +35 -0
  3. package/dist/db/webhook-deliveries.d.ts +4 -0
  4. package/dist/db/webhook-deliveries.d.ts.map +1 -1
  5. package/dist/db/webhook-deliveries.js +10 -2
  6. package/dist/db/webhook-deliveries.js.map +1 -1
  7. package/dist/index.d.ts +2 -2
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +1 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/presentation/routes/index.js +1 -1
  12. package/dist/presentation/routes/index.js.map +1 -1
  13. package/dist/presentation/routes/manage-connections.d.ts +1 -1
  14. package/dist/presentation/routes/manage-connections.d.ts.map +1 -1
  15. package/dist/presentation/routes/manage-connections.js +28 -3
  16. package/dist/presentation/routes/manage-connections.js.map +1 -1
  17. package/dist/providers/jira.d.ts.map +1 -1
  18. package/dist/providers/jira.js +110 -3
  19. package/dist/providers/jira.js.map +1 -1
  20. package/dist/providers/linear.d.ts.map +1 -1
  21. package/dist/providers/linear.js +15 -1
  22. package/dist/providers/linear.js.map +1 -1
  23. package/dist/providers/slack.d.ts.map +1 -1
  24. package/dist/providers/slack.js +17 -2
  25. package/dist/providers/slack.js.map +1 -1
  26. package/dist/tsconfig.test.tsbuildinfo +1 -1
  27. package/package.json +28 -38
  28. package/src/db/webhook-deliveries.test.ts +11 -0
  29. package/src/db/webhook-deliveries.ts +12 -3
  30. package/src/index.ts +2 -1
  31. package/src/presentation/routes/index.ts +1 -1
  32. package/src/presentation/routes/manage-connections.test.ts +113 -0
  33. package/src/presentation/routes/manage-connections.ts +26 -2
  34. package/src/providers/jira.ts +152 -7
  35. package/src/providers/linear.test.ts +136 -0
  36. package/src/providers/linear.ts +14 -0
  37. package/src/providers/slack.test.ts +139 -0
  38. package/src/providers/slack.ts +20 -1
  39. package/tsconfig.build.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/api-integration-core",
3
3
  "license": "MIT",
4
- "version": "4.0.0",
4
+ "version": "5.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -13,22 +13,12 @@
13
13
  "types": "dist/index.d.ts",
14
14
  "imports": {
15
15
  "#test/*": "./test/*",
16
- "#*": {
17
- "workspace-source": "./src/*",
18
- "development": "./src/*",
19
- "default": "./dist/*"
20
- }
16
+ "#*": "./dist/*"
21
17
  },
22
18
  "exports": {
23
19
  ".": {
24
- "development": {
25
- "types": "./src/index.ts",
26
- "default": "./src/index.ts"
27
- },
28
- "default": {
29
- "types": "./dist/index.d.ts",
30
- "default": "./dist/index.js"
31
- }
20
+ "types": "./dist/index.d.ts",
21
+ "default": "./dist/index.js"
32
22
  }
33
23
  },
34
24
  "dependencies": {
@@ -37,26 +27,26 @@
37
27
  "ajv": "^8.20.0",
38
28
  "drizzle-orm": "^0.45.2",
39
29
  "zod": "^4.4.3",
40
- "@shipfox/api-agent-dto": "3.0.0",
41
- "@shipfox/api-auth-context": "3.0.0",
42
- "@shipfox/api-integration-core-dto": "3.0.0",
43
- "@shipfox/api-integration-gitea": "4.0.0",
44
- "@shipfox/api-integration-github": "4.0.0",
45
- "@shipfox/api-integration-jira": "4.0.0",
46
- "@shipfox/api-integration-linear": "4.0.0",
47
- "@shipfox/api-integration-sentry": "4.0.0",
48
- "@shipfox/api-integration-slack": "4.0.0",
49
- "@shipfox/api-integration-webhook": "3.0.0",
50
- "@shipfox/config": "1.2.1",
51
- "@shipfox/node-drizzle": "0.3.0",
52
- "@shipfox/node-fastify": "0.2.2",
53
- "@shipfox/node-module": "0.3.1",
54
- "@shipfox/node-opentelemetry": "0.5.1",
55
- "@shipfox/node-outbox": "0.2.2",
56
- "@shipfox/node-postgres": "0.4.1",
57
- "@shipfox/node-temporal": "0.3.0",
58
- "@shipfox/regex": "0.2.1",
59
- "@shipfox/redact": "0.2.1"
30
+ "@shipfox/api-agent-dto": "5.0.0",
31
+ "@shipfox/api-auth-context": "5.0.0",
32
+ "@shipfox/api-integration-core-dto": "5.0.0",
33
+ "@shipfox/api-integration-gitea": "5.0.0",
34
+ "@shipfox/api-integration-github": "5.0.0",
35
+ "@shipfox/api-integration-jira": "5.0.0",
36
+ "@shipfox/api-integration-linear": "5.0.0",
37
+ "@shipfox/api-integration-sentry": "5.0.0",
38
+ "@shipfox/api-integration-slack": "5.0.0",
39
+ "@shipfox/api-integration-webhook": "5.0.0",
40
+ "@shipfox/config": "1.2.2",
41
+ "@shipfox/node-drizzle": "0.3.1",
42
+ "@shipfox/node-fastify": "0.2.3",
43
+ "@shipfox/node-module": "0.3.2",
44
+ "@shipfox/node-opentelemetry": "0.5.2",
45
+ "@shipfox/node-outbox": "0.2.3",
46
+ "@shipfox/node-postgres": "0.4.2",
47
+ "@shipfox/node-temporal": "0.3.1",
48
+ "@shipfox/regex": "0.2.2",
49
+ "@shipfox/redact": "0.2.2"
60
50
  },
61
51
  "devDependencies": {
62
52
  "@temporalio/activity": "1.18.1",
@@ -69,11 +59,11 @@
69
59
  "fastify": "^5.3.3",
70
60
  "fastify-type-provider-zod": "^6.0.0",
71
61
  "fishery": "^2.4.0",
72
- "@shipfox/biome": "1.8.1",
73
- "@shipfox/swc": "1.2.5",
62
+ "@shipfox/biome": "1.8.2",
63
+ "@shipfox/swc": "1.2.6",
74
64
  "@shipfox/ts-config": "1.3.8",
75
- "@shipfox/typescript": "1.1.6",
76
- "@shipfox/vitest": "1.2.2"
65
+ "@shipfox/typescript": "1.1.7",
66
+ "@shipfox/vitest": "1.2.3"
77
67
  },
78
68
  "scripts": {
79
69
  "build": "shipfox-swc && shipfox-temporal-bundle dist/temporal/workflows/index.js",
@@ -9,6 +9,7 @@ import {db} from './db.js';
9
9
  import {integrationsOutbox} from './schema/outbox.js';
10
10
  import {integrationsWebhookDeliveries} from './schema/webhook-deliveries.js';
11
11
  import {
12
+ claimWebhookDelivery,
12
13
  publishIntegrationEventReceived,
13
14
  publishSourceCommitPushed,
14
15
  publishSourcePush,
@@ -165,6 +166,16 @@ describe('integration webhook delivery persistence', () => {
165
166
 
166
167
  expect(await deliveriesFor('github', deliveryId)).toHaveLength(1);
167
168
  });
169
+
170
+ it('reports whether it claimed a delivery', async () => {
171
+ const deliveryId = crypto.randomUUID();
172
+
173
+ const first = await claimWebhookDelivery({tx: db(), provider: 'github', deliveryId});
174
+ const duplicate = await claimWebhookDelivery({tx: db(), provider: 'github', deliveryId});
175
+
176
+ expect(first.claimed).toBe(true);
177
+ expect(duplicate.claimed).toBe(false);
178
+ });
168
179
  });
169
180
 
170
181
  describe('publishSourcePush', () => {
@@ -171,8 +171,10 @@ export interface RecordDeliveryOnlyParams {
171
171
  deliveryId: string;
172
172
  }
173
173
 
174
- export async function recordDeliveryOnly(params: RecordDeliveryOnlyParams): Promise<void> {
175
- await params.tx
174
+ export async function claimWebhookDelivery(
175
+ params: RecordDeliveryOnlyParams,
176
+ ): Promise<{claimed: boolean}> {
177
+ const inserted = await params.tx
176
178
  .insert(integrationsWebhookDeliveries)
177
179
  .values({
178
180
  provider: params.provider,
@@ -185,7 +187,13 @@ export async function recordDeliveryOnly(params: RecordDeliveryOnlyParams): Prom
185
187
  integrationsWebhookDeliveries.dedupScope,
186
188
  integrationsWebhookDeliveries.deliveryId,
187
189
  ],
188
- });
190
+ })
191
+ .returning({deliveryId: integrationsWebhookDeliveries.deliveryId});
192
+ return {claimed: inserted.length > 0};
193
+ }
194
+
195
+ export async function recordDeliveryOnly(params: RecordDeliveryOnlyParams): Promise<void> {
196
+ await claimWebhookDelivery(params);
189
197
  }
190
198
 
191
199
  export interface PruneWebhookDeliveriesParams {
@@ -204,4 +212,5 @@ export async function pruneWebhookDeliveries(
204
212
  export type PublishIntegrationEventReceivedFn = typeof publishIntegrationEventReceived;
205
213
  export type PublishSourcePushFn = typeof publishSourcePush;
206
214
  export type PublishSourceCommitPushedFn = typeof publishSourceCommitPushed;
215
+ export type ClaimWebhookDeliveryFn = typeof claimWebhookDelivery;
207
216
  export type RecordDeliveryOnlyFn = typeof recordDeliveryOnly;
package/src/index.ts CHANGED
@@ -98,6 +98,7 @@ export {createSourceControlIntegrationService} from '#core/source-control-servic
98
98
  export type {GetIntegrationConnectionByIdFn} from '#db/connections.js';
99
99
  export {getIntegrationConnectionById} from '#db/connections.js';
100
100
  export type {
101
+ ClaimWebhookDeliveryFn,
101
102
  PublishIntegrationEventReceivedFn,
102
103
  PublishIntegrationEventReceivedParams,
103
104
  PublishIntegrationEventReceivedResult,
@@ -106,7 +107,7 @@ export type {
106
107
  RecordDeliveryOnlyFn,
107
108
  RecordDeliveryOnlyParams,
108
109
  } from '#db/webhook-deliveries.js';
109
- export {pruneWebhookDeliveries} from '#db/webhook-deliveries.js';
110
+ export {claimWebhookDelivery, pruneWebhookDeliveries} from '#db/webhook-deliveries.js';
110
111
  export {integrationRouteErrorHandler} from '#presentation/routes/errors.js';
111
112
 
112
113
  export interface CreateIntegrationsModuleOptions {
@@ -45,7 +45,7 @@ export function createIntegrationRoutes(
45
45
  createListIntegrationProvidersRoute(registry),
46
46
  createListIntegrationConnectionsRoute(registry),
47
47
  createUpdateIntegrationConnectionRoute(registry),
48
- createDeleteIntegrationConnectionRoute(),
48
+ createDeleteIntegrationConnectionRoute(registry),
49
49
  createListRepositoriesRoute(sourceControl),
50
50
  ...agentToolsRoutes,
51
51
  ...providerRoutes,
@@ -110,6 +110,119 @@ describe('DELETE /integration-connections/:connectionId', () => {
110
110
  expect(reloaded).toBeUndefined();
111
111
  });
112
112
 
113
+ it('runs provider cleanup hooks while retaining ownership of the core row', async () => {
114
+ const deleteConnectionRecords = vi.fn(() => Promise.resolve());
115
+ const deleteConnectionSecrets = vi.fn(() => Promise.resolve());
116
+ const app = await createTestApp([
117
+ sourceProvider({
118
+ provider: 'slack',
119
+ displayName: 'Slack',
120
+ adapters: {},
121
+ deleteConnectionRecords,
122
+ deleteConnectionSecrets,
123
+ }),
124
+ ]);
125
+ const connection = await upsertIntegrationConnection({
126
+ workspaceId: context.workspaceId,
127
+ provider: 'slack',
128
+ externalAccountId: 'T123',
129
+ slug: 'slack_acme',
130
+ displayName: 'Slack Acme',
131
+ });
132
+
133
+ const res = await app.inject({
134
+ method: 'DELETE',
135
+ url: `/integration-connections/${connection.id}`,
136
+ headers: {authorization: 'Bearer user'},
137
+ });
138
+
139
+ expect(res.statusCode).toBe(204);
140
+ expect(deleteConnectionRecords).toHaveBeenCalledWith(connection, {
141
+ tx: expect.anything(),
142
+ });
143
+ expect(deleteConnectionSecrets).toHaveBeenCalledWith(connection);
144
+ await expect(getIntegrationConnectionById(connection.id)).resolves.toBeUndefined();
145
+ });
146
+
147
+ it('keeps the connection when provider record cleanup fails', async () => {
148
+ const deleteConnectionSecrets = vi.fn(() => Promise.resolve());
149
+ const app = await createTestApp([
150
+ sourceProvider({
151
+ provider: 'slack',
152
+ displayName: 'Slack',
153
+ adapters: {},
154
+ deleteConnectionRecords: () => Promise.reject(new Error('record cleanup failed')),
155
+ deleteConnectionSecrets,
156
+ }),
157
+ ]);
158
+ const connection = await upsertIntegrationConnection({
159
+ workspaceId: context.workspaceId,
160
+ provider: 'slack',
161
+ externalAccountId: 'T123',
162
+ slug: 'slack_acme',
163
+ displayName: 'Slack Acme',
164
+ });
165
+
166
+ const res = await app.inject({
167
+ method: 'DELETE',
168
+ url: `/integration-connections/${connection.id}`,
169
+ headers: {authorization: 'Bearer user'},
170
+ });
171
+
172
+ expect(res.statusCode).toBe(500);
173
+ await expect(getIntegrationConnectionById(connection.id)).resolves.toMatchObject({
174
+ id: connection.id,
175
+ });
176
+ expect(deleteConnectionSecrets).not.toHaveBeenCalled();
177
+ });
178
+
179
+ it('deletes the connection when provider secret cleanup fails after commit', async () => {
180
+ const app = await createTestApp([
181
+ sourceProvider({
182
+ provider: 'slack',
183
+ displayName: 'Slack',
184
+ adapters: {},
185
+ deleteConnectionSecrets: () => Promise.reject(new Error('secret cleanup failed')),
186
+ }),
187
+ ]);
188
+ const connection = await upsertIntegrationConnection({
189
+ workspaceId: context.workspaceId,
190
+ provider: 'slack',
191
+ externalAccountId: 'T123',
192
+ slug: 'slack_acme',
193
+ displayName: 'Slack Acme',
194
+ });
195
+
196
+ const res = await app.inject({
197
+ method: 'DELETE',
198
+ url: `/integration-connections/${connection.id}`,
199
+ headers: {authorization: 'Bearer user'},
200
+ });
201
+
202
+ expect(res.statusCode).toBe(204);
203
+ await expect(getIntegrationConnectionById(connection.id)).resolves.toBeUndefined();
204
+ });
205
+
206
+ it('deletes an unregistered provider connection without provider cleanup', async () => {
207
+ const app = await createTestApp([]);
208
+ const connection = await upsertIntegrationConnection({
209
+ workspaceId: context.workspaceId,
210
+ provider: 'slack',
211
+ externalAccountId: 'T123',
212
+ slug: 'slack_acme',
213
+ displayName: 'Slack Acme',
214
+ });
215
+
216
+ const res = await app.inject({
217
+ method: 'DELETE',
218
+ url: `/integration-connections/${connection.id}`,
219
+ headers: {authorization: 'Bearer user'},
220
+ });
221
+
222
+ expect(res.statusCode).toBe(204);
223
+ await expect(getIntegrationConnectionById(connection.id)).resolves.toBeUndefined();
224
+ });
225
+
113
226
  it('returns not-found for a missing connection', async () => {
114
227
  const app = await createTestApp([sourceProvider()]);
115
228
 
@@ -11,6 +11,7 @@ import {
11
11
  getIntegrationConnectionById,
12
12
  updateIntegrationConnectionLifecycleStatus,
13
13
  } from '#db/connections.js';
14
+ import {db} from '#db/db.js';
14
15
  import {toIntegrationConnectionDto} from '#presentation/dto/integrations.js';
15
16
 
16
17
  const connectionParamsSchema = z.object({
@@ -51,7 +52,7 @@ export function createUpdateIntegrationConnectionRoute(registry: IntegrationProv
51
52
  });
52
53
  }
53
54
 
54
- export function createDeleteIntegrationConnectionRoute() {
55
+ export function createDeleteIntegrationConnectionRoute(registry: IntegrationProviderRegistry) {
55
56
  return defineRoute({
56
57
  method: 'DELETE',
57
58
  path: '/integration-connections/:connectionId',
@@ -70,7 +71,30 @@ export function createDeleteIntegrationConnectionRoute() {
70
71
  }
71
72
 
72
73
  requireWorkspaceAccess({request, workspaceId: connection.workspaceId});
73
- await deleteIntegrationConnection({id: connection.id});
74
+ const provider = registry
75
+ .list()
76
+ .find((candidate) => candidate.provider === connection.provider);
77
+ const hasCleanupHooks =
78
+ provider?.deleteConnectionRecords !== undefined ||
79
+ provider?.deleteConnectionSecrets !== undefined;
80
+ if (!hasCleanupHooks) {
81
+ request.log.warn(
82
+ {connectionId: connection.id, provider: connection.provider},
83
+ 'Deleting integration connection without provider cleanup',
84
+ );
85
+ }
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
+ );
97
+ }
74
98
  reply.status(204);
75
99
  },
76
100
  });
@@ -1,22 +1,160 @@
1
+ import {
2
+ type IntegrationConnection as CoreIntegrationConnection,
3
+ slugifyConnectionSlug,
4
+ } from '@shipfox/api-integration-core-dto';
5
+ import type {
6
+ ConnectJiraInstallationInput,
7
+ JiraPendingSelectionSecretsStore,
8
+ JiraSecretsStore,
9
+ } from '@shipfox/api-integration-jira';
1
10
  import {config} from '#config.js';
11
+ import {
12
+ deleteIntegrationConnection,
13
+ getIntegrationConnectionById,
14
+ resolveUniqueConnectionSlug,
15
+ updateIntegrationConnectionLifecycleStatus,
16
+ upsertIntegrationConnection,
17
+ } from '#db/connections.js';
18
+ import {db} from '#db/db.js';
19
+ import {retryConnectionSlugCollision} from '#providers/connection-slug.js';
2
20
  import type {IntegrationModuleParts, IntegrationProviderModule} from '#providers/types.js';
3
21
 
4
22
  const JIRA_MIGRATIONS_TABLE = '__drizzle_migrations_integrations_jira';
23
+ const JIRA_SECRETS_NAMESPACE_PREFIX = 'system/integrations/jira/';
24
+ type IntegrationDb = ReturnType<typeof db>;
25
+ type IntegrationTx = Parameters<Parameters<IntegrationDb['transaction']>[0]>[0];
5
26
 
6
- async function loadJiraModuleParts(): Promise<IntegrationModuleParts> {
27
+ async function loadJiraModuleParts(
28
+ options: Parameters<IntegrationProviderModule['load']>[0] = {},
29
+ ): Promise<IntegrationModuleParts> {
7
30
  const {
8
31
  createJiraIntegrationProvider,
32
+ createJiraPendingSelectionStore,
33
+ createJiraTokenStore,
9
34
  db: jiraDb,
35
+ disconnectJiraInstallation: disconnectJiraInstallationRecords,
36
+ getJiraInstallationByCloudId,
10
37
  migrationsPath,
38
+ upsertJiraInstallation,
11
39
  } = await import('@shipfox/api-integration-jira');
12
40
 
13
- return {
14
- provider: createJiraIntegrationProvider(),
15
- database: {
16
- db: jiraDb,
17
- migrationsPath,
18
- migrationsTableName: JIRA_MIGRATIONS_TABLE,
41
+ async function getExistingJiraConnection(input: {
42
+ cloudId: string;
43
+ }): Promise<CoreIntegrationConnection<'jira'> | undefined> {
44
+ const installation = await getJiraInstallationByCloudId(input.cloudId);
45
+ if (!installation) return undefined;
46
+ return (await getIntegrationConnectionById(installation.connectionId)) as
47
+ | CoreIntegrationConnection<'jira'>
48
+ | undefined;
49
+ }
50
+
51
+ function connectJiraInstallation(
52
+ input: ConnectJiraInstallationInput,
53
+ ): Promise<CoreIntegrationConnection<'jira'>> {
54
+ return retryConnectionSlugCollision(() =>
55
+ db().transaction(async (tx) => {
56
+ const slug = await resolveUniqueConnectionSlug(
57
+ {
58
+ workspaceId: input.workspaceId,
59
+ provider: 'jira',
60
+ externalAccountId: input.cloudId,
61
+ baseSlug: slugifyConnectionSlug(`jira_${input.siteName || input.cloudId}`, {
62
+ fallback: 'jira',
63
+ }),
64
+ },
65
+ {tx},
66
+ );
67
+ const connection = await upsertIntegrationConnection(
68
+ {
69
+ workspaceId: input.workspaceId,
70
+ provider: 'jira',
71
+ externalAccountId: input.cloudId,
72
+ slug,
73
+ displayName: input.displayName,
74
+ lifecycleStatus: 'active',
75
+ },
76
+ {tx},
77
+ );
78
+ await upsertJiraInstallation(
79
+ {
80
+ connectionId: connection.id,
81
+ cloudId: input.cloudId,
82
+ siteUrl: input.siteUrl,
83
+ siteName: input.siteName,
84
+ authorizingAccountId: input.authorizingAccountId,
85
+ scopes: input.scopes,
86
+ status: 'installed',
87
+ tokenExpiresAt: input.tokenExpiresAt,
88
+ },
89
+ {tx},
90
+ );
91
+ return connection as CoreIntegrationConnection<'jira'>;
92
+ }),
93
+ );
94
+ }
95
+
96
+ async function disconnectJiraInstallation(input: {connectionId: string}): Promise<void> {
97
+ await disconnectJiraInstallationRecords<IntegrationTx>({
98
+ connectionId: input.connectionId,
99
+ getConnection: getIntegrationConnectionById,
100
+ deleteSecrets: (params) =>
101
+ options.secrets?.jira?.deleteSecrets({
102
+ ...params,
103
+ namespace: jiraNamespaceSuffix(params.namespace),
104
+ }) ?? Promise.resolve(0),
105
+ transaction: (fn) => db().transaction((tx) => fn(tx)),
106
+ deleteConnection: (params, transactionOptions) =>
107
+ deleteIntegrationConnection({id: params.connectionId}, transactionOptions),
108
+ });
109
+ }
110
+
111
+ const fallbackSecrets: JiraSecretsStore & JiraPendingSelectionSecretsStore = {
112
+ getSecret: () => Promise.resolve(null),
113
+ setSecrets: () => Promise.reject(new Error('Jira token storage is not configured')),
114
+ deleteSecrets: () => Promise.resolve(0),
115
+ };
116
+ const secrets: JiraSecretsStore & JiraPendingSelectionSecretsStore = options.secrets?.jira
117
+ ? {
118
+ getSecret: (params) =>
119
+ options.secrets?.jira?.getSecret({
120
+ ...params,
121
+ namespace: jiraNamespaceSuffix(params.namespace),
122
+ }) ?? Promise.resolve(null),
123
+ setSecrets: (params) =>
124
+ options.secrets?.jira?.setSecrets({
125
+ ...params,
126
+ namespace: jiraNamespaceSuffix(params.namespace),
127
+ }) ?? Promise.resolve(),
128
+ deleteSecrets: (params) =>
129
+ options.secrets?.jira?.deleteSecrets({
130
+ ...params,
131
+ namespace: jiraNamespaceSuffix(params.namespace),
132
+ }) ?? Promise.resolve(0),
133
+ }
134
+ : fallbackSecrets;
135
+ const tokenStore = createJiraTokenStore({
136
+ resolveConnection: getIntegrationConnectionById,
137
+ secrets,
138
+ markConnectionError: async ({connectionId}) => {
139
+ await updateIntegrationConnectionLifecycleStatus({
140
+ id: connectionId,
141
+ lifecycleStatus: 'error',
142
+ });
19
143
  },
144
+ });
145
+ const pendingStore = createJiraPendingSelectionStore({secrets});
146
+
147
+ return {
148
+ provider: createJiraIntegrationProvider({
149
+ routes: {
150
+ tokenStore,
151
+ pendingStore,
152
+ getExistingJiraConnection,
153
+ connectJiraInstallation,
154
+ disconnectJiraInstallation,
155
+ },
156
+ }),
157
+ database: {db: jiraDb, migrationsPath, migrationsTableName: JIRA_MIGRATIONS_TABLE},
20
158
  };
21
159
  }
22
160
 
@@ -25,3 +163,10 @@ export const jiraProviderModule: IntegrationProviderModule = {
25
163
  enabled: config.INTEGRATIONS_ENABLE_JIRA_PROVIDER,
26
164
  load: loadJiraModuleParts,
27
165
  };
166
+
167
+ function jiraNamespaceSuffix(namespace: string): string {
168
+ if (!namespace.startsWith(JIRA_SECRETS_NAMESPACE_PREFIX)) {
169
+ throw new Error('Jira provider attempted to access an unscoped secret namespace');
170
+ }
171
+ return namespace.slice(JIRA_SECRETS_NAMESPACE_PREFIX.length);
172
+ }
@@ -0,0 +1,136 @@
1
+ import {
2
+ getLinearInstallationByConnectionId,
3
+ upsertLinearInstallation,
4
+ } from '@shipfox/api-integration-linear';
5
+ import {runMigrations} from '@shipfox/node-drizzle';
6
+ import {getIntegrationConnectionById, upsertIntegrationConnection} from '#db/connections.js';
7
+ import {db} from '#db/db.js';
8
+ import {linearProviderModule} from '#providers/linear.js';
9
+ import {createTestApp, useIntegrationRouteTest} from '#test/route-utils.js';
10
+
11
+ describe('linearProviderModule', () => {
12
+ const context = useIntegrationRouteTest();
13
+
14
+ it('deletes the installation and tokens through the generic route before allowing a reinstall', async () => {
15
+ const deleteSecrets = vi.fn(() => Promise.resolve(2));
16
+ const scopedSecrets = {
17
+ getSecret: vi.fn(() => Promise.resolve(null)),
18
+ setSecrets: vi.fn(() => Promise.resolve()),
19
+ deleteSecrets,
20
+ };
21
+ const linearPart = await linearProviderModule.load({
22
+ secrets: {linear: scopedSecrets, deleteSecrets},
23
+ });
24
+ if (!linearPart.database) throw new Error('Linear provider database is not configured');
25
+ const organizationId = crypto.randomUUID();
26
+
27
+ await runMigrations(
28
+ linearPart.database.db(),
29
+ linearPart.database.migrationsPath,
30
+ linearPart.database.migrationsTableName,
31
+ );
32
+ const app = await createTestApp([linearPart.provider]);
33
+ const connection = await upsertIntegrationConnection({
34
+ workspaceId: context.workspaceId,
35
+ provider: 'linear',
36
+ externalAccountId: organizationId,
37
+ slug: 'linear_acme',
38
+ displayName: 'Linear Acme',
39
+ });
40
+ await upsertLinearInstallation({
41
+ connectionId: connection.id,
42
+ organizationId,
43
+ organizationUrlKey: 'acme',
44
+ appUserId: 'user-1',
45
+ scopes: ['read'],
46
+ status: 'installed',
47
+ tokenExpiresAt: null,
48
+ });
49
+
50
+ const res = await app.inject({
51
+ method: 'DELETE',
52
+ url: `/integration-connections/${connection.id}`,
53
+ headers: {authorization: 'Bearer user'},
54
+ });
55
+
56
+ expect(res.statusCode).toBe(204);
57
+ await expect(getIntegrationConnectionById(connection.id)).resolves.toBeUndefined();
58
+ await expect(getLinearInstallationByConnectionId(connection.id)).resolves.toBeUndefined();
59
+ expect(deleteSecrets).toHaveBeenCalledWith({
60
+ workspaceId: context.workspaceId,
61
+ namespace: connection.id,
62
+ });
63
+
64
+ const replacement = await upsertIntegrationConnection({
65
+ workspaceId: context.workspaceId,
66
+ provider: 'linear',
67
+ externalAccountId: organizationId,
68
+ slug: 'linear_acme_again',
69
+ displayName: 'Linear Acme',
70
+ });
71
+ await upsertLinearInstallation({
72
+ connectionId: replacement.id,
73
+ organizationId,
74
+ organizationUrlKey: 'acme',
75
+ appUserId: 'user-1',
76
+ scopes: ['read'],
77
+ status: 'installed',
78
+ tokenExpiresAt: null,
79
+ });
80
+
81
+ await expect(getLinearInstallationByConnectionId(replacement.id)).resolves.toMatchObject({
82
+ organizationId,
83
+ });
84
+ });
85
+
86
+ it('rolls back provider record cleanup when its transaction fails', async () => {
87
+ const deleteSecrets = vi.fn(() => Promise.resolve(2));
88
+ const scopedSecrets = {
89
+ getSecret: vi.fn(() => Promise.resolve(null)),
90
+ setSecrets: vi.fn(() => Promise.resolve()),
91
+ deleteSecrets,
92
+ };
93
+ const linearPart = await linearProviderModule.load({
94
+ secrets: {linear: scopedSecrets, deleteSecrets},
95
+ });
96
+ if (!linearPart.database) throw new Error('Linear provider database is not configured');
97
+ const organizationId = crypto.randomUUID();
98
+
99
+ await runMigrations(
100
+ linearPart.database.db(),
101
+ linearPart.database.migrationsPath,
102
+ linearPart.database.migrationsTableName,
103
+ );
104
+ const connection = await upsertIntegrationConnection({
105
+ workspaceId: context.workspaceId,
106
+ provider: 'linear',
107
+ externalAccountId: organizationId,
108
+ slug: 'linear_acme',
109
+ displayName: 'Linear Acme',
110
+ });
111
+ await upsertLinearInstallation({
112
+ connectionId: connection.id,
113
+ organizationId,
114
+ organizationUrlKey: 'acme',
115
+ appUserId: 'user-1',
116
+ scopes: ['read'],
117
+ status: 'installed',
118
+ tokenExpiresAt: null,
119
+ });
120
+
121
+ await expect(
122
+ db().transaction(async (tx) => {
123
+ await linearPart.provider.deleteConnectionRecords?.(connection, {tx});
124
+ throw new Error('transaction failed');
125
+ }),
126
+ ).rejects.toThrow('transaction failed');
127
+
128
+ await expect(getIntegrationConnectionById(connection.id)).resolves.toMatchObject({
129
+ id: connection.id,
130
+ });
131
+ await expect(getLinearInstallationByConnectionId(connection.id)).resolves.toMatchObject({
132
+ connectionId: connection.id,
133
+ });
134
+ expect(deleteSecrets).not.toHaveBeenCalled();
135
+ });
136
+ });