@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
package/src/core/tokens.ts
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {createJiraApiClient, type JiraApiClient} from '#api/client.js';
|
|
2
|
+
import {
|
|
3
|
+
JiraAccessTokenMissingError,
|
|
4
|
+
JiraConnectionNotFoundError,
|
|
5
|
+
JiraIntegrationProviderError,
|
|
6
|
+
JiraTokenUnrefreshableError,
|
|
7
|
+
} from '#core/errors.js';
|
|
8
|
+
import {
|
|
9
|
+
getJiraInstallationByConnectionId,
|
|
10
|
+
updateJiraInstallationTokenExpiry,
|
|
11
|
+
withJiraRefreshLock,
|
|
12
|
+
} from '#db/installations.js';
|
|
2
13
|
|
|
3
14
|
const ACCESS_TOKEN_KEY = 'ACCESS_TOKEN';
|
|
4
15
|
const REFRESH_TOKEN_KEY = 'REFRESH_TOKEN';
|
|
16
|
+
const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000;
|
|
17
|
+
const tokenRefreshes = new Map<string, Promise<string>>();
|
|
5
18
|
|
|
6
19
|
export interface JiraConnectionResolverResult {
|
|
7
20
|
workspaceId: string;
|
|
@@ -20,6 +33,8 @@ export interface JiraSecretsStore {
|
|
|
20
33
|
export interface CreateJiraTokenStoreParams {
|
|
21
34
|
resolveConnection(connectionId: string): Promise<JiraConnectionResolverResult | undefined>;
|
|
22
35
|
secrets: JiraSecretsStore;
|
|
36
|
+
client?: JiraApiClient | undefined;
|
|
37
|
+
markConnectionError?: (params: {connectionId: string}) => Promise<void>;
|
|
23
38
|
}
|
|
24
39
|
|
|
25
40
|
export interface StoreJiraTokensParams {
|
|
@@ -31,6 +46,7 @@ export interface StoreJiraTokensParams {
|
|
|
31
46
|
|
|
32
47
|
export interface GetJiraAccessTokenParams {
|
|
33
48
|
connectionId: string;
|
|
49
|
+
forceRefresh?: boolean | undefined;
|
|
34
50
|
}
|
|
35
51
|
|
|
36
52
|
export interface JiraTokenStore {
|
|
@@ -43,18 +59,28 @@ export function jiraSecretsNamespace(connectionId: string): string {
|
|
|
43
59
|
}
|
|
44
60
|
|
|
45
61
|
export function createJiraTokenStore(params: CreateJiraTokenStoreParams): JiraTokenStore {
|
|
62
|
+
const client = params.client ?? createJiraApiClient();
|
|
46
63
|
async function resolveWorkspaceId(connectionId: string): Promise<string> {
|
|
47
64
|
const connection = await params.resolveConnection(connectionId);
|
|
48
65
|
if (!connection) throw new JiraConnectionNotFoundError(connectionId);
|
|
49
66
|
return connection.workspaceId;
|
|
50
67
|
}
|
|
68
|
+
const readSecretToken = (
|
|
69
|
+
workspaceId: string,
|
|
70
|
+
connectionId: string,
|
|
71
|
+
key: typeof ACCESS_TOKEN_KEY | typeof REFRESH_TOKEN_KEY,
|
|
72
|
+
) => params.secrets.getSecret({workspaceId, namespace: jiraSecretsNamespace(connectionId), key});
|
|
73
|
+
async function readAccessToken(workspaceId: string, connectionId: string): Promise<string> {
|
|
74
|
+
const token = await readSecretToken(workspaceId, connectionId, ACCESS_TOKEN_KEY);
|
|
75
|
+
if (!token) throw new JiraAccessTokenMissingError(connectionId);
|
|
76
|
+
return token;
|
|
77
|
+
}
|
|
51
78
|
|
|
52
79
|
return {
|
|
53
80
|
async storeTokens(input) {
|
|
54
81
|
const workspaceId = await resolveWorkspaceId(input.connectionId);
|
|
55
82
|
const values: Record<string, string> = {[ACCESS_TOKEN_KEY]: input.accessToken};
|
|
56
83
|
if (input.refreshToken) values[REFRESH_TOKEN_KEY] = input.refreshToken;
|
|
57
|
-
|
|
58
84
|
await params.secrets.setSecrets({
|
|
59
85
|
workspaceId,
|
|
60
86
|
namespace: jiraSecretsNamespace(input.connectionId),
|
|
@@ -62,16 +88,127 @@ export function createJiraTokenStore(params: CreateJiraTokenStoreParams): JiraTo
|
|
|
62
88
|
editedBy: input.editedBy,
|
|
63
89
|
});
|
|
64
90
|
},
|
|
65
|
-
|
|
66
91
|
async getAccessToken(input) {
|
|
67
92
|
const workspaceId = await resolveWorkspaceId(input.connectionId);
|
|
68
|
-
const
|
|
93
|
+
const accessToken = await readAccessToken(workspaceId, input.connectionId);
|
|
94
|
+
if (
|
|
95
|
+
!input.forceRefresh &&
|
|
96
|
+
!shouldRefresh(
|
|
97
|
+
(await getJiraInstallationByConnectionId(input.connectionId))?.tokenExpiresAt ?? null,
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
return accessToken;
|
|
101
|
+
const inFlight = tokenRefreshes.get(input.connectionId);
|
|
102
|
+
if (inFlight) return inFlight;
|
|
103
|
+
const refresh = refreshAccessTokenWithLock({
|
|
104
|
+
connectionId: input.connectionId,
|
|
69
105
|
workspaceId,
|
|
70
|
-
|
|
71
|
-
|
|
106
|
+
originalAccessToken: accessToken,
|
|
107
|
+
forceRefresh: input.forceRefresh === true,
|
|
108
|
+
client,
|
|
109
|
+
secrets: params.secrets,
|
|
110
|
+
readAccessToken,
|
|
111
|
+
readSecretToken,
|
|
112
|
+
markConnectionError: params.markConnectionError,
|
|
72
113
|
});
|
|
73
|
-
|
|
74
|
-
|
|
114
|
+
tokenRefreshes.set(input.connectionId, refresh);
|
|
115
|
+
void refresh.then(
|
|
116
|
+
() => clearTokenRefresh(input.connectionId, refresh),
|
|
117
|
+
() => clearTokenRefresh(input.connectionId, refresh),
|
|
118
|
+
);
|
|
119
|
+
return refresh;
|
|
75
120
|
},
|
|
76
121
|
};
|
|
77
122
|
}
|
|
123
|
+
|
|
124
|
+
function shouldRefresh(expiresAt: Date | null): boolean {
|
|
125
|
+
return expiresAt !== null && expiresAt.getTime() <= Date.now() + TOKEN_REFRESH_MARGIN_MS;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function clearTokenRefresh(connectionId: string, refresh: Promise<string>): void {
|
|
129
|
+
if (tokenRefreshes.get(connectionId) === refresh) tokenRefreshes.delete(connectionId);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function refreshAccessTokenWithLock(params: {
|
|
133
|
+
connectionId: string;
|
|
134
|
+
workspaceId: string;
|
|
135
|
+
originalAccessToken: string;
|
|
136
|
+
forceRefresh: boolean;
|
|
137
|
+
client: JiraApiClient;
|
|
138
|
+
secrets: JiraSecretsStore;
|
|
139
|
+
readAccessToken(workspaceId: string, connectionId: string): Promise<string>;
|
|
140
|
+
readSecretToken(
|
|
141
|
+
workspaceId: string,
|
|
142
|
+
connectionId: string,
|
|
143
|
+
key: typeof ACCESS_TOKEN_KEY | typeof REFRESH_TOKEN_KEY,
|
|
144
|
+
): Promise<string | null>;
|
|
145
|
+
markConnectionError?: ((params: {connectionId: string}) => Promise<void>) | undefined;
|
|
146
|
+
}): Promise<string> {
|
|
147
|
+
const lock = await withJiraRefreshLock(params.connectionId, () =>
|
|
148
|
+
refreshAccessTokenForConnection(params),
|
|
149
|
+
);
|
|
150
|
+
if (lock.acquired) return lock.value;
|
|
151
|
+
const reread = await params.readAccessToken(params.workspaceId, params.connectionId);
|
|
152
|
+
if (reread !== params.originalAccessToken) return reread;
|
|
153
|
+
throw new JiraIntegrationProviderError(
|
|
154
|
+
'provider-unavailable',
|
|
155
|
+
'Jira token refresh is already in progress',
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function refreshAccessTokenForConnection(params: {
|
|
160
|
+
connectionId: string;
|
|
161
|
+
workspaceId: string;
|
|
162
|
+
originalAccessToken: string;
|
|
163
|
+
forceRefresh: boolean;
|
|
164
|
+
client: JiraApiClient;
|
|
165
|
+
secrets: JiraSecretsStore;
|
|
166
|
+
readAccessToken(workspaceId: string, connectionId: string): Promise<string>;
|
|
167
|
+
readSecretToken(
|
|
168
|
+
workspaceId: string,
|
|
169
|
+
connectionId: string,
|
|
170
|
+
key: typeof ACCESS_TOKEN_KEY | typeof REFRESH_TOKEN_KEY,
|
|
171
|
+
): Promise<string | null>;
|
|
172
|
+
markConnectionError?: ((params: {connectionId: string}) => Promise<void>) | undefined;
|
|
173
|
+
}): Promise<string> {
|
|
174
|
+
const current = await params.readAccessToken(params.workspaceId, params.connectionId);
|
|
175
|
+
const installation = await getJiraInstallationByConnectionId(params.connectionId);
|
|
176
|
+
if (
|
|
177
|
+
current !== params.originalAccessToken ||
|
|
178
|
+
(!params.forceRefresh && !shouldRefresh(installation?.tokenExpiresAt ?? null))
|
|
179
|
+
)
|
|
180
|
+
return current;
|
|
181
|
+
const refreshToken = await params.readSecretToken(
|
|
182
|
+
params.workspaceId,
|
|
183
|
+
params.connectionId,
|
|
184
|
+
REFRESH_TOKEN_KEY,
|
|
185
|
+
);
|
|
186
|
+
if (!refreshToken) {
|
|
187
|
+
if (params.forceRefresh || shouldRefresh(installation?.tokenExpiresAt ?? null))
|
|
188
|
+
throw new JiraTokenUnrefreshableError(params.connectionId);
|
|
189
|
+
return current;
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
const refreshed = await params.client.refreshAccessToken({refreshToken});
|
|
193
|
+
if (!refreshed.refreshToken) throw new JiraTokenUnrefreshableError(params.connectionId);
|
|
194
|
+
await params.secrets.setSecrets({
|
|
195
|
+
workspaceId: params.workspaceId,
|
|
196
|
+
namespace: jiraSecretsNamespace(params.connectionId),
|
|
197
|
+
values: {
|
|
198
|
+
[ACCESS_TOKEN_KEY]: refreshed.accessToken,
|
|
199
|
+
[REFRESH_TOKEN_KEY]: refreshed.refreshToken,
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
await updateJiraInstallationTokenExpiry({
|
|
203
|
+
connectionId: params.connectionId,
|
|
204
|
+
tokenExpiresAt: refreshed.expiresAt ?? null,
|
|
205
|
+
scopes: refreshed.scopes.length > 0 ? refreshed.scopes : undefined,
|
|
206
|
+
});
|
|
207
|
+
return refreshed.accessToken;
|
|
208
|
+
} catch (error) {
|
|
209
|
+
if (error instanceof JiraIntegrationProviderError && error.reason === 'access-denied') {
|
|
210
|
+
await params.markConnectionError?.({connectionId: params.connectionId});
|
|
211
|
+
}
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
}
|
package/src/db/db.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import {drizzle, type NodePgDatabase} from '@shipfox/node-drizzle';
|
|
2
2
|
import {pgClient} from '@shipfox/node-postgres';
|
|
3
3
|
import {jiraInstallations} from './schema/installations.js';
|
|
4
|
+
import {jiraPendingSelections} from './schema/pending-selections.js';
|
|
4
5
|
|
|
5
|
-
export const schema = {jiraInstallations};
|
|
6
|
+
export const schema = {jiraInstallations, jiraPendingSelections};
|
|
6
7
|
|
|
7
8
|
let database: NodePgDatabase<typeof schema> | undefined;
|
|
8
9
|
|
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
import {JiraInstallationSiteMismatchError} from '#core/errors.js';
|
|
2
1
|
import {
|
|
2
|
+
JiraInstallationAlreadyLinkedError,
|
|
3
|
+
JiraInstallationSiteMismatchError,
|
|
4
|
+
} from '#core/errors.js';
|
|
5
|
+
import {
|
|
6
|
+
deleteJiraInstallationByConnectionId,
|
|
7
|
+
getJiraInstallationByCloudId,
|
|
3
8
|
getJiraInstallationByConnectionId,
|
|
4
9
|
getJiraInstallationByWebhookId,
|
|
5
10
|
markJiraInstallationRevoked,
|
|
11
|
+
updateJiraInstallationTokenExpiry,
|
|
6
12
|
upsertJiraInstallation,
|
|
7
13
|
} from './installations.js';
|
|
8
14
|
|
|
@@ -69,6 +75,36 @@ describe('jira installations', () => {
|
|
|
69
75
|
});
|
|
70
76
|
});
|
|
71
77
|
|
|
78
|
+
it('refuses to link the same Jira site to a second connection', async () => {
|
|
79
|
+
const first = createInstallationInput();
|
|
80
|
+
await upsertJiraInstallation(first);
|
|
81
|
+
const second = createInstallationInput({cloudId: first.cloudId});
|
|
82
|
+
|
|
83
|
+
const result = upsertJiraInstallation(second);
|
|
84
|
+
|
|
85
|
+
await expect(result).rejects.toBeInstanceOf(JiraInstallationAlreadyLinkedError);
|
|
86
|
+
await expect(getJiraInstallationByCloudId(first.cloudId)).resolves.toMatchObject({
|
|
87
|
+
connectionId: first.connectionId,
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('updates token metadata and deletes an installation', async () => {
|
|
92
|
+
const input = createInstallationInput();
|
|
93
|
+
await upsertJiraInstallation(input);
|
|
94
|
+
const tokenExpiresAt = new Date('2030-01-01T00:00:00.000Z');
|
|
95
|
+
|
|
96
|
+
const updated = await updateJiraInstallationTokenExpiry({
|
|
97
|
+
connectionId: input.connectionId,
|
|
98
|
+
tokenExpiresAt,
|
|
99
|
+
scopes: ['read:jira-work', 'write:jira-work'],
|
|
100
|
+
});
|
|
101
|
+
const deleted = await deleteJiraInstallationByConnectionId(input.connectionId);
|
|
102
|
+
|
|
103
|
+
expect(updated).toMatchObject({tokenExpiresAt, scopes: ['read:jira-work', 'write:jira-work']});
|
|
104
|
+
expect(deleted).toBe(true);
|
|
105
|
+
await expect(getJiraInstallationByConnectionId(input.connectionId)).resolves.toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
|
|
72
108
|
it('returns undefined for unknown connection and webhook ids', async () => {
|
|
73
109
|
const connectionId = crypto.randomUUID();
|
|
74
110
|
const webhookId = Number.MAX_SAFE_INTEGER;
|
package/src/db/installations.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
+
import {isUniqueViolation} from '@shipfox/node-drizzle';
|
|
2
|
+
import {pgClient} from '@shipfox/node-postgres';
|
|
1
3
|
import {eq, sql} from 'drizzle-orm';
|
|
2
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
JiraConnectionAlreadyLinkedError,
|
|
6
|
+
JiraInstallationAlreadyLinkedError,
|
|
7
|
+
JiraInstallationSiteMismatchError,
|
|
8
|
+
} from '#core/errors.js';
|
|
3
9
|
import {db} from './db.js';
|
|
4
10
|
import {jiraInstallations, toJiraInstallation} from './schema/installations.js';
|
|
5
11
|
|
|
@@ -34,6 +40,12 @@ export interface UpsertJiraInstallationParams {
|
|
|
34
40
|
tokenExpiresAt?: Date | null | undefined;
|
|
35
41
|
}
|
|
36
42
|
|
|
43
|
+
export interface UpdateJiraInstallationTokenExpiryParams {
|
|
44
|
+
connectionId: string;
|
|
45
|
+
tokenExpiresAt: Date | null;
|
|
46
|
+
scopes?: string[] | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
37
49
|
type JiraDb = ReturnType<typeof db>;
|
|
38
50
|
type JiraTx = Parameters<Parameters<JiraDb['transaction']>[0]>[0];
|
|
39
51
|
|
|
@@ -44,24 +56,12 @@ export async function upsertJiraInstallation(
|
|
|
44
56
|
const executor = (options.tx ?? db()) as JiraDb | JiraTx;
|
|
45
57
|
const now = new Date();
|
|
46
58
|
const webhookIds = params.webhookIds ?? [];
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
siteName: params.siteName,
|
|
54
|
-
authorizingAccountId: params.authorizingAccountId,
|
|
55
|
-
scopes: params.scopes,
|
|
56
|
-
webhookIds,
|
|
57
|
-
webhookExpiresAt: params.webhookExpiresAt ?? null,
|
|
58
|
-
status: params.status,
|
|
59
|
-
tokenExpiresAt: params.tokenExpiresAt ?? null,
|
|
60
|
-
})
|
|
61
|
-
.onConflictDoUpdate({
|
|
62
|
-
target: jiraInstallations.connectionId,
|
|
63
|
-
setWhere: eq(jiraInstallations.cloudId, params.cloudId),
|
|
64
|
-
set: {
|
|
59
|
+
let row: typeof jiraInstallations.$inferSelect | undefined;
|
|
60
|
+
try {
|
|
61
|
+
[row] = await executor
|
|
62
|
+
.insert(jiraInstallations)
|
|
63
|
+
.values({
|
|
64
|
+
connectionId: params.connectionId,
|
|
65
65
|
cloudId: params.cloudId,
|
|
66
66
|
siteUrl: params.siteUrl,
|
|
67
67
|
siteName: params.siteName,
|
|
@@ -71,15 +71,112 @@ export async function upsertJiraInstallation(
|
|
|
71
71
|
webhookExpiresAt: params.webhookExpiresAt ?? null,
|
|
72
72
|
status: params.status,
|
|
73
73
|
tokenExpiresAt: params.tokenExpiresAt ?? null,
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
})
|
|
75
|
+
.onConflictDoUpdate({
|
|
76
|
+
target: jiraInstallations.connectionId,
|
|
77
|
+
setWhere: eq(jiraInstallations.cloudId, params.cloudId),
|
|
78
|
+
set: {
|
|
79
|
+
cloudId: params.cloudId,
|
|
80
|
+
siteUrl: params.siteUrl,
|
|
81
|
+
siteName: params.siteName,
|
|
82
|
+
authorizingAccountId: params.authorizingAccountId,
|
|
83
|
+
scopes: params.scopes,
|
|
84
|
+
webhookIds,
|
|
85
|
+
webhookExpiresAt: params.webhookExpiresAt ?? null,
|
|
86
|
+
status: params.status,
|
|
87
|
+
tokenExpiresAt: params.tokenExpiresAt ?? null,
|
|
88
|
+
updatedAt: now,
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
.returning();
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (isUniqueViolation(error, 'integrations_jira_installations_connection_unique')) {
|
|
94
|
+
throw new JiraConnectionAlreadyLinkedError(params.connectionId);
|
|
95
|
+
}
|
|
96
|
+
if (isUniqueViolation(error, 'integrations_jira_installations_cloud_id_unique')) {
|
|
97
|
+
throw new JiraInstallationAlreadyLinkedError(params.cloudId);
|
|
98
|
+
}
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
78
101
|
|
|
79
102
|
if (!row) throw new JiraInstallationSiteMismatchError(params.connectionId, params.cloudId);
|
|
80
103
|
return toJiraInstallation(row);
|
|
81
104
|
}
|
|
82
105
|
|
|
106
|
+
export async function getJiraInstallationByCloudId(
|
|
107
|
+
cloudId: string,
|
|
108
|
+
options: {tx?: unknown} = {},
|
|
109
|
+
): Promise<JiraInstallation | undefined> {
|
|
110
|
+
const executor = (options.tx ?? db()) as JiraDb | JiraTx;
|
|
111
|
+
const rows = await executor
|
|
112
|
+
.select()
|
|
113
|
+
.from(jiraInstallations)
|
|
114
|
+
.where(eq(jiraInstallations.cloudId, cloudId))
|
|
115
|
+
.limit(1);
|
|
116
|
+
const row = rows[0];
|
|
117
|
+
return row ? toJiraInstallation(row) : undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function updateJiraInstallationTokenExpiry(
|
|
121
|
+
params: UpdateJiraInstallationTokenExpiryParams,
|
|
122
|
+
options: {tx?: unknown} = {},
|
|
123
|
+
): Promise<JiraInstallation | undefined> {
|
|
124
|
+
const executor = (options.tx ?? db()) as JiraDb | JiraTx;
|
|
125
|
+
const [row] = await executor
|
|
126
|
+
.update(jiraInstallations)
|
|
127
|
+
.set({
|
|
128
|
+
tokenExpiresAt: params.tokenExpiresAt,
|
|
129
|
+
...(params.scopes === undefined ? {} : {scopes: params.scopes}),
|
|
130
|
+
updatedAt: new Date(),
|
|
131
|
+
})
|
|
132
|
+
.where(eq(jiraInstallations.connectionId, params.connectionId))
|
|
133
|
+
.returning();
|
|
134
|
+
return row ? toJiraInstallation(row) : undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function deleteJiraInstallationByConnectionId(
|
|
138
|
+
connectionId: string,
|
|
139
|
+
options: {tx?: unknown} = {},
|
|
140
|
+
): Promise<boolean> {
|
|
141
|
+
const executor = (options.tx ?? db()) as JiraDb | JiraTx;
|
|
142
|
+
const result = await executor
|
|
143
|
+
.delete(jiraInstallations)
|
|
144
|
+
.where(eq(jiraInstallations.connectionId, connectionId));
|
|
145
|
+
return (result.rowCount ?? 0) > 0;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export type JiraRefreshLockResult<T> = {acquired: true; value: T} | {acquired: false};
|
|
149
|
+
|
|
150
|
+
export function withJiraRefreshLock<T>(
|
|
151
|
+
connectionId: string,
|
|
152
|
+
fn: () => Promise<T>,
|
|
153
|
+
): Promise<JiraRefreshLockResult<T>> {
|
|
154
|
+
return withJiraRefreshLockClient(connectionId, fn);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function withJiraRefreshLockClient<T>(
|
|
158
|
+
connectionId: string,
|
|
159
|
+
fn: () => Promise<T>,
|
|
160
|
+
): Promise<JiraRefreshLockResult<T>> {
|
|
161
|
+
const client = await pgClient().connect();
|
|
162
|
+
let acquired = false;
|
|
163
|
+
try {
|
|
164
|
+
const lock = await client.query<{acquired: boolean}>(
|
|
165
|
+
'SELECT pg_try_advisory_lock(hashtext($1)) AS acquired',
|
|
166
|
+
[connectionId],
|
|
167
|
+
);
|
|
168
|
+
acquired = lock.rows[0]?.acquired === true;
|
|
169
|
+
if (!acquired) return {acquired: false};
|
|
170
|
+
return {acquired: true, value: await fn()};
|
|
171
|
+
} finally {
|
|
172
|
+
try {
|
|
173
|
+
if (acquired) await client.query('SELECT pg_advisory_unlock(hashtext($1))', [connectionId]);
|
|
174
|
+
} finally {
|
|
175
|
+
client.release();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
83
180
|
export async function getJiraInstallationByConnectionId(
|
|
84
181
|
connectionId: string,
|
|
85
182
|
options: {tx?: unknown} = {},
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {and, eq, lt} from 'drizzle-orm';
|
|
2
|
+
import {db} from './db.js';
|
|
3
|
+
import {jiraPendingSelections, toJiraPendingSelection} from './schema/pending-selections.js';
|
|
4
|
+
|
|
5
|
+
export async function saveJiraPendingSelection(params: {
|
|
6
|
+
stateHash: string;
|
|
7
|
+
workspaceId: string;
|
|
8
|
+
expiresAt: Date;
|
|
9
|
+
sites: typeof jiraPendingSelections.$inferInsert.sites;
|
|
10
|
+
}): Promise<void> {
|
|
11
|
+
await db()
|
|
12
|
+
.insert(jiraPendingSelections)
|
|
13
|
+
.values(params)
|
|
14
|
+
.onConflictDoUpdate({
|
|
15
|
+
target: jiraPendingSelections.stateHash,
|
|
16
|
+
set: {workspaceId: params.workspaceId, expiresAt: params.expiresAt, sites: params.sites},
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function getJiraPendingSelection(params: {stateHash: string; workspaceId: string}) {
|
|
21
|
+
const rows = await db()
|
|
22
|
+
.select()
|
|
23
|
+
.from(jiraPendingSelections)
|
|
24
|
+
.where(
|
|
25
|
+
and(
|
|
26
|
+
eq(jiraPendingSelections.stateHash, params.stateHash),
|
|
27
|
+
eq(jiraPendingSelections.workspaceId, params.workspaceId),
|
|
28
|
+
),
|
|
29
|
+
)
|
|
30
|
+
.limit(1);
|
|
31
|
+
const row = rows[0];
|
|
32
|
+
return row ? toJiraPendingSelection(row) : undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function deleteJiraPendingSelection(params: {
|
|
36
|
+
stateHash: string;
|
|
37
|
+
workspaceId: string;
|
|
38
|
+
}): Promise<void> {
|
|
39
|
+
await db()
|
|
40
|
+
.delete(jiraPendingSelections)
|
|
41
|
+
.where(
|
|
42
|
+
and(
|
|
43
|
+
eq(jiraPendingSelections.stateHash, params.stateHash),
|
|
44
|
+
eq(jiraPendingSelections.workspaceId, params.workspaceId),
|
|
45
|
+
),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function listExpiredJiraPendingSelections(now: Date) {
|
|
50
|
+
const rows = await db()
|
|
51
|
+
.select()
|
|
52
|
+
.from(jiraPendingSelections)
|
|
53
|
+
.where(lt(jiraPendingSelections.expiresAt, now));
|
|
54
|
+
return rows.map(toJiraPendingSelection);
|
|
55
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
|
|
2
|
-
import {
|
|
2
|
+
import {jsonb, text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
|
|
3
3
|
import type {JiraInstallation} from '#db/installations.js';
|
|
4
4
|
import {pgTable} from './common.js';
|
|
5
5
|
|
|
@@ -22,7 +22,7 @@ export const jiraInstallations = pgTable(
|
|
|
22
22
|
},
|
|
23
23
|
(table) => [
|
|
24
24
|
uniqueIndex('integrations_jira_installations_connection_unique').on(table.connectionId),
|
|
25
|
-
|
|
25
|
+
uniqueIndex('integrations_jira_installations_cloud_id_unique').on(table.cloudId),
|
|
26
26
|
],
|
|
27
27
|
);
|
|
28
28
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import {jsonb, text, timestamp, uuid} from 'drizzle-orm/pg-core';
|
|
2
|
+
import type {JiraAccessibleResource} from '#api/client.js';
|
|
3
|
+
import {pgTable} from './common.js';
|
|
4
|
+
|
|
5
|
+
export const jiraPendingSelections = pgTable('pending_selections', {
|
|
6
|
+
stateHash: text('state_hash').primaryKey(),
|
|
7
|
+
workspaceId: uuid('workspace_id').notNull(),
|
|
8
|
+
expiresAt: timestamp('expires_at', {withTimezone: true}).notNull(),
|
|
9
|
+
sites: jsonb('sites').$type<JiraAccessibleResource[]>().notNull(),
|
|
10
|
+
createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export type JiraPendingSelectionDb = typeof jiraPendingSelections.$inferSelect;
|
|
14
|
+
|
|
15
|
+
export interface JiraPendingSelection {
|
|
16
|
+
stateHash: string;
|
|
17
|
+
workspaceId: string;
|
|
18
|
+
expiresAt: Date;
|
|
19
|
+
sites: JiraAccessibleResource[];
|
|
20
|
+
createdAt: Date;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function toJiraPendingSelection(row: JiraPendingSelectionDb): JiraPendingSelection {
|
|
24
|
+
return {
|
|
25
|
+
stateHash: row.stateHash,
|
|
26
|
+
workspaceId: row.workspaceId,
|
|
27
|
+
expiresAt: row.expiresAt,
|
|
28
|
+
sites: row.sites,
|
|
29
|
+
createdAt: row.createdAt,
|
|
30
|
+
};
|
|
31
|
+
}
|
package/src/index.test.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import {createJiraIntegrationProvider} from '#index.js';
|
|
2
2
|
|
|
3
3
|
describe('createJiraIntegrationProvider', () => {
|
|
4
|
-
it('creates the
|
|
4
|
+
it('creates the Jira provider', () => {
|
|
5
5
|
const provider = createJiraIntegrationProvider();
|
|
6
6
|
|
|
7
|
-
expect(provider).
|
|
7
|
+
expect(provider).toMatchObject({
|
|
8
|
+
provider: 'jira',
|
|
9
|
+
displayName: 'Jira',
|
|
10
|
+
adapters: {},
|
|
11
|
+
routes: [],
|
|
12
|
+
});
|
|
8
13
|
});
|
|
9
14
|
});
|
package/src/index.ts
CHANGED
|
@@ -1,14 +1,54 @@
|
|
|
1
1
|
import {JIRA_PROVIDER} from '@shipfox/api-integration-jira-dto';
|
|
2
|
+
import {createJiraApiClient, type JiraApiClient} from '#api/client.js';
|
|
2
3
|
import {config} from '#config.js';
|
|
3
4
|
import {closeDb, db} from '#db/db.js';
|
|
5
|
+
import {getJiraInstallationByConnectionId} from '#db/installations.js';
|
|
4
6
|
import {migrationsPath} from '#db/migrations.js';
|
|
7
|
+
import {
|
|
8
|
+
type CreateJiraIntegrationRoutesOptions,
|
|
9
|
+
createJiraIntegrationRoutes,
|
|
10
|
+
} from '#presentation/routes/install.js';
|
|
5
11
|
|
|
6
12
|
export type {JiraProvider} from '@shipfox/api-integration-jira-dto';
|
|
13
|
+
export type {
|
|
14
|
+
JiraAccessibleResource,
|
|
15
|
+
JiraApiClient,
|
|
16
|
+
JiraAuthorization,
|
|
17
|
+
JiraIdentity,
|
|
18
|
+
} from '#api/client.js';
|
|
19
|
+
export {createJiraApiClient, mapJiraError} from '#api/client.js';
|
|
20
|
+
export type {DisconnectJiraInstallationParams} from '#core/disconnect.js';
|
|
21
|
+
export {disconnectJiraInstallation} from '#core/disconnect.js';
|
|
7
22
|
export {
|
|
8
23
|
JiraAccessTokenMissingError,
|
|
24
|
+
JiraAuthorizationScopeMismatchError,
|
|
25
|
+
JiraConnectionAlreadyLinkedError,
|
|
9
26
|
JiraConnectionNotFoundError,
|
|
27
|
+
JiraInstallationAlreadyLinkedError,
|
|
10
28
|
JiraInstallationSiteMismatchError,
|
|
29
|
+
JiraInstallStateActorMismatchError,
|
|
30
|
+
JiraInstallStateError,
|
|
31
|
+
JiraIntegrationProviderError,
|
|
32
|
+
JiraOAuthCallbackError,
|
|
33
|
+
JiraOfflineAccessNotGrantedError,
|
|
34
|
+
JiraPendingSelectionNotFoundError,
|
|
35
|
+
JiraSiteSelectionMismatchError,
|
|
36
|
+
JiraTokenUnrefreshableError,
|
|
11
37
|
} from '#core/errors.js';
|
|
38
|
+
export type {ConnectJiraInstallationInput, HandleJiraCallbackParams} from '#core/install.js';
|
|
39
|
+
export {
|
|
40
|
+
handleJiraCallback,
|
|
41
|
+
handleJiraOAuthCallbackError,
|
|
42
|
+
handleJiraSiteSelection,
|
|
43
|
+
} from '#core/install.js';
|
|
44
|
+
export type {JiraPendingSelectionSecretsStore, JiraPendingSelectionStore} from '#core/pending.js';
|
|
45
|
+
export {createJiraPendingSelectionStore, jiraPendingSecretsNamespace} from '#core/pending.js';
|
|
46
|
+
export {
|
|
47
|
+
assertJiraAuthorizationScopes,
|
|
48
|
+
formatJiraOAuthScopes,
|
|
49
|
+
JIRA_OAUTH_SCOPES,
|
|
50
|
+
} from '#core/scopes.js';
|
|
51
|
+
export {signJiraInstallState, verifyJiraInstallState} from '#core/state.js';
|
|
12
52
|
export type {
|
|
13
53
|
CreateJiraTokenStoreParams,
|
|
14
54
|
GetJiraAccessTokenParams,
|
|
@@ -24,13 +64,37 @@ export type {
|
|
|
24
64
|
UpsertJiraInstallationParams,
|
|
25
65
|
} from '#db/installations.js';
|
|
26
66
|
export {
|
|
67
|
+
deleteJiraInstallationByConnectionId,
|
|
68
|
+
getJiraInstallationByCloudId,
|
|
27
69
|
getJiraInstallationByConnectionId,
|
|
28
70
|
getJiraInstallationByWebhookId,
|
|
29
71
|
markJiraInstallationRevoked,
|
|
72
|
+
updateJiraInstallationTokenExpiry,
|
|
30
73
|
upsertJiraInstallation,
|
|
74
|
+
withJiraRefreshLock,
|
|
31
75
|
} from '#db/installations.js';
|
|
32
76
|
export {closeDb, config, db, migrationsPath};
|
|
33
77
|
|
|
34
|
-
export
|
|
35
|
-
|
|
78
|
+
export interface CreateJiraIntegrationProviderOptions {
|
|
79
|
+
jira?: JiraApiClient | undefined;
|
|
80
|
+
getJiraInstallationByConnectionId?: typeof getJiraInstallationByConnectionId | undefined;
|
|
81
|
+
routes?: Omit<CreateJiraIntegrationRoutesOptions, 'jira' | 'connectionCapabilities'> | undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function createJiraIntegrationProvider(options: CreateJiraIntegrationProviderOptions = {}) {
|
|
85
|
+
const jira = options.jira ?? createJiraApiClient();
|
|
86
|
+
const getInstallationByConnectionId =
|
|
87
|
+
options.getJiraInstallationByConnectionId ?? getJiraInstallationByConnectionId;
|
|
88
|
+
const routes = options.routes
|
|
89
|
+
? [createJiraIntegrationRoutes({jira, connectionCapabilities: [], ...options.routes})]
|
|
90
|
+
: [];
|
|
91
|
+
return {
|
|
92
|
+
provider: JIRA_PROVIDER,
|
|
93
|
+
displayName: 'Jira',
|
|
94
|
+
adapters: {},
|
|
95
|
+
async connectionExternalUrl(connection: {id: string}): Promise<string | undefined> {
|
|
96
|
+
return (await getInstallationByConnectionId(connection.id))?.siteUrl;
|
|
97
|
+
},
|
|
98
|
+
routes,
|
|
99
|
+
};
|
|
36
100
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {toIntegrationConnectionDto} from '@shipfox/api-integration-core-dto';
|