@shipfox/api-integration-jira 11.0.0 → 12.1.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 +17 -2
- package/CHANGELOG.md +36 -0
- package/README.md +58 -2
- package/dist/api/client.d.ts +34 -0
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +131 -0
- package/dist/api/client.js.map +1 -1
- package/dist/config.d.ts +0 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -4
- package/dist/config.js.map +1 -1
- package/dist/core/agent-tools-provider.d.ts +26 -0
- package/dist/core/agent-tools-provider.d.ts.map +1 -0
- package/dist/core/agent-tools-provider.js +130 -0
- package/dist/core/agent-tools-provider.js.map +1 -0
- package/dist/core/agent-tools.d.ts +16 -0
- package/dist/core/agent-tools.d.ts.map +1 -0
- package/dist/core/agent-tools.js +441 -0
- package/dist/core/agent-tools.js.map +1 -0
- package/dist/core/install.d.ts +22 -0
- package/dist/core/install.d.ts.map +1 -1
- package/dist/core/install.js +68 -20
- package/dist/core/install.js.map +1 -1
- package/dist/core/tokens.d.ts +2 -0
- package/dist/core/tokens.d.ts.map +1 -1
- package/dist/core/tokens.js +129 -37
- package/dist/core/tokens.js.map +1 -1
- package/dist/core/webhook-processor.d.ts +15 -0
- package/dist/core/webhook-processor.d.ts.map +1 -0
- package/dist/core/webhook-processor.js +137 -0
- package/dist/core/webhook-processor.js.map +1 -0
- package/dist/core/webhook-registration.d.ts +26 -0
- package/dist/core/webhook-registration.d.ts.map +1 -0
- package/dist/core/webhook-registration.js +165 -0
- package/dist/core/webhook-registration.js.map +1 -0
- package/dist/core/webhook.d.ts +18 -0
- package/dist/core/webhook.d.ts.map +1 -0
- package/dist/core/webhook.js +38 -0
- package/dist/core/webhook.js.map +1 -0
- package/dist/db/db.d.ts +68 -0
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/installations.d.ts +33 -6
- package/dist/db/installations.d.ts.map +1 -1
- package/dist/db/installations.js +79 -7
- package/dist/db/installations.js.map +1 -1
- package/dist/db/schema/installations.d.ts +34 -0
- package/dist/db/schema/installations.d.ts.map +1 -1
- package/dist/db/schema/installations.js +12 -2
- package/dist/db/schema/installations.js.map +1 -1
- package/dist/index.d.ts +34 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +76 -8
- package/dist/index.js.map +1 -1
- package/dist/presentation/routes/install.d.ts +22 -0
- package/dist/presentation/routes/install.d.ts.map +1 -1
- package/dist/presentation/routes/install.js.map +1 -1
- package/dist/presentation/routes/webhooks.d.ts +8 -0
- package/dist/presentation/routes/webhooks.d.ts.map +1 -0
- package/dist/presentation/routes/webhooks.js +108 -0
- package/dist/presentation/routes/webhooks.js.map +1 -0
- package/dist/temporal/activities/index.d.ts +2 -0
- package/dist/temporal/activities/index.d.ts.map +1 -0
- package/dist/temporal/activities/index.js +3 -0
- package/dist/temporal/activities/index.js.map +1 -0
- package/dist/temporal/activities/refresh-jira-tokens.d.ts +20 -0
- package/dist/temporal/activities/refresh-jira-tokens.d.ts.map +1 -0
- package/dist/temporal/activities/refresh-jira-tokens.js +58 -0
- package/dist/temporal/activities/refresh-jira-tokens.js.map +1 -0
- package/dist/temporal/constants.d.ts +6 -0
- package/dist/temporal/constants.d.ts.map +1 -0
- package/dist/temporal/constants.js +8 -0
- package/dist/temporal/constants.js.map +1 -0
- package/dist/temporal/worker.d.ts +9 -0
- package/dist/temporal/worker.d.ts.map +1 -0
- package/dist/temporal/worker.js +26 -0
- package/dist/temporal/worker.js.map +1 -0
- package/dist/temporal/workflows/index.bundle.js +25225 -0
- package/dist/temporal/workflows/index.bundle.meta.json +1 -0
- package/dist/temporal/workflows/index.d.ts +2 -0
- package/dist/temporal/workflows/index.d.ts.map +1 -0
- package/dist/temporal/workflows/index.js +3 -0
- package/dist/temporal/workflows/index.js.map +1 -0
- package/dist/temporal/workflows/refresh-jira-tokens-cron.d.ts +2 -0
- package/dist/temporal/workflows/refresh-jira-tokens-cron.d.ts.map +1 -0
- package/dist/temporal/workflows/refresh-jira-tokens-cron.js +20 -0
- package/dist/temporal/workflows/refresh-jira-tokens-cron.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0001_jira-token-refresh-state.sql +12 -0
- package/drizzle/meta/0001_snapshot.json +245 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +17 -8
- package/src/api/client.test.ts +236 -9
- package/src/api/client.ts +192 -0
- package/src/config.test.ts +0 -1
- package/src/config.ts +1 -4
- package/src/core/agent-tools-provider.test.ts +299 -0
- package/src/core/agent-tools-provider.ts +175 -0
- package/src/core/agent-tools.ts +450 -0
- package/src/core/install.test.ts +179 -0
- package/src/core/install.ts +85 -19
- package/src/core/tokens-refresh.test.ts +223 -4
- package/src/core/tokens.test.ts +1 -1
- package/src/core/tokens.ts +153 -33
- package/src/core/webhook-processor.test.ts +418 -0
- package/src/core/webhook-processor.ts +166 -0
- package/src/core/webhook-registration.test.ts +375 -0
- package/src/core/webhook-registration.ts +216 -0
- package/src/core/webhook.ts +69 -0
- package/src/db/installations.test.ts +148 -2
- package/src/db/installations.ts +133 -7
- package/src/db/schema/installations.ts +21 -1
- package/src/index.test.ts +42 -0
- package/src/index.ts +148 -5
- package/src/presentation/routes/install.ts +12 -0
- package/src/presentation/routes/webhooks.test.ts +132 -0
- package/src/presentation/routes/webhooks.ts +115 -0
- package/src/temporal/activities/index.ts +5 -0
- package/src/temporal/activities/refresh-jira-tokens.test.ts +156 -0
- package/src/temporal/activities/refresh-jira-tokens.ts +88 -0
- package/src/temporal/constants.ts +8 -0
- package/src/temporal/worker.ts +39 -0
- package/src/temporal/workflows/index.ts +1 -0
- package/src/temporal/workflows/refresh-jira-tokens-cron.ts +19 -0
- package/test/env.ts +0 -1
- package/test/factories/jira-installation.ts +4 -0
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import {logger} from '@shipfox/node-opentelemetry';
|
|
2
|
+
import type {JiraApiClient} from '#api/client.js';
|
|
3
|
+
import {
|
|
4
|
+
getJiraInstallationByConnectionId,
|
|
5
|
+
type JiraInstallationLock,
|
|
6
|
+
updateJiraInstallationWebhook,
|
|
7
|
+
withJiraInstallationLock,
|
|
8
|
+
} from '#db/installations.js';
|
|
9
|
+
|
|
10
|
+
export const JIRA_WEBHOOK_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
export interface RegisterJiraWebhookParams {
|
|
13
|
+
jira: Pick<JiraApiClient, 'registerDynamicWebhook' | 'deleteDynamicWebhook'>;
|
|
14
|
+
connectionId: string;
|
|
15
|
+
cloudId: string;
|
|
16
|
+
accessToken: string;
|
|
17
|
+
webhookUrl: string;
|
|
18
|
+
now?: () => Date;
|
|
19
|
+
getInstallation?: typeof getJiraInstallationByConnectionId;
|
|
20
|
+
updateInstallation?: typeof updateJiraInstallationWebhook;
|
|
21
|
+
withRegistrationLock?: JiraInstallationLock;
|
|
22
|
+
onRegistrationSuccess?: (input: {tx?: unknown}) => Promise<void>;
|
|
23
|
+
onRegistrationFailure?: (input: {tx?: unknown}) => Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Register Jira's curated webhook and replace local metadata as one serialized state transition. */
|
|
27
|
+
export async function registerJiraWebhook(
|
|
28
|
+
params: RegisterJiraWebhookParams,
|
|
29
|
+
): Promise<{webhookId: number; webhookExpiresAt: Date}> {
|
|
30
|
+
const withRegistrationLock = params.withRegistrationLock ?? withJiraInstallationLock;
|
|
31
|
+
let callbackFailed = false;
|
|
32
|
+
let registeredWebhookId: number | undefined;
|
|
33
|
+
let previous: Awaited<ReturnType<typeof getJiraInstallationByConnectionId>>;
|
|
34
|
+
let metadataPersisted = false;
|
|
35
|
+
let cleanupSupersededWebhooks: (() => Promise<void>) | undefined;
|
|
36
|
+
let result: {webhookId: number; webhookExpiresAt: Date};
|
|
37
|
+
try {
|
|
38
|
+
result = await withRegistrationLock(params.cloudId, async () => {
|
|
39
|
+
try {
|
|
40
|
+
const getInstallation = params.getInstallation ?? getJiraInstallationByConnectionId;
|
|
41
|
+
previous = await getInstallation(params.connectionId);
|
|
42
|
+
const registration = await params.jira.registerDynamicWebhook({
|
|
43
|
+
accessToken: params.accessToken,
|
|
44
|
+
cloudId: params.cloudId,
|
|
45
|
+
url: params.webhookUrl,
|
|
46
|
+
});
|
|
47
|
+
registeredWebhookId = registration.webhookId;
|
|
48
|
+
const now = params.now ?? (() => new Date());
|
|
49
|
+
const webhookExpiresAt = new Date(now().getTime() + JIRA_WEBHOOK_TTL_MS);
|
|
50
|
+
|
|
51
|
+
const updateInstallation = params.updateInstallation ?? updateJiraInstallationWebhook;
|
|
52
|
+
const updateInput = {
|
|
53
|
+
connectionId: params.connectionId,
|
|
54
|
+
webhookIds: [...new Set([registration.webhookId, ...(previous?.webhookIds ?? [])])],
|
|
55
|
+
webhookExpiresAt,
|
|
56
|
+
};
|
|
57
|
+
const installation = await updateInstallation(updateInput);
|
|
58
|
+
if (!installation)
|
|
59
|
+
throw new Error('Jira webhook registration lost its installation record');
|
|
60
|
+
metadataPersisted = true;
|
|
61
|
+
await params.onRegistrationSuccess?.({});
|
|
62
|
+
|
|
63
|
+
cleanupSupersededWebhooks = () =>
|
|
64
|
+
finishSupersededWebhookCleanup(
|
|
65
|
+
params,
|
|
66
|
+
previous,
|
|
67
|
+
registration.webhookId,
|
|
68
|
+
webhookExpiresAt,
|
|
69
|
+
);
|
|
70
|
+
return {webhookId: registration.webhookId, webhookExpiresAt};
|
|
71
|
+
} catch (error) {
|
|
72
|
+
callbackFailed = true;
|
|
73
|
+
if (registeredWebhookId !== undefined) {
|
|
74
|
+
const deleted = await deleteNewWebhookAfterPersistenceFailure(
|
|
75
|
+
params,
|
|
76
|
+
registeredWebhookId,
|
|
77
|
+
);
|
|
78
|
+
if (metadataPersisted || !deleted) {
|
|
79
|
+
await restorePreviousWebhookMetadata(
|
|
80
|
+
params,
|
|
81
|
+
previous,
|
|
82
|
+
deleted ? undefined : registeredWebhookId,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
} else if (metadataPersisted) {
|
|
86
|
+
await restorePreviousWebhookMetadata(params, previous);
|
|
87
|
+
}
|
|
88
|
+
await bestEffortRegistrationFailure(params);
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (!callbackFailed) {
|
|
94
|
+
if (registeredWebhookId !== undefined) {
|
|
95
|
+
const deleted = await deleteNewWebhookAfterPersistenceFailure(params, registeredWebhookId);
|
|
96
|
+
if (metadataPersisted || !deleted) {
|
|
97
|
+
await restorePreviousWebhookMetadata(
|
|
98
|
+
params,
|
|
99
|
+
previous,
|
|
100
|
+
deleted ? undefined : registeredWebhookId,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
} else if (metadataPersisted) {
|
|
104
|
+
await restorePreviousWebhookMetadata(params, previous);
|
|
105
|
+
}
|
|
106
|
+
await bestEffortRegistrationFailure(params);
|
|
107
|
+
}
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (cleanupSupersededWebhooks) {
|
|
112
|
+
try {
|
|
113
|
+
await withRegistrationLock(params.cloudId, cleanupSupersededWebhooks);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
logger().warn(
|
|
116
|
+
{err: error, connectionId: params.connectionId},
|
|
117
|
+
'Jira superseded webhook cleanup persistence failed',
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return result;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function finishSupersededWebhookCleanup(
|
|
126
|
+
params: RegisterJiraWebhookParams,
|
|
127
|
+
previous: Awaited<ReturnType<typeof getJiraInstallationByConnectionId>>,
|
|
128
|
+
registeredWebhookId: number,
|
|
129
|
+
webhookExpiresAt: Date,
|
|
130
|
+
): Promise<void> {
|
|
131
|
+
const supersededWebhookIds = (previous?.webhookIds ?? []).filter(
|
|
132
|
+
(webhookId) => webhookId !== registeredWebhookId,
|
|
133
|
+
);
|
|
134
|
+
if (supersededWebhookIds.length === 0) return;
|
|
135
|
+
|
|
136
|
+
const failedCleanupIds: number[] = [];
|
|
137
|
+
for (const webhookId of supersededWebhookIds) {
|
|
138
|
+
try {
|
|
139
|
+
await params.jira.deleteDynamicWebhook({
|
|
140
|
+
accessToken: params.accessToken,
|
|
141
|
+
cloudId: params.cloudId,
|
|
142
|
+
webhookId,
|
|
143
|
+
});
|
|
144
|
+
} catch (error) {
|
|
145
|
+
failedCleanupIds.push(webhookId);
|
|
146
|
+
logger().warn(
|
|
147
|
+
{err: error, connectionId: params.connectionId, webhookId},
|
|
148
|
+
'Jira superseded webhook cleanup failed',
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const retainedWebhookIds = [registeredWebhookId, ...failedCleanupIds];
|
|
154
|
+
const updateInstallation = params.updateInstallation ?? updateJiraInstallationWebhook;
|
|
155
|
+
const cleanupMetadata = await updateInstallation({
|
|
156
|
+
connectionId: params.connectionId,
|
|
157
|
+
webhookIds: retainedWebhookIds,
|
|
158
|
+
webhookExpiresAt,
|
|
159
|
+
});
|
|
160
|
+
if (!cleanupMetadata)
|
|
161
|
+
throw new Error('Jira webhook cleanup metadata persistence returned no installation');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function bestEffortRegistrationFailure(params: RegisterJiraWebhookParams): Promise<void> {
|
|
165
|
+
try {
|
|
166
|
+
await params.onRegistrationFailure?.({});
|
|
167
|
+
} catch (error) {
|
|
168
|
+
logger().warn(
|
|
169
|
+
{err: error, connectionId: params.connectionId},
|
|
170
|
+
'Jira connection error-state update failed after webhook registration rejection',
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function restorePreviousWebhookMetadata(
|
|
176
|
+
params: RegisterJiraWebhookParams,
|
|
177
|
+
previous: Awaited<ReturnType<typeof getJiraInstallationByConnectionId>>,
|
|
178
|
+
retainedWebhookId?: number,
|
|
179
|
+
): Promise<void> {
|
|
180
|
+
try {
|
|
181
|
+
const updateInstallation = params.updateInstallation ?? updateJiraInstallationWebhook;
|
|
182
|
+
await updateInstallation({
|
|
183
|
+
connectionId: params.connectionId,
|
|
184
|
+
webhookIds: [
|
|
185
|
+
...(retainedWebhookId === undefined ? [] : [retainedWebhookId]),
|
|
186
|
+
...(previous?.webhookIds ?? []).filter((webhookId) => webhookId !== retainedWebhookId),
|
|
187
|
+
],
|
|
188
|
+
webhookExpiresAt: previous?.webhookExpiresAt ?? null,
|
|
189
|
+
});
|
|
190
|
+
} catch (error) {
|
|
191
|
+
logger().warn(
|
|
192
|
+
{err: error, connectionId: params.connectionId},
|
|
193
|
+
'Jira webhook metadata restoration failed after registration rejection',
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function deleteNewWebhookAfterPersistenceFailure(
|
|
199
|
+
params: RegisterJiraWebhookParams,
|
|
200
|
+
webhookId: number,
|
|
201
|
+
): Promise<boolean> {
|
|
202
|
+
try {
|
|
203
|
+
await params.jira.deleteDynamicWebhook({
|
|
204
|
+
accessToken: params.accessToken,
|
|
205
|
+
cloudId: params.cloudId,
|
|
206
|
+
webhookId,
|
|
207
|
+
});
|
|
208
|
+
return true;
|
|
209
|
+
} catch (cleanupError) {
|
|
210
|
+
logger().warn(
|
|
211
|
+
{err: cleanupError, connectionId: params.connectionId, webhookId},
|
|
212
|
+
'Jira webhook cleanup failed after metadata persistence rejection',
|
|
213
|
+
);
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type {JiraWebhookEnvelopeDto} from '@shipfox/api-integration-jira-dto';
|
|
2
|
+
import type {
|
|
3
|
+
IntegrationConnection,
|
|
4
|
+
IntegrationTx,
|
|
5
|
+
PublishIntegrationEventReceivedFn,
|
|
6
|
+
RecordDeliveryOnlyFn,
|
|
7
|
+
} from '@shipfox/api-integration-spi';
|
|
8
|
+
import type {JiraInstallation} from '#db/installations.js';
|
|
9
|
+
|
|
10
|
+
const JIRA_PROVIDER = 'jira';
|
|
11
|
+
|
|
12
|
+
export interface HandleJiraWebhookParams {
|
|
13
|
+
tx: IntegrationTx;
|
|
14
|
+
deliveryId: string;
|
|
15
|
+
receivedAt: string;
|
|
16
|
+
rawPayload: JiraWebhookEnvelopeDto;
|
|
17
|
+
cloudId: string;
|
|
18
|
+
connection: IntegrationConnection<'jira'>;
|
|
19
|
+
authorizingAccountId: string;
|
|
20
|
+
publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
|
|
21
|
+
recordDeliveryOnly: RecordDeliveryOnlyFn;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type HandleJiraWebhookResult = 'published' | 'duplicate' | 'discarded';
|
|
25
|
+
|
|
26
|
+
export async function handleJiraWebhook(
|
|
27
|
+
params: HandleJiraWebhookParams,
|
|
28
|
+
): Promise<HandleJiraWebhookResult> {
|
|
29
|
+
if (isSelfAuthoredEvent(params.rawPayload, params.authorizingAccountId)) {
|
|
30
|
+
await params.recordDeliveryOnly({
|
|
31
|
+
tx: params.tx,
|
|
32
|
+
provider: JIRA_PROVIDER,
|
|
33
|
+
deliveryId: params.deliveryId,
|
|
34
|
+
});
|
|
35
|
+
return 'discarded';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const payload = {...params.rawPayload, cloudId: params.cloudId};
|
|
39
|
+
const result = await params.publishIntegrationEventReceived({
|
|
40
|
+
tx: params.tx,
|
|
41
|
+
event: {
|
|
42
|
+
provider: JIRA_PROVIDER,
|
|
43
|
+
source: params.connection.slug,
|
|
44
|
+
event: params.rawPayload.webhookEvent,
|
|
45
|
+
workspaceId: params.connection.workspaceId,
|
|
46
|
+
connectionId: params.connection.id,
|
|
47
|
+
connectionName: params.connection.displayName,
|
|
48
|
+
deliveryId: params.deliveryId,
|
|
49
|
+
receivedAt: params.receivedAt,
|
|
50
|
+
payload,
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
return result.published ? 'published' : 'duplicate';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isSelfAuthoredEvent(payload: JiraWebhookEnvelopeDto, authorizingAccountId: string) {
|
|
57
|
+
return (
|
|
58
|
+
(payload.webhookEvent === 'comment_created' ||
|
|
59
|
+
payload.webhookEvent === 'comment_updated' ||
|
|
60
|
+
payload.webhookEvent === 'jira:issue_updated') &&
|
|
61
|
+
payload.user.accountId === authorizingAccountId
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function isJiraInstallationUsable(
|
|
66
|
+
installation: JiraInstallation | undefined,
|
|
67
|
+
): installation is JiraInstallation {
|
|
68
|
+
return installation?.status === 'installed';
|
|
69
|
+
}
|
|
@@ -7,9 +7,13 @@ import {
|
|
|
7
7
|
getJiraInstallationByCloudId,
|
|
8
8
|
getJiraInstallationByConnectionId,
|
|
9
9
|
getJiraInstallationByWebhookId,
|
|
10
|
+
listJiraInstallationsDueForTokenRefresh,
|
|
10
11
|
markJiraInstallationRevoked,
|
|
12
|
+
markJiraInstallationTokenRefreshAttempt,
|
|
11
13
|
updateJiraInstallationTokenExpiry,
|
|
14
|
+
updateJiraInstallationWebhook,
|
|
12
15
|
upsertJiraInstallation,
|
|
16
|
+
withJiraWebhookRegistrationLock,
|
|
13
17
|
} from './installations.js';
|
|
14
18
|
|
|
15
19
|
function createInstallationInput(
|
|
@@ -62,6 +66,36 @@ describe('jira installations', () => {
|
|
|
62
66
|
});
|
|
63
67
|
});
|
|
64
68
|
|
|
69
|
+
it('preserves webhook metadata when reconnect upsert omits it', async () => {
|
|
70
|
+
const webhookExpiresAt = new Date('2030-01-01T00:00:00.000Z');
|
|
71
|
+
const input = createInstallationInput({webhookIds: [321], webhookExpiresAt});
|
|
72
|
+
await upsertJiraInstallation(input);
|
|
73
|
+
|
|
74
|
+
const result = await upsertJiraInstallation({
|
|
75
|
+
...input,
|
|
76
|
+
scopes: ['read:jira-work', 'write:jira-work'],
|
|
77
|
+
webhookIds: undefined,
|
|
78
|
+
webhookExpiresAt: undefined,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(result.webhookIds).toEqual([321]);
|
|
82
|
+
expect(result.webhookExpiresAt).toEqual(webhookExpiresAt);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('replaces webhook ids and expiry together after registration', async () => {
|
|
86
|
+
const input = createInstallationInput({webhookIds: [321]});
|
|
87
|
+
await upsertJiraInstallation(input);
|
|
88
|
+
const webhookExpiresAt = new Date('2030-02-01T00:00:00.000Z');
|
|
89
|
+
|
|
90
|
+
const result = await updateJiraInstallationWebhook({
|
|
91
|
+
connectionId: input.connectionId,
|
|
92
|
+
webhookIds: [654],
|
|
93
|
+
webhookExpiresAt,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(result).toMatchObject({webhookIds: [654], webhookExpiresAt});
|
|
97
|
+
});
|
|
98
|
+
|
|
65
99
|
it('refuses to repoint a connection to a different Jira site', async () => {
|
|
66
100
|
const first = createInstallationInput();
|
|
67
101
|
await upsertJiraInstallation(first);
|
|
@@ -89,10 +123,19 @@ describe('jira installations', () => {
|
|
|
89
123
|
});
|
|
90
124
|
|
|
91
125
|
it('updates token metadata and deletes an installation', async () => {
|
|
92
|
-
const input = createInstallationInput(
|
|
93
|
-
|
|
126
|
+
const input = createInstallationInput({
|
|
127
|
+
refreshTokenLastUsedAt: new Date('2020-01-01T00:00:00.000Z'),
|
|
128
|
+
refreshTokenLastAttemptedAt: new Date('2020-01-01T00:00:00.000Z'),
|
|
129
|
+
});
|
|
130
|
+
const initial = await upsertJiraInstallation(input);
|
|
94
131
|
const tokenExpiresAt = new Date('2030-01-01T00:00:00.000Z');
|
|
95
132
|
|
|
133
|
+
const attempted = await markJiraInstallationTokenRefreshAttempt(input.connectionId);
|
|
134
|
+
|
|
135
|
+
expect(attempted?.refreshTokenLastAttemptedAt.getTime()).toBeGreaterThan(
|
|
136
|
+
initial.refreshTokenLastAttemptedAt.getTime(),
|
|
137
|
+
);
|
|
138
|
+
|
|
96
139
|
const updated = await updateJiraInstallationTokenExpiry({
|
|
97
140
|
connectionId: input.connectionId,
|
|
98
141
|
tokenExpiresAt,
|
|
@@ -101,10 +144,81 @@ describe('jira installations', () => {
|
|
|
101
144
|
const deleted = await deleteJiraInstallationByConnectionId(input.connectionId);
|
|
102
145
|
|
|
103
146
|
expect(updated).toMatchObject({tokenExpiresAt, scopes: ['read:jira-work', 'write:jira-work']});
|
|
147
|
+
expect(updated?.refreshTokenLastUsedAt.getTime()).toBeGreaterThan(
|
|
148
|
+
initial.refreshTokenLastUsedAt.getTime(),
|
|
149
|
+
);
|
|
104
150
|
expect(deleted).toBe(true);
|
|
105
151
|
await expect(getJiraInstallationByConnectionId(input.connectionId)).resolves.toBeUndefined();
|
|
106
152
|
});
|
|
107
153
|
|
|
154
|
+
it('lists only installed installations whose refresh tokens are nearing idle expiry', async () => {
|
|
155
|
+
const due = createInstallationInput({
|
|
156
|
+
refreshTokenLastUsedAt: new Date('2026-01-01T00:00:00.000Z'),
|
|
157
|
+
});
|
|
158
|
+
const fresh = createInstallationInput({
|
|
159
|
+
refreshTokenLastUsedAt: new Date('2026-07-01T00:00:00.000Z'),
|
|
160
|
+
});
|
|
161
|
+
const boundary = createInstallationInput({
|
|
162
|
+
refreshTokenLastUsedAt: new Date('2026-06-01T00:00:00.000Z'),
|
|
163
|
+
});
|
|
164
|
+
const revoked = createInstallationInput({
|
|
165
|
+
refreshTokenLastUsedAt: new Date('2026-01-01T00:00:00.000Z'),
|
|
166
|
+
status: 'revoked',
|
|
167
|
+
});
|
|
168
|
+
await upsertJiraInstallation(due);
|
|
169
|
+
await upsertJiraInstallation(fresh);
|
|
170
|
+
await upsertJiraInstallation(boundary);
|
|
171
|
+
await upsertJiraInstallation(revoked);
|
|
172
|
+
|
|
173
|
+
const result = await listJiraInstallationsDueForTokenRefresh({
|
|
174
|
+
before: new Date('2026-06-01T00:00:00.000Z'),
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expect(result.map((installation) => installation.connectionId)).toContain(due.connectionId);
|
|
178
|
+
expect(result.map((installation) => installation.connectionId)).toContain(
|
|
179
|
+
boundary.connectionId,
|
|
180
|
+
);
|
|
181
|
+
expect(result.map((installation) => installation.connectionId)).not.toContain(
|
|
182
|
+
fresh.connectionId,
|
|
183
|
+
);
|
|
184
|
+
expect(result.map((installation) => installation.connectionId)).not.toContain(
|
|
185
|
+
revoked.connectionId,
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('orders due installations by their latest refresh attempt', async () => {
|
|
190
|
+
const refreshTokenLastUsedAt = new Date('1970-01-01T00:00:00.000Z');
|
|
191
|
+
const attemptBase = Date.now();
|
|
192
|
+
const latestAttempt = createInstallationInput({
|
|
193
|
+
refreshTokenLastUsedAt,
|
|
194
|
+
refreshTokenLastAttemptedAt: new Date(attemptBase + 3_000),
|
|
195
|
+
});
|
|
196
|
+
const middleAttempt = createInstallationInput({
|
|
197
|
+
refreshTokenLastUsedAt,
|
|
198
|
+
refreshTokenLastAttemptedAt: new Date(attemptBase + 2_000),
|
|
199
|
+
});
|
|
200
|
+
const oldestAttempt = createInstallationInput({
|
|
201
|
+
refreshTokenLastUsedAt,
|
|
202
|
+
refreshTokenLastAttemptedAt: new Date(attemptBase + 1_000),
|
|
203
|
+
});
|
|
204
|
+
await upsertJiraInstallation(latestAttempt);
|
|
205
|
+
await upsertJiraInstallation(middleAttempt);
|
|
206
|
+
await upsertJiraInstallation(oldestAttempt);
|
|
207
|
+
|
|
208
|
+
const result = await listJiraInstallationsDueForTokenRefresh({
|
|
209
|
+
before: new Date('2000-01-01T00:00:00.000Z'),
|
|
210
|
+
limit: 1_000,
|
|
211
|
+
});
|
|
212
|
+
const connectionIds = result.map((installation) => installation.connectionId);
|
|
213
|
+
|
|
214
|
+
expect(connectionIds.indexOf(oldestAttempt.connectionId)).toBeLessThan(
|
|
215
|
+
connectionIds.indexOf(middleAttempt.connectionId),
|
|
216
|
+
);
|
|
217
|
+
expect(connectionIds.indexOf(middleAttempt.connectionId)).toBeLessThan(
|
|
218
|
+
connectionIds.indexOf(latestAttempt.connectionId),
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
|
|
108
222
|
it('returns undefined for unknown connection and webhook ids', async () => {
|
|
109
223
|
const connectionId = crypto.randomUUID();
|
|
110
224
|
const webhookId = Number.MAX_SAFE_INTEGER;
|
|
@@ -126,4 +240,36 @@ describe('jira installations', () => {
|
|
|
126
240
|
expect(revoked?.status).toBe('revoked');
|
|
127
241
|
expect(missing).toBeUndefined();
|
|
128
242
|
});
|
|
243
|
+
|
|
244
|
+
it('serializes webhook registration lock contenders for one connection', async () => {
|
|
245
|
+
const connectionId = crypto.randomUUID();
|
|
246
|
+
let releaseFirst!: () => void;
|
|
247
|
+
let firstEntered!: () => void;
|
|
248
|
+
const firstReady = new Promise<void>((resolve) => {
|
|
249
|
+
firstEntered = resolve;
|
|
250
|
+
});
|
|
251
|
+
const firstRelease = new Promise<void>((resolve) => {
|
|
252
|
+
releaseFirst = resolve;
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const first = withJiraWebhookRegistrationLock(connectionId, async () => {
|
|
256
|
+
firstEntered();
|
|
257
|
+
await firstRelease;
|
|
258
|
+
return 'first';
|
|
259
|
+
});
|
|
260
|
+
await firstReady;
|
|
261
|
+
|
|
262
|
+
let secondFinished = false;
|
|
263
|
+
const second = withJiraWebhookRegistrationLock(connectionId, () => {
|
|
264
|
+
secondFinished = true;
|
|
265
|
+
return Promise.resolve('second');
|
|
266
|
+
});
|
|
267
|
+
await Promise.resolve();
|
|
268
|
+
expect(secondFinished).toBe(false);
|
|
269
|
+
|
|
270
|
+
releaseFirst();
|
|
271
|
+
await expect(first).resolves.toBe('first');
|
|
272
|
+
await expect(second).resolves.toBe('second');
|
|
273
|
+
expect(secondFinished).toBe(true);
|
|
274
|
+
});
|
|
129
275
|
});
|
package/src/db/installations.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {isUniqueViolation} from '@shipfox/node-drizzle';
|
|
2
|
-
import {pgClient} from '@shipfox/node-postgres';
|
|
3
|
-
import {eq, sql} from 'drizzle-orm';
|
|
2
|
+
import {pgClient, withPostgresSession} from '@shipfox/node-postgres';
|
|
3
|
+
import {and, asc, eq, lte, sql} from 'drizzle-orm';
|
|
4
4
|
import {
|
|
5
5
|
JiraConnectionAlreadyLinkedError,
|
|
6
6
|
JiraInstallationAlreadyLinkedError,
|
|
@@ -9,6 +9,33 @@ import {
|
|
|
9
9
|
import {db} from './db.js';
|
|
10
10
|
import {jiraInstallations, toJiraInstallation} from './schema/installations.js';
|
|
11
11
|
|
|
12
|
+
export type JiraInstallationLock = <T>(lockKey: string, fn: () => Promise<T>) => Promise<T>;
|
|
13
|
+
|
|
14
|
+
const JIRA_ADVISORY_LOCK_RETRY_DELAY_MS = 100;
|
|
15
|
+
const JIRA_ADVISORY_LOCK_MAX_RETRY_DELAY_MS = 1_000;
|
|
16
|
+
const JIRA_ADVISORY_LOCK_TIMEOUT_MS = 30_000;
|
|
17
|
+
|
|
18
|
+
type JiraAdvisoryLockAttempt<T> = {acquired: true; value: T} | {acquired: false};
|
|
19
|
+
|
|
20
|
+
export function withJiraInstallationLock<T>(lockKey: string, fn: () => Promise<T>) {
|
|
21
|
+
const advisoryKey = `jira-installation:${lockKey}`;
|
|
22
|
+
return withJiraAdvisoryLockWait(`Jira installation lock: ${lockKey}`, () =>
|
|
23
|
+
withPostgresSession(async (client) => {
|
|
24
|
+
const lock = await client.query<{acquired: boolean}>(
|
|
25
|
+
'SELECT pg_try_advisory_lock(hashtext($1)) AS acquired',
|
|
26
|
+
[advisoryKey],
|
|
27
|
+
);
|
|
28
|
+
if (lock.rows[0]?.acquired !== true) return {acquired: false as const};
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
return {acquired: true as const, value: await fn()};
|
|
32
|
+
} finally {
|
|
33
|
+
await client.query('SELECT pg_advisory_unlock(hashtext($1))', [advisoryKey]);
|
|
34
|
+
}
|
|
35
|
+
}),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
12
39
|
export type JiraInstallationStatus = 'installed' | 'revoked';
|
|
13
40
|
|
|
14
41
|
export interface JiraInstallation {
|
|
@@ -23,6 +50,8 @@ export interface JiraInstallation {
|
|
|
23
50
|
webhookExpiresAt: Date | null;
|
|
24
51
|
status: JiraInstallationStatus;
|
|
25
52
|
tokenExpiresAt: Date | null;
|
|
53
|
+
refreshTokenLastUsedAt: Date;
|
|
54
|
+
refreshTokenLastAttemptedAt: Date;
|
|
26
55
|
createdAt: Date;
|
|
27
56
|
updatedAt: Date;
|
|
28
57
|
}
|
|
@@ -38,6 +67,8 @@ export interface UpsertJiraInstallationParams {
|
|
|
38
67
|
webhookExpiresAt?: Date | null | undefined;
|
|
39
68
|
status: JiraInstallationStatus;
|
|
40
69
|
tokenExpiresAt?: Date | null | undefined;
|
|
70
|
+
refreshTokenLastUsedAt?: Date | undefined;
|
|
71
|
+
refreshTokenLastAttemptedAt?: Date | undefined;
|
|
41
72
|
}
|
|
42
73
|
|
|
43
74
|
export interface UpdateJiraInstallationTokenExpiryParams {
|
|
@@ -46,6 +77,12 @@ export interface UpdateJiraInstallationTokenExpiryParams {
|
|
|
46
77
|
scopes?: string[] | undefined;
|
|
47
78
|
}
|
|
48
79
|
|
|
80
|
+
export interface UpdateJiraInstallationWebhookParams {
|
|
81
|
+
connectionId: string;
|
|
82
|
+
webhookIds: number[];
|
|
83
|
+
webhookExpiresAt: Date | null;
|
|
84
|
+
}
|
|
85
|
+
|
|
49
86
|
type JiraDb = ReturnType<typeof db>;
|
|
50
87
|
type JiraTx = Parameters<Parameters<JiraDb['transaction']>[0]>[0];
|
|
51
88
|
|
|
@@ -55,7 +92,6 @@ export async function upsertJiraInstallation(
|
|
|
55
92
|
): Promise<JiraInstallation> {
|
|
56
93
|
const executor = (options.tx ?? db()) as JiraDb | JiraTx;
|
|
57
94
|
const now = new Date();
|
|
58
|
-
const webhookIds = params.webhookIds ?? [];
|
|
59
95
|
let row: typeof jiraInstallations.$inferSelect | undefined;
|
|
60
96
|
try {
|
|
61
97
|
[row] = await executor
|
|
@@ -67,10 +103,12 @@ export async function upsertJiraInstallation(
|
|
|
67
103
|
siteName: params.siteName,
|
|
68
104
|
authorizingAccountId: params.authorizingAccountId,
|
|
69
105
|
scopes: params.scopes,
|
|
70
|
-
webhookIds,
|
|
106
|
+
webhookIds: params.webhookIds ?? [],
|
|
71
107
|
webhookExpiresAt: params.webhookExpiresAt ?? null,
|
|
72
108
|
status: params.status,
|
|
73
109
|
tokenExpiresAt: params.tokenExpiresAt ?? null,
|
|
110
|
+
refreshTokenLastUsedAt: params.refreshTokenLastUsedAt ?? now,
|
|
111
|
+
refreshTokenLastAttemptedAt: params.refreshTokenLastAttemptedAt ?? now,
|
|
74
112
|
})
|
|
75
113
|
.onConflictDoUpdate({
|
|
76
114
|
target: jiraInstallations.connectionId,
|
|
@@ -81,10 +119,14 @@ export async function upsertJiraInstallation(
|
|
|
81
119
|
siteName: params.siteName,
|
|
82
120
|
authorizingAccountId: params.authorizingAccountId,
|
|
83
121
|
scopes: params.scopes,
|
|
84
|
-
webhookIds,
|
|
85
|
-
|
|
122
|
+
...(params.webhookIds === undefined ? {} : {webhookIds: params.webhookIds}),
|
|
123
|
+
...(params.webhookExpiresAt === undefined
|
|
124
|
+
? {}
|
|
125
|
+
: {webhookExpiresAt: params.webhookExpiresAt}),
|
|
86
126
|
status: params.status,
|
|
87
127
|
tokenExpiresAt: params.tokenExpiresAt ?? null,
|
|
128
|
+
refreshTokenLastUsedAt: params.refreshTokenLastUsedAt ?? now,
|
|
129
|
+
refreshTokenLastAttemptedAt: params.refreshTokenLastAttemptedAt ?? now,
|
|
88
130
|
updatedAt: now,
|
|
89
131
|
},
|
|
90
132
|
})
|
|
@@ -122,11 +164,63 @@ export async function updateJiraInstallationTokenExpiry(
|
|
|
122
164
|
options: {tx?: unknown} = {},
|
|
123
165
|
): Promise<JiraInstallation | undefined> {
|
|
124
166
|
const executor = (options.tx ?? db()) as JiraDb | JiraTx;
|
|
167
|
+
const now = new Date();
|
|
125
168
|
const [row] = await executor
|
|
126
169
|
.update(jiraInstallations)
|
|
127
170
|
.set({
|
|
128
171
|
tokenExpiresAt: params.tokenExpiresAt,
|
|
172
|
+
refreshTokenLastUsedAt: now,
|
|
173
|
+
refreshTokenLastAttemptedAt: now,
|
|
129
174
|
...(params.scopes === undefined ? {} : {scopes: params.scopes}),
|
|
175
|
+
updatedAt: now,
|
|
176
|
+
})
|
|
177
|
+
.where(eq(jiraInstallations.connectionId, params.connectionId))
|
|
178
|
+
.returning();
|
|
179
|
+
return row ? toJiraInstallation(row) : undefined;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function markJiraInstallationTokenRefreshAttempt(
|
|
183
|
+
connectionId: string,
|
|
184
|
+
options: {tx?: unknown} = {},
|
|
185
|
+
): Promise<JiraInstallation | undefined> {
|
|
186
|
+
const executor = (options.tx ?? db()) as JiraDb | JiraTx;
|
|
187
|
+
const [row] = await executor
|
|
188
|
+
.update(jiraInstallations)
|
|
189
|
+
.set({refreshTokenLastAttemptedAt: new Date()})
|
|
190
|
+
.where(eq(jiraInstallations.connectionId, connectionId))
|
|
191
|
+
.returning();
|
|
192
|
+
return row ? toJiraInstallation(row) : undefined;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function listJiraInstallationsDueForTokenRefresh(
|
|
196
|
+
params: {before: Date; limit?: number | undefined},
|
|
197
|
+
options: {tx?: unknown} = {},
|
|
198
|
+
): Promise<JiraInstallation[]> {
|
|
199
|
+
const executor = (options.tx ?? db()) as JiraDb | JiraTx;
|
|
200
|
+
const rows = await executor
|
|
201
|
+
.select()
|
|
202
|
+
.from(jiraInstallations)
|
|
203
|
+
.where(
|
|
204
|
+
and(
|
|
205
|
+
eq(jiraInstallations.status, 'installed'),
|
|
206
|
+
lte(jiraInstallations.refreshTokenLastUsedAt, params.before),
|
|
207
|
+
),
|
|
208
|
+
)
|
|
209
|
+
.orderBy(asc(jiraInstallations.refreshTokenLastAttemptedAt))
|
|
210
|
+
.limit(params.limit ?? 100);
|
|
211
|
+
return rows.map(toJiraInstallation);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function updateJiraInstallationWebhook(
|
|
215
|
+
params: UpdateJiraInstallationWebhookParams,
|
|
216
|
+
options: {tx?: unknown} = {},
|
|
217
|
+
): Promise<JiraInstallation | undefined> {
|
|
218
|
+
const executor = (options.tx ?? db()) as JiraDb | JiraTx;
|
|
219
|
+
const [row] = await executor
|
|
220
|
+
.update(jiraInstallations)
|
|
221
|
+
.set({
|
|
222
|
+
webhookIds: params.webhookIds,
|
|
223
|
+
webhookExpiresAt: params.webhookExpiresAt,
|
|
130
224
|
updatedAt: new Date(),
|
|
131
225
|
})
|
|
132
226
|
.where(eq(jiraInstallations.connectionId, params.connectionId))
|
|
@@ -145,7 +239,7 @@ export async function deleteJiraInstallationByConnectionId(
|
|
|
145
239
|
return (result.rowCount ?? 0) > 0;
|
|
146
240
|
}
|
|
147
241
|
|
|
148
|
-
export type JiraRefreshLockResult<T> =
|
|
242
|
+
export type JiraRefreshLockResult<T> = JiraAdvisoryLockAttempt<T>;
|
|
149
243
|
|
|
150
244
|
export function withJiraRefreshLock<T>(
|
|
151
245
|
connectionId: string,
|
|
@@ -154,6 +248,22 @@ export function withJiraRefreshLock<T>(
|
|
|
154
248
|
return withJiraRefreshLockClient(connectionId, fn);
|
|
155
249
|
}
|
|
156
250
|
|
|
251
|
+
export function withJiraRefreshLockAndWait<T>(
|
|
252
|
+
connectionId: string,
|
|
253
|
+
fn: () => Promise<T>,
|
|
254
|
+
): Promise<T> {
|
|
255
|
+
return withJiraAdvisoryLockWait(`Jira refresh lock: ${connectionId}`, () =>
|
|
256
|
+
withJiraRefreshLock(connectionId, fn),
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function withJiraWebhookRegistrationLock<T>(
|
|
261
|
+
lockKey: string,
|
|
262
|
+
fn: (tx?: unknown) => Promise<T>,
|
|
263
|
+
): Promise<T> {
|
|
264
|
+
return withJiraInstallationLock(lockKey, () => fn());
|
|
265
|
+
}
|
|
266
|
+
|
|
157
267
|
async function withJiraRefreshLockClient<T>(
|
|
158
268
|
connectionId: string,
|
|
159
269
|
fn: () => Promise<T>,
|
|
@@ -177,6 +287,22 @@ async function withJiraRefreshLockClient<T>(
|
|
|
177
287
|
}
|
|
178
288
|
}
|
|
179
289
|
|
|
290
|
+
async function withJiraAdvisoryLockWait<T>(
|
|
291
|
+
lockDescription: string,
|
|
292
|
+
attemptLock: () => Promise<JiraAdvisoryLockAttempt<T>>,
|
|
293
|
+
): Promise<T> {
|
|
294
|
+
const deadline = Date.now() + JIRA_ADVISORY_LOCK_TIMEOUT_MS;
|
|
295
|
+
let retryDelayMs = JIRA_ADVISORY_LOCK_RETRY_DELAY_MS;
|
|
296
|
+
|
|
297
|
+
while (true) {
|
|
298
|
+
const attempt = await attemptLock();
|
|
299
|
+
if (attempt.acquired) return attempt.value;
|
|
300
|
+
if (Date.now() >= deadline) throw new Error(`Timed out waiting for ${lockDescription}`);
|
|
301
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
302
|
+
retryDelayMs = Math.min(retryDelayMs * 2, JIRA_ADVISORY_LOCK_MAX_RETRY_DELAY_MS);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
180
306
|
export async function getJiraInstallationByConnectionId(
|
|
181
307
|
connectionId: string,
|
|
182
308
|
options: {tx?: unknown} = {},
|