@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.
- package/.turbo/turbo-build.log +4 -4
- package/CHANGELOG.md +35 -0
- package/dist/db/webhook-deliveries.d.ts +4 -0
- package/dist/db/webhook-deliveries.d.ts.map +1 -1
- package/dist/db/webhook-deliveries.js +10 -2
- package/dist/db/webhook-deliveries.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/presentation/routes/index.js +1 -1
- package/dist/presentation/routes/index.js.map +1 -1
- package/dist/presentation/routes/manage-connections.d.ts +1 -1
- package/dist/presentation/routes/manage-connections.d.ts.map +1 -1
- package/dist/presentation/routes/manage-connections.js +28 -3
- package/dist/presentation/routes/manage-connections.js.map +1 -1
- package/dist/providers/jira.d.ts.map +1 -1
- package/dist/providers/jira.js +110 -3
- package/dist/providers/jira.js.map +1 -1
- package/dist/providers/linear.d.ts.map +1 -1
- package/dist/providers/linear.js +15 -1
- package/dist/providers/linear.js.map +1 -1
- package/dist/providers/slack.d.ts.map +1 -1
- package/dist/providers/slack.js +17 -2
- package/dist/providers/slack.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +28 -38
- package/src/db/webhook-deliveries.test.ts +11 -0
- package/src/db/webhook-deliveries.ts +12 -3
- package/src/index.ts +2 -1
- package/src/presentation/routes/index.ts +1 -1
- package/src/presentation/routes/manage-connections.test.ts +113 -0
- package/src/presentation/routes/manage-connections.ts +26 -2
- package/src/providers/jira.ts +152 -7
- package/src/providers/linear.test.ts +136 -0
- package/src/providers/linear.ts +14 -0
- package/src/providers/slack.test.ts +139 -0
- package/src/providers/slack.ts +20 -1
- 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
|
+
"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
|
-
"
|
|
25
|
-
|
|
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": "
|
|
41
|
-
"@shipfox/api-auth-context": "
|
|
42
|
-
"@shipfox/api-integration-core-dto": "
|
|
43
|
-
"@shipfox/api-integration-gitea": "
|
|
44
|
-
"@shipfox/api-integration-github": "
|
|
45
|
-
"@shipfox/api-integration-jira": "
|
|
46
|
-
"@shipfox/api-integration-linear": "
|
|
47
|
-
"@shipfox/api-integration-sentry": "
|
|
48
|
-
"@shipfox/api-integration-slack": "
|
|
49
|
-
"@shipfox/api-integration-webhook": "
|
|
50
|
-
"@shipfox/config": "1.2.
|
|
51
|
-
"@shipfox/node-drizzle": "0.3.
|
|
52
|
-
"@shipfox/node-fastify": "0.2.
|
|
53
|
-
"@shipfox/node-module": "0.3.
|
|
54
|
-
"@shipfox/node-opentelemetry": "0.5.
|
|
55
|
-
"@shipfox/node-outbox": "0.2.
|
|
56
|
-
"@shipfox/node-postgres": "0.4.
|
|
57
|
-
"@shipfox/node-temporal": "0.3.
|
|
58
|
-
"@shipfox/regex": "0.2.
|
|
59
|
-
"@shipfox/redact": "0.2.
|
|
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.
|
|
73
|
-
"@shipfox/swc": "1.2.
|
|
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.
|
|
76
|
-
"@shipfox/vitest": "1.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
|
|
175
|
-
|
|
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
|
-
|
|
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
|
});
|
package/src/providers/jira.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
+
});
|