@shipfox/api-integration-jira 4.0.0 → 6.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 +1 -1
- package/CHANGELOG.md +52 -0
- package/dist/api/client.d.ts +33 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +143 -0
- package/dist/api/client.js.map +1 -0
- package/dist/core/disconnect.d.ts +18 -0
- package/dist/core/disconnect.d.ts.map +1 -0
- package/dist/core/disconnect.js +21 -0
- package/dist/core/disconnect.js.map +1 -0
- package/dist/core/errors.d.ts +37 -0
- package/dist/core/errors.d.ts.map +1 -1
- package/dist/core/errors.js +63 -0
- package/dist/core/errors.js.map +1 -1
- package/dist/core/install.d.ts +55 -0
- package/dist/core/install.d.ts.map +1 -0
- package/dist/core/install.js +121 -0
- package/dist/core/install.js.map +1 -0
- package/dist/core/pending.d.ts +43 -0
- package/dist/core/pending.d.ts.map +1 -0
- package/dist/core/pending.js +107 -0
- package/dist/core/pending.js.map +1 -0
- package/dist/core/scopes.d.ts +4 -0
- package/dist/core/scopes.d.ts.map +1 -0
- package/dist/core/scopes.js +23 -0
- package/dist/core/scopes.js.map +1 -0
- package/dist/core/state.d.ts +12 -0
- package/dist/core/state.d.ts.map +1 -0
- package/dist/core/state.js +51 -0
- package/dist/core/state.js.map +1 -0
- package/dist/core/tokens.d.ts +6 -0
- package/dist/core/tokens.d.ts.map +1 -1
- package/dist/core/tokens.js +82 -6
- package/dist/core/tokens.js.map +1 -1
- package/dist/db/db.d.ts +188 -0
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/db.js +3 -1
- package/dist/db/db.js.map +1 -1
- package/dist/db/installations.d.ts +21 -0
- package/dist/db/installations.d.ts.map +1 -1
- package/dist/db/installations.js +82 -19
- package/dist/db/installations.js.map +1 -1
- package/dist/db/pending-selections.d.ts +17 -0
- package/dist/db/pending-selections.d.ts.map +1 -0
- package/dist/db/pending-selections.js +27 -0
- package/dist/db/pending-selections.js.map +1 -0
- package/dist/db/schema/installations.js +2 -2
- package/dist/db/schema/installations.js.map +1 -1
- package/dist/db/schema/pending-selections.d.ts +105 -0
- package/dist/db/schema/pending-selections.d.ts.map +1 -0
- package/dist/db/schema/pending-selections.js +24 -0
- package/dist/db/schema/pending-selections.js.map +1 -0
- package/dist/index.d.ts +26 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +27 -4
- package/dist/index.js.map +1 -1
- package/dist/presentation/dto/integrations.d.ts +2 -0
- package/dist/presentation/dto/integrations.d.ts.map +1 -0
- package/dist/presentation/dto/integrations.js +3 -0
- package/dist/presentation/dto/integrations.js.map +1 -0
- package/dist/presentation/routes/errors.d.ts +2 -0
- package/dist/presentation/routes/errors.d.ts.map +1 -0
- package/dist/presentation/routes/errors.js +58 -0
- package/dist/presentation/routes/errors.js.map +1 -0
- package/dist/presentation/routes/install.d.ts +26 -0
- package/dist/presentation/routes/install.d.ts.map +1 -0
- package/dist/presentation/routes/install.js +133 -0
- package/dist/presentation/routes/install.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0000_whole_master_chief.sql +8 -1
- package/drizzle/meta/0000_snapshot.json +47 -3
- package/package.json +22 -26
- package/src/api/client.test.ts +29 -0
- package/src/api/client.ts +213 -0
- package/src/core/disconnect.test.ts +24 -0
- package/src/core/disconnect.ts +25 -0
- package/src/core/errors.ts +79 -0
- package/src/core/install.test.ts +129 -0
- package/src/core/install.ts +191 -0
- package/src/core/pending.ts +146 -0
- package/src/core/scopes.test.ts +19 -0
- package/src/core/scopes.ts +20 -0
- package/src/core/state.test.ts +38 -0
- package/src/core/state.ts +79 -0
- package/src/core/tokens-refresh.test.ts +106 -0
- package/src/core/tokens.test.ts +16 -2
- package/src/core/tokens.ts +145 -8
- package/src/db/db.ts +2 -1
- package/src/db/installations.test.ts +37 -1
- package/src/db/installations.ts +120 -23
- package/src/db/pending-selections.ts +55 -0
- package/src/db/schema/installations.ts +2 -2
- package/src/db/schema/pending-selections.ts +31 -0
- package/src/index.test.ts +7 -2
- package/src/index.ts +66 -2
- package/src/presentation/dto/integrations.ts +1 -0
- package/src/presentation/routes/errors.ts +61 -0
- package/src/presentation/routes/install.ts +161 -0
- package/test/globalSetup.ts +0 -9
- package/tsconfig.build.tsbuildinfo +1 -1
- package/turbo.json +9 -0
- package/test/api-secrets.d.ts +0 -26
|
@@ -15,4 +15,11 @@ CREATE TABLE "integrations_jira_installations" (
|
|
|
15
15
|
);
|
|
16
16
|
--> statement-breakpoint
|
|
17
17
|
CREATE UNIQUE INDEX "integrations_jira_installations_connection_unique" ON "integrations_jira_installations" USING btree ("connection_id");--> statement-breakpoint
|
|
18
|
-
CREATE INDEX "
|
|
18
|
+
CREATE UNIQUE INDEX "integrations_jira_installations_cloud_id_unique" ON "integrations_jira_installations" USING btree ("cloud_id");--> statement-breakpoint
|
|
19
|
+
CREATE TABLE "integrations_jira_pending_selections" (
|
|
20
|
+
"state_hash" text PRIMARY KEY NOT NULL,
|
|
21
|
+
"workspace_id" uuid NOT NULL,
|
|
22
|
+
"expires_at" timestamp with time zone NOT NULL,
|
|
23
|
+
"sites" jsonb NOT NULL,
|
|
24
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
25
|
+
);
|
|
@@ -107,8 +107,8 @@
|
|
|
107
107
|
"method": "btree",
|
|
108
108
|
"with": {}
|
|
109
109
|
},
|
|
110
|
-
"
|
|
111
|
-
"name": "
|
|
110
|
+
"integrations_jira_installations_cloud_id_unique": {
|
|
111
|
+
"name": "integrations_jira_installations_cloud_id_unique",
|
|
112
112
|
"columns": [
|
|
113
113
|
{
|
|
114
114
|
"expression": "cloud_id",
|
|
@@ -117,7 +117,7 @@
|
|
|
117
117
|
"nulls": "last"
|
|
118
118
|
}
|
|
119
119
|
],
|
|
120
|
-
"isUnique":
|
|
120
|
+
"isUnique": true,
|
|
121
121
|
"concurrently": false,
|
|
122
122
|
"method": "btree",
|
|
123
123
|
"with": {}
|
|
@@ -129,6 +129,50 @@
|
|
|
129
129
|
"policies": {},
|
|
130
130
|
"checkConstraints": {},
|
|
131
131
|
"isRLSEnabled": false
|
|
132
|
+
},
|
|
133
|
+
"public.integrations_jira_pending_selections": {
|
|
134
|
+
"name": "integrations_jira_pending_selections",
|
|
135
|
+
"schema": "",
|
|
136
|
+
"columns": {
|
|
137
|
+
"state_hash": {
|
|
138
|
+
"name": "state_hash",
|
|
139
|
+
"type": "text",
|
|
140
|
+
"primaryKey": true,
|
|
141
|
+
"notNull": true
|
|
142
|
+
},
|
|
143
|
+
"workspace_id": {
|
|
144
|
+
"name": "workspace_id",
|
|
145
|
+
"type": "uuid",
|
|
146
|
+
"primaryKey": false,
|
|
147
|
+
"notNull": true
|
|
148
|
+
},
|
|
149
|
+
"expires_at": {
|
|
150
|
+
"name": "expires_at",
|
|
151
|
+
"type": "timestamp with time zone",
|
|
152
|
+
"primaryKey": false,
|
|
153
|
+
"notNull": true
|
|
154
|
+
},
|
|
155
|
+
"sites": {
|
|
156
|
+
"name": "sites",
|
|
157
|
+
"type": "jsonb",
|
|
158
|
+
"primaryKey": false,
|
|
159
|
+
"notNull": true
|
|
160
|
+
},
|
|
161
|
+
"created_at": {
|
|
162
|
+
"name": "created_at",
|
|
163
|
+
"type": "timestamp with time zone",
|
|
164
|
+
"primaryKey": false,
|
|
165
|
+
"notNull": true,
|
|
166
|
+
"default": "now()"
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
"indexes": {},
|
|
170
|
+
"foreignKeys": {},
|
|
171
|
+
"compositePrimaryKeys": {},
|
|
172
|
+
"uniqueConstraints": {},
|
|
173
|
+
"policies": {},
|
|
174
|
+
"checkConstraints": {},
|
|
175
|
+
"isRLSEnabled": false
|
|
132
176
|
}
|
|
133
177
|
},
|
|
134
178
|
"enums": {},
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/api-integration-jira",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "6.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -13,48 +13,44 @@
|
|
|
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": {
|
|
35
25
|
"drizzle-orm": "^0.45.2",
|
|
36
|
-
"
|
|
37
|
-
"@shipfox/
|
|
38
|
-
"@shipfox/
|
|
39
|
-
"@shipfox/
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
"@shipfox/
|
|
26
|
+
"ky": "^2.0.0",
|
|
27
|
+
"@shipfox/api-integration-core-dto": "6.0.0",
|
|
28
|
+
"@shipfox/api-integration-jira-dto": "6.0.0",
|
|
29
|
+
"@shipfox/api-workspaces": "6.0.0",
|
|
30
|
+
"@shipfox/config": "1.2.2",
|
|
31
|
+
"@shipfox/node-drizzle": "0.3.2",
|
|
32
|
+
"@shipfox/node-fastify": "0.2.4",
|
|
33
|
+
"@shipfox/node-opentelemetry": "0.5.2",
|
|
34
|
+
"@shipfox/node-postgres": "0.4.2",
|
|
35
|
+
"@shipfox/api-auth-context": "6.0.0"
|
|
43
36
|
},
|
|
44
37
|
"devDependencies": {
|
|
45
38
|
"@types/pg": "^8.15.5",
|
|
46
39
|
"drizzle-kit": "^0.31.10",
|
|
40
|
+
"fastify": "^5.3.3",
|
|
47
41
|
"fishery": "^2.4.0",
|
|
48
|
-
"@shipfox/
|
|
49
|
-
"@shipfox/
|
|
50
|
-
"@shipfox/
|
|
51
|
-
"@shipfox/
|
|
52
|
-
"@shipfox/
|
|
42
|
+
"@shipfox/biome": "1.8.2",
|
|
43
|
+
"@shipfox/depcruise": "1.0.2",
|
|
44
|
+
"@shipfox/swc": "1.2.6",
|
|
45
|
+
"@shipfox/ts-config": "1.3.8",
|
|
46
|
+
"@shipfox/typescript": "1.1.7",
|
|
47
|
+
"@shipfox/vitest": "1.2.3"
|
|
53
48
|
},
|
|
54
49
|
"scripts": {
|
|
55
50
|
"build": "shipfox-swc",
|
|
56
51
|
"check": "shipfox-biome-check",
|
|
57
52
|
"check:fix": "shipfox-biome-check --write",
|
|
53
|
+
"depcruise": "shipfox-depcruise",
|
|
58
54
|
"test": "shipfox-vitest-run",
|
|
59
55
|
"test:watch": "shipfox-vitest-watch",
|
|
60
56
|
"type": "shipfox-tsc-check",
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import {HTTPError} from 'ky';
|
|
2
|
+
import type {JiraIntegrationProviderError} from '#core/errors.js';
|
|
3
|
+
import {mapJiraError} from './client.js';
|
|
4
|
+
|
|
5
|
+
function rejectedRequest(status: number): () => Promise<never> {
|
|
6
|
+
return () =>
|
|
7
|
+
Promise.reject(
|
|
8
|
+
new HTTPError(
|
|
9
|
+
new Response(null, {status}),
|
|
10
|
+
new Request('https://jira.example.test'),
|
|
11
|
+
{} as never,
|
|
12
|
+
),
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('mapJiraError', () => {
|
|
17
|
+
it.each([
|
|
18
|
+
[401, 'access-denied'],
|
|
19
|
+
[403, 'access-denied'],
|
|
20
|
+
[400, 'malformed-provider-response'],
|
|
21
|
+
[404, 'malformed-provider-response'],
|
|
22
|
+
] as const)('maps HTTP %i to %s', async (status, reason) => {
|
|
23
|
+
const result = mapJiraError('test', rejectedRequest(status));
|
|
24
|
+
|
|
25
|
+
await expect(result).rejects.toMatchObject({
|
|
26
|
+
reason,
|
|
27
|
+
} satisfies Partial<JiraIntegrationProviderError>);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import {logger} from '@shipfox/node-opentelemetry';
|
|
2
|
+
import ky, {HTTPError, TimeoutError} from 'ky';
|
|
3
|
+
import {config} from '#config.js';
|
|
4
|
+
import {JiraIntegrationProviderError} from '#core/errors.js';
|
|
5
|
+
|
|
6
|
+
const JIRA_API_TIMEOUT_MS = 10_000;
|
|
7
|
+
const SCOPE_SEPARATOR_RE = /[,\s]+/;
|
|
8
|
+
|
|
9
|
+
export interface JiraAuthorization {
|
|
10
|
+
accessToken: string;
|
|
11
|
+
refreshToken?: string | undefined;
|
|
12
|
+
expiresAt?: Date | undefined;
|
|
13
|
+
scopes: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface JiraAccessibleResource {
|
|
17
|
+
cloudId: string;
|
|
18
|
+
name: string;
|
|
19
|
+
url: string;
|
|
20
|
+
scopes: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface JiraIdentity {
|
|
24
|
+
accountId: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface JiraApiClient {
|
|
28
|
+
exchangeAuthorizationCode(input: {code: string}): Promise<JiraAuthorization>;
|
|
29
|
+
refreshAccessToken(input: {refreshToken: string}): Promise<JiraAuthorization>;
|
|
30
|
+
getAccessibleResources(input: {accessToken: string}): Promise<JiraAccessibleResource[]>;
|
|
31
|
+
getMyself(input: {accessToken: string; cloudId: string}): Promise<JiraIdentity>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface JiraTokenResponse {
|
|
35
|
+
access_token?: unknown;
|
|
36
|
+
refresh_token?: unknown;
|
|
37
|
+
expires_in?: unknown;
|
|
38
|
+
scope?: unknown;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface JiraResourceResponse {
|
|
42
|
+
id?: unknown;
|
|
43
|
+
name?: unknown;
|
|
44
|
+
url?: unknown;
|
|
45
|
+
scopes?: unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface JiraMyselfResponse {
|
|
49
|
+
accountId?: unknown;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createJiraApiClient(): JiraApiClient {
|
|
53
|
+
return {
|
|
54
|
+
async exchangeAuthorizationCode(input) {
|
|
55
|
+
const body = await mapJiraError('exchange-authorization-code', () =>
|
|
56
|
+
ky
|
|
57
|
+
.post(`${config.JIRA_AUTH_BASE_URL}/oauth/token`, {
|
|
58
|
+
body: new URLSearchParams({
|
|
59
|
+
grant_type: 'authorization_code',
|
|
60
|
+
client_id: config.JIRA_OAUTH_CLIENT_ID,
|
|
61
|
+
client_secret: config.JIRA_OAUTH_CLIENT_SECRET,
|
|
62
|
+
code: input.code,
|
|
63
|
+
redirect_uri: config.JIRA_OAUTH_REDIRECT_URL,
|
|
64
|
+
}),
|
|
65
|
+
timeout: JIRA_API_TIMEOUT_MS,
|
|
66
|
+
})
|
|
67
|
+
.json<JiraTokenResponse>(),
|
|
68
|
+
);
|
|
69
|
+
return parseAuthorization(body);
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
async refreshAccessToken(input) {
|
|
73
|
+
const body = await mapJiraError('refresh-access-token', () =>
|
|
74
|
+
ky
|
|
75
|
+
.post(`${config.JIRA_AUTH_BASE_URL}/oauth/token`, {
|
|
76
|
+
body: new URLSearchParams({
|
|
77
|
+
grant_type: 'refresh_token',
|
|
78
|
+
client_id: config.JIRA_OAUTH_CLIENT_ID,
|
|
79
|
+
client_secret: config.JIRA_OAUTH_CLIENT_SECRET,
|
|
80
|
+
refresh_token: input.refreshToken,
|
|
81
|
+
}),
|
|
82
|
+
timeout: JIRA_API_TIMEOUT_MS,
|
|
83
|
+
})
|
|
84
|
+
.json<JiraTokenResponse>(),
|
|
85
|
+
);
|
|
86
|
+
return parseAuthorization(body);
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
async getAccessibleResources(input) {
|
|
90
|
+
const body = await mapJiraError('get-accessible-resources', () =>
|
|
91
|
+
ky
|
|
92
|
+
.get(`${config.JIRA_API_BASE_URL}/oauth/token/accessible-resources`, {
|
|
93
|
+
headers: {authorization: `Bearer ${input.accessToken}`},
|
|
94
|
+
timeout: JIRA_API_TIMEOUT_MS,
|
|
95
|
+
})
|
|
96
|
+
.json<unknown>(),
|
|
97
|
+
);
|
|
98
|
+
if (!Array.isArray(body)) {
|
|
99
|
+
throw malformed('Jira accessible-resources response was not an array');
|
|
100
|
+
}
|
|
101
|
+
return body.map(parseAccessibleResource);
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
async getMyself(input) {
|
|
105
|
+
const body = await mapJiraError('get-myself', () =>
|
|
106
|
+
ky
|
|
107
|
+
.get(`${config.JIRA_API_BASE_URL}/ex/jira/${input.cloudId}/rest/api/3/myself`, {
|
|
108
|
+
headers: {authorization: `Bearer ${input.accessToken}`},
|
|
109
|
+
timeout: JIRA_API_TIMEOUT_MS,
|
|
110
|
+
})
|
|
111
|
+
.json<JiraMyselfResponse>(),
|
|
112
|
+
);
|
|
113
|
+
if (typeof body.accountId !== 'string' || body.accountId.length === 0) {
|
|
114
|
+
throw malformed('Jira identity response did not include an accountId');
|
|
115
|
+
}
|
|
116
|
+
return {accountId: body.accountId};
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseAuthorization(body: JiraTokenResponse): JiraAuthorization {
|
|
122
|
+
if (typeof body.access_token !== 'string' || body.access_token.length === 0) {
|
|
123
|
+
throw malformed('Jira authorization response did not include an access token');
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
accessToken: body.access_token,
|
|
127
|
+
refreshToken: typeof body.refresh_token === 'string' ? body.refresh_token : undefined,
|
|
128
|
+
expiresAt: parseExpiresAt(body.expires_in),
|
|
129
|
+
scopes: parseScopes(body.scope),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function parseAccessibleResource(value: unknown): JiraAccessibleResource {
|
|
134
|
+
if (!value || typeof value !== 'object') throw malformed('Jira site response was malformed');
|
|
135
|
+
const {id, name, url, scopes} = value as JiraResourceResponse;
|
|
136
|
+
if (
|
|
137
|
+
typeof id !== 'string' ||
|
|
138
|
+
id.length === 0 ||
|
|
139
|
+
typeof name !== 'string' ||
|
|
140
|
+
name.length === 0 ||
|
|
141
|
+
typeof url !== 'string' ||
|
|
142
|
+
url.length === 0 ||
|
|
143
|
+
!Array.isArray(scopes) ||
|
|
144
|
+
!scopes.every((scope) => typeof scope === 'string')
|
|
145
|
+
) {
|
|
146
|
+
throw malformed('Jira site response did not include a valid cloud id, name, URL, and scopes');
|
|
147
|
+
}
|
|
148
|
+
return {cloudId: id, name, url, scopes};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function parseScopes(scope: unknown): string[] {
|
|
152
|
+
if (typeof scope === 'string')
|
|
153
|
+
return scope
|
|
154
|
+
.split(SCOPE_SEPARATOR_RE)
|
|
155
|
+
.map((value) => value.trim())
|
|
156
|
+
.filter(Boolean);
|
|
157
|
+
if (Array.isArray(scope) && scope.every((value) => typeof value === 'string')) return scope;
|
|
158
|
+
if (scope === undefined) return [];
|
|
159
|
+
throw malformed('Jira authorization response included malformed scopes');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function parseExpiresAt(expiresIn: unknown): Date | undefined {
|
|
163
|
+
if (expiresIn === undefined) return undefined;
|
|
164
|
+
if (typeof expiresIn !== 'number' || !Number.isFinite(expiresIn) || expiresIn <= 0) {
|
|
165
|
+
throw malformed('Jira authorization response included a malformed expiry');
|
|
166
|
+
}
|
|
167
|
+
return new Date(Date.now() + expiresIn * 1000);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function mapJiraError<T>(operation: string, request: () => Promise<T>): Promise<T> {
|
|
171
|
+
try {
|
|
172
|
+
return await request();
|
|
173
|
+
} catch (error) {
|
|
174
|
+
if (error instanceof JiraIntegrationProviderError) throw error;
|
|
175
|
+
if (error instanceof HTTPError) {
|
|
176
|
+
const {status, statusText, headers} = error.response;
|
|
177
|
+
logger().warn({operation, status, statusText}, 'Jira API request rejected');
|
|
178
|
+
if (status === 429) {
|
|
179
|
+
throw new JiraIntegrationProviderError(
|
|
180
|
+
'rate-limited',
|
|
181
|
+
'Jira request was rate limited',
|
|
182
|
+
retryAfterSeconds(headers),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if (status >= 500)
|
|
186
|
+
throw new JiraIntegrationProviderError('provider-unavailable', 'Jira request failed');
|
|
187
|
+
if (status === 401 || status === 403) {
|
|
188
|
+
throw new JiraIntegrationProviderError('access-denied', 'Jira request was rejected');
|
|
189
|
+
}
|
|
190
|
+
throw malformed('Jira request was rejected');
|
|
191
|
+
}
|
|
192
|
+
if (error instanceof TimeoutError) {
|
|
193
|
+
logger().warn({operation}, 'Jira API request timed out');
|
|
194
|
+
throw new JiraIntegrationProviderError('timeout', 'Jira request timed out');
|
|
195
|
+
}
|
|
196
|
+
logger().warn(
|
|
197
|
+
{operation, errName: error instanceof Error ? error.name : typeof error},
|
|
198
|
+
'Jira API request failed',
|
|
199
|
+
);
|
|
200
|
+
throw new JiraIntegrationProviderError('provider-unavailable', 'Jira request failed');
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function malformed(message: string): JiraIntegrationProviderError {
|
|
205
|
+
return new JiraIntegrationProviderError('malformed-provider-response', message);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function retryAfterSeconds(headers: Headers): number | undefined {
|
|
209
|
+
const value = headers.get('retry-after');
|
|
210
|
+
if (!value) return undefined;
|
|
211
|
+
const parsed = Number.parseInt(value, 10);
|
|
212
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
213
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
vi.mock('#db/installations.js', () => ({
|
|
2
|
+
deleteJiraInstallationByConnectionId: vi.fn().mockResolvedValue(true),
|
|
3
|
+
}));
|
|
4
|
+
|
|
5
|
+
import {disconnectJiraInstallation} from './disconnect.js';
|
|
6
|
+
|
|
7
|
+
describe('disconnectJiraInstallation', () => {
|
|
8
|
+
it('deletes secrets, installation, and connection when the connection exists', async () => {
|
|
9
|
+
const deleteSecrets = vi.fn().mockResolvedValue(1);
|
|
10
|
+
const deleteConnection = vi.fn().mockResolvedValue(true);
|
|
11
|
+
const transaction = vi.fn(async (fn) => await fn({}));
|
|
12
|
+
|
|
13
|
+
await disconnectJiraInstallation({
|
|
14
|
+
connectionId: crypto.randomUUID(),
|
|
15
|
+
getConnection: vi.fn().mockResolvedValue({workspaceId: crypto.randomUUID()}),
|
|
16
|
+
deleteSecrets,
|
|
17
|
+
transaction,
|
|
18
|
+
deleteConnection,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
expect(deleteSecrets).toHaveBeenCalledOnce();
|
|
22
|
+
expect(deleteConnection).toHaveBeenCalledOnce();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import {deleteJiraInstallationByConnectionId} from '#db/installations.js';
|
|
2
|
+
import {jiraSecretsNamespace} from './tokens.js';
|
|
3
|
+
|
|
4
|
+
export interface DisconnectJiraInstallationParams<Tx = unknown> {
|
|
5
|
+
connectionId: string;
|
|
6
|
+
getConnection(connectionId: string): Promise<{workspaceId: string} | undefined>;
|
|
7
|
+
deleteSecrets(params: {workspaceId: string; namespace: string}): Promise<number>;
|
|
8
|
+
transaction<T>(fn: (tx: Tx) => Promise<T>): Promise<T>;
|
|
9
|
+
deleteConnection(params: {connectionId: string}, options: {tx: Tx}): Promise<boolean>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function disconnectJiraInstallation<Tx = unknown>(
|
|
13
|
+
params: DisconnectJiraInstallationParams<Tx>,
|
|
14
|
+
): Promise<void> {
|
|
15
|
+
const connection = await params.getConnection(params.connectionId);
|
|
16
|
+
if (connection)
|
|
17
|
+
await params.deleteSecrets({
|
|
18
|
+
workspaceId: connection.workspaceId,
|
|
19
|
+
namespace: jiraSecretsNamespace(params.connectionId),
|
|
20
|
+
});
|
|
21
|
+
await params.transaction(async (tx) => {
|
|
22
|
+
await deleteJiraInstallationByConnectionId(params.connectionId, {tx});
|
|
23
|
+
await params.deleteConnection({connectionId: params.connectionId}, {tx});
|
|
24
|
+
});
|
|
25
|
+
}
|
package/src/core/errors.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {IntegrationProviderError} from '@shipfox/api-integration-core-dto';
|
|
2
|
+
|
|
1
3
|
export class JiraConnectionNotFoundError extends Error {
|
|
2
4
|
constructor(connectionId: string) {
|
|
3
5
|
super(`Jira integration connection was not found: ${connectionId}`);
|
|
@@ -20,3 +22,80 @@ export class JiraInstallationSiteMismatchError extends Error {
|
|
|
20
22
|
this.name = 'JiraInstallationSiteMismatchError';
|
|
21
23
|
}
|
|
22
24
|
}
|
|
25
|
+
|
|
26
|
+
export class JiraIntegrationProviderError extends IntegrationProviderError {}
|
|
27
|
+
|
|
28
|
+
export class JiraInstallStateError extends Error {
|
|
29
|
+
constructor(message = 'Invalid Jira install state') {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = 'JiraInstallStateError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class JiraInstallStateActorMismatchError extends Error {
|
|
36
|
+
constructor() {
|
|
37
|
+
super('Jira install state was created by a different user');
|
|
38
|
+
this.name = 'JiraInstallStateActorMismatchError';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class JiraOAuthCallbackError extends Error {
|
|
43
|
+
constructor(
|
|
44
|
+
public readonly providerError: string,
|
|
45
|
+
public readonly providerDescription: string | undefined,
|
|
46
|
+
) {
|
|
47
|
+
super(providerDescription ?? `Jira OAuth callback failed: ${providerError}`);
|
|
48
|
+
this.name = 'JiraOAuthCallbackError';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class JiraAuthorizationScopeMismatchError extends Error {
|
|
53
|
+
constructor(public readonly missingScopes: string[]) {
|
|
54
|
+
super(`Jira authorization is missing usable site scopes: ${missingScopes.join(', ')}`);
|
|
55
|
+
this.name = 'JiraAuthorizationScopeMismatchError';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class JiraOfflineAccessNotGrantedError extends Error {
|
|
60
|
+
constructor() {
|
|
61
|
+
super(
|
|
62
|
+
'Jira authorization did not grant offline access; reconnect and approve the requested scopes',
|
|
63
|
+
);
|
|
64
|
+
this.name = 'JiraOfflineAccessNotGrantedError';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class JiraInstallationAlreadyLinkedError extends Error {
|
|
69
|
+
constructor(cloudId: string) {
|
|
70
|
+
super(`Jira site is already linked to another Shipfox workspace: ${cloudId}`);
|
|
71
|
+
this.name = 'JiraInstallationAlreadyLinkedError';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class JiraConnectionAlreadyLinkedError extends Error {
|
|
76
|
+
constructor(connectionId: string) {
|
|
77
|
+
super(`Integration connection is already linked to another Jira site: ${connectionId}`);
|
|
78
|
+
this.name = 'JiraConnectionAlreadyLinkedError';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class JiraTokenUnrefreshableError extends Error {
|
|
83
|
+
constructor(public readonly connectionId: string) {
|
|
84
|
+
super(`Jira token cannot be refreshed; reconnect is required: ${connectionId}`);
|
|
85
|
+
this.name = 'JiraTokenUnrefreshableError';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export class JiraSiteSelectionMismatchError extends Error {
|
|
90
|
+
constructor(cloudId: string) {
|
|
91
|
+
super(`Jira site was not included in the authorized site selection: ${cloudId}`);
|
|
92
|
+
this.name = 'JiraSiteSelectionMismatchError';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export class JiraPendingSelectionNotFoundError extends Error {
|
|
97
|
+
constructor() {
|
|
98
|
+
super('The Jira site selection expired or has already been completed; start over');
|
|
99
|
+
this.name = 'JiraPendingSelectionNotFoundError';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import {
|
|
2
|
+
JiraOfflineAccessNotGrantedError,
|
|
3
|
+
JiraPendingSelectionNotFoundError,
|
|
4
|
+
JiraSiteSelectionMismatchError,
|
|
5
|
+
} from './errors.js';
|
|
6
|
+
import {handleJiraCallback, handleJiraSiteSelection} from './install.js';
|
|
7
|
+
import {signJiraInstallState} from './state.js';
|
|
8
|
+
|
|
9
|
+
function createParams() {
|
|
10
|
+
const workspaceId = crypto.randomUUID();
|
|
11
|
+
const state = signJiraInstallState({workspaceId, userId: 'user-1'});
|
|
12
|
+
const jira = {
|
|
13
|
+
exchangeAuthorizationCode: vi.fn().mockResolvedValue({
|
|
14
|
+
accessToken: 'access',
|
|
15
|
+
refreshToken: 'refresh',
|
|
16
|
+
expiresAt: new Date(),
|
|
17
|
+
scopes: [],
|
|
18
|
+
}),
|
|
19
|
+
getAccessibleResources: vi.fn(),
|
|
20
|
+
getMyself: vi.fn().mockResolvedValue({accountId: 'account-1'}),
|
|
21
|
+
refreshAccessToken: vi.fn(),
|
|
22
|
+
};
|
|
23
|
+
const tokenStore = {storeTokens: vi.fn().mockResolvedValue(undefined)};
|
|
24
|
+
const pendingStore = {
|
|
25
|
+
save: vi.fn().mockResolvedValue(undefined),
|
|
26
|
+
load: vi.fn(),
|
|
27
|
+
clear: vi.fn().mockResolvedValue(undefined),
|
|
28
|
+
};
|
|
29
|
+
const connectJiraInstallation = vi
|
|
30
|
+
.fn()
|
|
31
|
+
.mockResolvedValue({id: 'connection-1', workspaceId, provider: 'jira'});
|
|
32
|
+
return {
|
|
33
|
+
workspaceId,
|
|
34
|
+
state,
|
|
35
|
+
jira,
|
|
36
|
+
tokenStore,
|
|
37
|
+
pendingStore,
|
|
38
|
+
connectJiraInstallation,
|
|
39
|
+
code: 'code',
|
|
40
|
+
sessionUserId: 'user-1',
|
|
41
|
+
sessionMemberships: [],
|
|
42
|
+
requireWorkspaceMembership: vi.fn().mockResolvedValue(undefined),
|
|
43
|
+
getExistingJiraConnection: vi.fn().mockResolvedValue(undefined),
|
|
44
|
+
disconnectJiraInstallation: vi.fn().mockResolvedValue(undefined),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('Jira OAuth installation', () => {
|
|
49
|
+
it('connects a single granted site and stores its tokens', async () => {
|
|
50
|
+
const params = createParams();
|
|
51
|
+
params.jira.getAccessibleResources.mockResolvedValue([
|
|
52
|
+
{
|
|
53
|
+
cloudId: 'cloud-1',
|
|
54
|
+
name: 'Acme',
|
|
55
|
+
url: 'https://acme.atlassian.net',
|
|
56
|
+
scopes: ['read:jira-work'],
|
|
57
|
+
},
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
const result = await handleJiraCallback(params);
|
|
61
|
+
|
|
62
|
+
expect(result).toMatchObject({id: 'connection-1'});
|
|
63
|
+
expect(params.connectJiraInstallation).toHaveBeenCalledWith(
|
|
64
|
+
expect.objectContaining({cloudId: 'cloud-1'}),
|
|
65
|
+
);
|
|
66
|
+
expect(params.tokenStore.storeTokens).toHaveBeenCalledWith(
|
|
67
|
+
expect.objectContaining({connectionId: 'connection-1', refreshToken: 'refresh'}),
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('stores a multi-site grant until the selected site completes', async () => {
|
|
72
|
+
const params = createParams();
|
|
73
|
+
const sites = [
|
|
74
|
+
{
|
|
75
|
+
cloudId: 'cloud-1',
|
|
76
|
+
name: 'Acme',
|
|
77
|
+
url: 'https://acme.atlassian.net',
|
|
78
|
+
scopes: ['read:jira-work'],
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
cloudId: 'cloud-2',
|
|
82
|
+
name: 'Beta',
|
|
83
|
+
url: 'https://beta.atlassian.net',
|
|
84
|
+
scopes: ['read:issue:jira'],
|
|
85
|
+
},
|
|
86
|
+
];
|
|
87
|
+
params.jira.getAccessibleResources.mockResolvedValue(sites);
|
|
88
|
+
|
|
89
|
+
const callback = await handleJiraCallback(params);
|
|
90
|
+
params.pendingStore.load.mockResolvedValue({
|
|
91
|
+
authorization: {accessToken: 'access', refreshToken: 'refresh', scopes: []},
|
|
92
|
+
sites,
|
|
93
|
+
});
|
|
94
|
+
const completed = await handleJiraSiteSelection({...params, cloudId: 'cloud-2'});
|
|
95
|
+
|
|
96
|
+
expect(callback).toEqual({sites});
|
|
97
|
+
expect(params.pendingStore.save).toHaveBeenCalled();
|
|
98
|
+
expect(completed).toMatchObject({id: 'connection-1'});
|
|
99
|
+
expect(params.pendingStore.clear).toHaveBeenCalledWith({
|
|
100
|
+
workspaceId: params.workspaceId,
|
|
101
|
+
state: params.state,
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('rejects missing refresh tokens and invalid pending selections', async () => {
|
|
106
|
+
const missingRefresh = createParams();
|
|
107
|
+
missingRefresh.jira.exchangeAuthorizationCode.mockResolvedValue({
|
|
108
|
+
accessToken: 'access',
|
|
109
|
+
scopes: [],
|
|
110
|
+
});
|
|
111
|
+
const missingPending = createParams();
|
|
112
|
+
missingPending.pendingStore.load.mockResolvedValue(undefined);
|
|
113
|
+
const mismatchedSite = createParams();
|
|
114
|
+
mismatchedSite.pendingStore.load.mockResolvedValue({
|
|
115
|
+
authorization: {accessToken: 'access', refreshToken: 'refresh', scopes: []},
|
|
116
|
+
sites: [],
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await expect(handleJiraCallback(missingRefresh)).rejects.toBeInstanceOf(
|
|
120
|
+
JiraOfflineAccessNotGrantedError,
|
|
121
|
+
);
|
|
122
|
+
await expect(
|
|
123
|
+
handleJiraSiteSelection({...missingPending, cloudId: 'cloud-1'}),
|
|
124
|
+
).rejects.toBeInstanceOf(JiraPendingSelectionNotFoundError);
|
|
125
|
+
await expect(
|
|
126
|
+
handleJiraSiteSelection({...mismatchedSite, cloudId: 'cloud-1'}),
|
|
127
|
+
).rejects.toBeInstanceOf(JiraSiteSelectionMismatchError);
|
|
128
|
+
});
|
|
129
|
+
});
|