@shipfox/api-integration-core 3.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 (57) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +62 -0
  3. package/dist/config.d.ts +2 -0
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +8 -0
  6. package/dist/config.js.map +1 -1
  7. package/dist/db/webhook-deliveries.d.ts +4 -0
  8. package/dist/db/webhook-deliveries.d.ts.map +1 -1
  9. package/dist/db/webhook-deliveries.js +10 -2
  10. package/dist/db/webhook-deliveries.js.map +1 -1
  11. package/dist/index.d.ts +2 -2
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +1 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/presentation/routes/index.js +1 -1
  16. package/dist/presentation/routes/index.js.map +1 -1
  17. package/dist/presentation/routes/manage-connections.d.ts +1 -1
  18. package/dist/presentation/routes/manage-connections.d.ts.map +1 -1
  19. package/dist/presentation/routes/manage-connections.js +28 -3
  20. package/dist/presentation/routes/manage-connections.js.map +1 -1
  21. package/dist/providers/jira.d.ts +3 -0
  22. package/dist/providers/jira.d.ts.map +1 -0
  23. package/dist/providers/jira.js +127 -0
  24. package/dist/providers/jira.js.map +1 -0
  25. package/dist/providers/linear.d.ts.map +1 -1
  26. package/dist/providers/linear.js +15 -1
  27. package/dist/providers/linear.js.map +1 -1
  28. package/dist/providers/modules.d.ts.map +1 -1
  29. package/dist/providers/modules.js +4 -0
  30. package/dist/providers/modules.js.map +1 -1
  31. package/dist/providers/slack.d.ts +3 -0
  32. package/dist/providers/slack.d.ts.map +1 -0
  33. package/dist/providers/slack.js +149 -0
  34. package/dist/providers/slack.js.map +1 -0
  35. package/dist/providers/types.d.ts +2 -0
  36. package/dist/providers/types.d.ts.map +1 -1
  37. package/dist/providers/types.js.map +1 -1
  38. package/dist/tsconfig.test.tsbuildinfo +1 -1
  39. package/package.json +28 -36
  40. package/src/config.ts +8 -0
  41. package/src/db/webhook-deliveries.test.ts +11 -0
  42. package/src/db/webhook-deliveries.ts +12 -3
  43. package/src/index.ts +2 -1
  44. package/src/presentation/routes/index.ts +1 -1
  45. package/src/presentation/routes/manage-connections.test.ts +113 -0
  46. package/src/presentation/routes/manage-connections.ts +26 -2
  47. package/src/providers/jira.test.ts +57 -0
  48. package/src/providers/jira.ts +172 -0
  49. package/src/providers/linear.test.ts +136 -0
  50. package/src/providers/linear.ts +14 -0
  51. package/src/providers/modules.test.ts +26 -0
  52. package/src/providers/modules.ts +4 -0
  53. package/src/providers/slack.test.ts +196 -0
  54. package/src/providers/slack.ts +196 -0
  55. package/src/providers/types.ts +2 -0
  56. package/test/env.ts +12 -0
  57. 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": "3.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,24 +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": "3.0.0",
44
- "@shipfox/api-integration-github": "3.0.0",
45
- "@shipfox/api-integration-linear": "3.0.0",
46
- "@shipfox/api-integration-sentry": "3.0.0",
47
- "@shipfox/api-integration-webhook": "3.0.0",
48
- "@shipfox/config": "1.2.1",
49
- "@shipfox/node-drizzle": "0.2.1",
50
- "@shipfox/node-fastify": "0.2.2",
51
- "@shipfox/node-module": "0.3.0",
52
- "@shipfox/node-opentelemetry": "0.5.1",
53
- "@shipfox/node-outbox": "0.2.1",
54
- "@shipfox/node-postgres": "0.4.1",
55
- "@shipfox/node-temporal": "0.3.0",
56
- "@shipfox/regex": "0.2.1",
57
- "@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"
58
50
  },
59
51
  "devDependencies": {
60
52
  "@temporalio/activity": "1.18.1",
@@ -67,11 +59,11 @@
67
59
  "fastify": "^5.3.3",
68
60
  "fastify-type-provider-zod": "^6.0.0",
69
61
  "fishery": "^2.4.0",
70
- "@shipfox/biome": "1.8.1",
71
- "@shipfox/swc": "1.2.5",
62
+ "@shipfox/biome": "1.8.2",
63
+ "@shipfox/swc": "1.2.6",
72
64
  "@shipfox/ts-config": "1.3.8",
73
- "@shipfox/typescript": "1.1.6",
74
- "@shipfox/vitest": "1.2.2"
65
+ "@shipfox/typescript": "1.1.7",
66
+ "@shipfox/vitest": "1.2.3"
75
67
  },
76
68
  "scripts": {
77
69
  "build": "shipfox-swc && shipfox-temporal-bundle dist/temporal/workflows/index.js",
package/src/config.ts CHANGED
@@ -13,6 +13,10 @@ export const config = createConfig({
13
13
  desc: 'Enables the GitHub integration provider so users can connect GitHub.',
14
14
  default: false,
15
15
  }),
16
+ INTEGRATIONS_ENABLE_JIRA_PROVIDER: bool({
17
+ desc: 'Enables the Jira integration provider so users can connect Jira sites.',
18
+ default: false,
19
+ }),
16
20
  INTEGRATIONS_ENABLE_LINEAR_PROVIDER: bool({
17
21
  desc: 'Enables the Linear integration provider so users can connect Linear workspaces.',
18
22
  default: false,
@@ -21,6 +25,10 @@ export const config = createConfig({
21
25
  desc: 'Enables the Sentry integration provider so users can connect Sentry.',
22
26
  default: false,
23
27
  }),
28
+ INTEGRATIONS_ENABLE_SLACK_PROVIDER: bool({
29
+ desc: 'Enables the Slack integration provider so users can connect Slack workspaces.',
30
+ default: false,
31
+ }),
24
32
  INTEGRATIONS_ENABLE_WEBHOOK_PROVIDER: bool({
25
33
  desc: 'Enables the generic webhook integration provider so users can create inbound webhook URLs. It is enabled by default because it does not require provider setup.',
26
34
  default: true,
@@ -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
  });
@@ -0,0 +1,57 @@
1
+ import {
2
+ getJiraInstallationByConnectionId,
3
+ upsertJiraInstallation,
4
+ } from '@shipfox/api-integration-jira';
5
+ import {runMigrations} from '@shipfox/node-drizzle';
6
+ import {getIntegrationConnectionById, upsertIntegrationConnection} from '#db/connections.js';
7
+
8
+ describe('jiraProviderModule', () => {
9
+ afterEach(() => {
10
+ vi.unstubAllEnvs();
11
+ vi.resetModules();
12
+ });
13
+
14
+ it('loads its database descriptor and persists a core connection with its Jira installation', async () => {
15
+ vi.stubEnv('INTEGRATIONS_ENABLE_JIRA_PROVIDER', 'true');
16
+ vi.resetModules();
17
+ const {createPostgresClient} = await import('@shipfox/node-postgres');
18
+ createPostgresClient();
19
+ const {loadEnabledProviderModules} = await import('#providers/modules.js');
20
+ const parts = await loadEnabledProviderModules();
21
+ const jiraPart = parts.find((part) => part.provider.provider === 'jira');
22
+ if (!jiraPart?.database) throw new Error('Jira provider database is not configured');
23
+ const workspaceId = crypto.randomUUID();
24
+ const cloudId = crypto.randomUUID();
25
+
26
+ await runMigrations(
27
+ jiraPart.database.db(),
28
+ jiraPart.database.migrationsPath,
29
+ jiraPart.database.migrationsTableName,
30
+ );
31
+ const connection = await upsertIntegrationConnection({
32
+ workspaceId,
33
+ provider: 'jira',
34
+ externalAccountId: cloudId,
35
+ slug: `jira_${cloudId}`,
36
+ displayName: 'Jira Acme',
37
+ });
38
+ await upsertJiraInstallation({
39
+ connectionId: connection.id,
40
+ cloudId,
41
+ siteUrl: 'https://acme.atlassian.net',
42
+ siteName: 'Acme',
43
+ authorizingAccountId: crypto.randomUUID(),
44
+ scopes: ['read:jira-work'],
45
+ status: 'installed',
46
+ });
47
+
48
+ await expect(getIntegrationConnectionById(connection.id)).resolves.toMatchObject({
49
+ id: connection.id,
50
+ provider: 'jira',
51
+ });
52
+ await expect(getJiraInstallationByConnectionId(connection.id)).resolves.toMatchObject({
53
+ connectionId: connection.id,
54
+ cloudId,
55
+ });
56
+ });
57
+ });
@@ -0,0 +1,172 @@
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';
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';
20
+ import type {IntegrationModuleParts, IntegrationProviderModule} from '#providers/types.js';
21
+
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];
26
+
27
+ async function loadJiraModuleParts(
28
+ options: Parameters<IntegrationProviderModule['load']>[0] = {},
29
+ ): Promise<IntegrationModuleParts> {
30
+ const {
31
+ createJiraIntegrationProvider,
32
+ createJiraPendingSelectionStore,
33
+ createJiraTokenStore,
34
+ db: jiraDb,
35
+ disconnectJiraInstallation: disconnectJiraInstallationRecords,
36
+ getJiraInstallationByCloudId,
37
+ migrationsPath,
38
+ upsertJiraInstallation,
39
+ } = await import('@shipfox/api-integration-jira');
40
+
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
+ });
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},
158
+ };
159
+ }
160
+
161
+ export const jiraProviderModule: IntegrationProviderModule = {
162
+ id: 'jira',
163
+ enabled: config.INTEGRATIONS_ENABLE_JIRA_PROVIDER,
164
+ load: loadJiraModuleParts,
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
+ }