@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.
Files changed (102) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +52 -0
  3. package/dist/api/client.d.ts +33 -0
  4. package/dist/api/client.d.ts.map +1 -0
  5. package/dist/api/client.js +143 -0
  6. package/dist/api/client.js.map +1 -0
  7. package/dist/core/disconnect.d.ts +18 -0
  8. package/dist/core/disconnect.d.ts.map +1 -0
  9. package/dist/core/disconnect.js +21 -0
  10. package/dist/core/disconnect.js.map +1 -0
  11. package/dist/core/errors.d.ts +37 -0
  12. package/dist/core/errors.d.ts.map +1 -1
  13. package/dist/core/errors.js +63 -0
  14. package/dist/core/errors.js.map +1 -1
  15. package/dist/core/install.d.ts +55 -0
  16. package/dist/core/install.d.ts.map +1 -0
  17. package/dist/core/install.js +121 -0
  18. package/dist/core/install.js.map +1 -0
  19. package/dist/core/pending.d.ts +43 -0
  20. package/dist/core/pending.d.ts.map +1 -0
  21. package/dist/core/pending.js +107 -0
  22. package/dist/core/pending.js.map +1 -0
  23. package/dist/core/scopes.d.ts +4 -0
  24. package/dist/core/scopes.d.ts.map +1 -0
  25. package/dist/core/scopes.js +23 -0
  26. package/dist/core/scopes.js.map +1 -0
  27. package/dist/core/state.d.ts +12 -0
  28. package/dist/core/state.d.ts.map +1 -0
  29. package/dist/core/state.js +51 -0
  30. package/dist/core/state.js.map +1 -0
  31. package/dist/core/tokens.d.ts +6 -0
  32. package/dist/core/tokens.d.ts.map +1 -1
  33. package/dist/core/tokens.js +82 -6
  34. package/dist/core/tokens.js.map +1 -1
  35. package/dist/db/db.d.ts +188 -0
  36. package/dist/db/db.d.ts.map +1 -1
  37. package/dist/db/db.js +3 -1
  38. package/dist/db/db.js.map +1 -1
  39. package/dist/db/installations.d.ts +21 -0
  40. package/dist/db/installations.d.ts.map +1 -1
  41. package/dist/db/installations.js +82 -19
  42. package/dist/db/installations.js.map +1 -1
  43. package/dist/db/pending-selections.d.ts +17 -0
  44. package/dist/db/pending-selections.d.ts.map +1 -0
  45. package/dist/db/pending-selections.js +27 -0
  46. package/dist/db/pending-selections.js.map +1 -0
  47. package/dist/db/schema/installations.js +2 -2
  48. package/dist/db/schema/installations.js.map +1 -1
  49. package/dist/db/schema/pending-selections.d.ts +105 -0
  50. package/dist/db/schema/pending-selections.d.ts.map +1 -0
  51. package/dist/db/schema/pending-selections.js +24 -0
  52. package/dist/db/schema/pending-selections.js.map +1 -0
  53. package/dist/index.d.ts +26 -3
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +27 -4
  56. package/dist/index.js.map +1 -1
  57. package/dist/presentation/dto/integrations.d.ts +2 -0
  58. package/dist/presentation/dto/integrations.d.ts.map +1 -0
  59. package/dist/presentation/dto/integrations.js +3 -0
  60. package/dist/presentation/dto/integrations.js.map +1 -0
  61. package/dist/presentation/routes/errors.d.ts +2 -0
  62. package/dist/presentation/routes/errors.d.ts.map +1 -0
  63. package/dist/presentation/routes/errors.js +58 -0
  64. package/dist/presentation/routes/errors.js.map +1 -0
  65. package/dist/presentation/routes/install.d.ts +26 -0
  66. package/dist/presentation/routes/install.d.ts.map +1 -0
  67. package/dist/presentation/routes/install.js +133 -0
  68. package/dist/presentation/routes/install.js.map +1 -0
  69. package/dist/tsconfig.test.tsbuildinfo +1 -1
  70. package/drizzle/0000_whole_master_chief.sql +8 -1
  71. package/drizzle/meta/0000_snapshot.json +47 -3
  72. package/package.json +22 -26
  73. package/src/api/client.test.ts +29 -0
  74. package/src/api/client.ts +213 -0
  75. package/src/core/disconnect.test.ts +24 -0
  76. package/src/core/disconnect.ts +25 -0
  77. package/src/core/errors.ts +79 -0
  78. package/src/core/install.test.ts +129 -0
  79. package/src/core/install.ts +191 -0
  80. package/src/core/pending.ts +146 -0
  81. package/src/core/scopes.test.ts +19 -0
  82. package/src/core/scopes.ts +20 -0
  83. package/src/core/state.test.ts +38 -0
  84. package/src/core/state.ts +79 -0
  85. package/src/core/tokens-refresh.test.ts +106 -0
  86. package/src/core/tokens.test.ts +16 -2
  87. package/src/core/tokens.ts +145 -8
  88. package/src/db/db.ts +2 -1
  89. package/src/db/installations.test.ts +37 -1
  90. package/src/db/installations.ts +120 -23
  91. package/src/db/pending-selections.ts +55 -0
  92. package/src/db/schema/installations.ts +2 -2
  93. package/src/db/schema/pending-selections.ts +31 -0
  94. package/src/index.test.ts +7 -2
  95. package/src/index.ts +66 -2
  96. package/src/presentation/dto/integrations.ts +1 -0
  97. package/src/presentation/routes/errors.ts +61 -0
  98. package/src/presentation/routes/install.ts +161 -0
  99. package/test/globalSetup.ts +0 -9
  100. package/tsconfig.build.tsbuildinfo +1 -1
  101. package/turbo.json +9 -0
  102. package/test/api-secrets.d.ts +0 -26
@@ -1,7 +1,20 @@
1
- import {JiraAccessTokenMissingError, JiraConnectionNotFoundError} from '#core/errors.js';
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 token = await params.secrets.getSecret({
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
- namespace: jiraSecretsNamespace(input.connectionId),
71
- key: ACCESS_TOKEN_KEY,
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
- if (!token) throw new JiraAccessTokenMissingError(input.connectionId);
74
- return token;
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;
@@ -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 {JiraInstallationSiteMismatchError} from '#core/errors.js';
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
- const [row] = await executor
48
- .insert(jiraInstallations)
49
- .values({
50
- connectionId: params.connectionId,
51
- cloudId: params.cloudId,
52
- siteUrl: params.siteUrl,
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
- updatedAt: now,
75
- },
76
- })
77
- .returning();
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 {index, jsonb, text, timestamp, uniqueIndex, uuid} from 'drizzle-orm/pg-core';
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
- index('integrations_jira_installations_cloud_id_idx').on(table.cloudId),
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 minimal Jira provider', () => {
4
+ it('creates the Jira provider', () => {
5
5
  const provider = createJiraIntegrationProvider();
6
6
 
7
- expect(provider).toEqual({provider: 'jira', displayName: 'Jira'});
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 function createJiraIntegrationProvider() {
35
- return {provider: JIRA_PROVIDER, displayName: 'Jira'};
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';