@shipfox/api-integration-jira 10.2.0 → 12.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 +33 -0
- package/README.md +1 -1
- package/dist/api/client.d.ts +15 -0
- package/dist/api/client.d.ts.map +1 -1
- package/dist/api/client.js +53 -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/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/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/installations.d.ts +11 -0
- package/dist/db/installations.d.ts.map +1 -1
- package/dist/db/installations.js +54 -5
- package/dist/db/installations.js.map +1 -1
- package/dist/index.d.ts +17 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +61 -5
- 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/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +9 -7
- package/src/api/client.test.ts +106 -0
- package/src/api/client.ts +85 -0
- package/src/config.test.ts +0 -1
- package/src/config.ts +1 -4
- package/src/core/install.test.ts +179 -0
- package/src/core/install.ts +85 -19
- package/src/core/tokens-refresh.test.ts +2 -0
- 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 +64 -0
- package/src/db/installations.ts +71 -5
- package/src/index.test.ts +8 -0
- package/src/index.ts +102 -3
- 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/test/env.ts +0 -1
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -9,7 +9,9 @@ import {
|
|
|
9
9
|
getJiraInstallationByWebhookId,
|
|
10
10
|
markJiraInstallationRevoked,
|
|
11
11
|
updateJiraInstallationTokenExpiry,
|
|
12
|
+
updateJiraInstallationWebhook,
|
|
12
13
|
upsertJiraInstallation,
|
|
14
|
+
withJiraWebhookRegistrationLock,
|
|
13
15
|
} from './installations.js';
|
|
14
16
|
|
|
15
17
|
function createInstallationInput(
|
|
@@ -62,6 +64,36 @@ describe('jira installations', () => {
|
|
|
62
64
|
});
|
|
63
65
|
});
|
|
64
66
|
|
|
67
|
+
it('preserves webhook metadata when reconnect upsert omits it', async () => {
|
|
68
|
+
const webhookExpiresAt = new Date('2030-01-01T00:00:00.000Z');
|
|
69
|
+
const input = createInstallationInput({webhookIds: [321], webhookExpiresAt});
|
|
70
|
+
await upsertJiraInstallation(input);
|
|
71
|
+
|
|
72
|
+
const result = await upsertJiraInstallation({
|
|
73
|
+
...input,
|
|
74
|
+
scopes: ['read:jira-work', 'write:jira-work'],
|
|
75
|
+
webhookIds: undefined,
|
|
76
|
+
webhookExpiresAt: undefined,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(result.webhookIds).toEqual([321]);
|
|
80
|
+
expect(result.webhookExpiresAt).toEqual(webhookExpiresAt);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('replaces webhook ids and expiry together after registration', async () => {
|
|
84
|
+
const input = createInstallationInput({webhookIds: [321]});
|
|
85
|
+
await upsertJiraInstallation(input);
|
|
86
|
+
const webhookExpiresAt = new Date('2030-02-01T00:00:00.000Z');
|
|
87
|
+
|
|
88
|
+
const result = await updateJiraInstallationWebhook({
|
|
89
|
+
connectionId: input.connectionId,
|
|
90
|
+
webhookIds: [654],
|
|
91
|
+
webhookExpiresAt,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(result).toMatchObject({webhookIds: [654], webhookExpiresAt});
|
|
95
|
+
});
|
|
96
|
+
|
|
65
97
|
it('refuses to repoint a connection to a different Jira site', async () => {
|
|
66
98
|
const first = createInstallationInput();
|
|
67
99
|
await upsertJiraInstallation(first);
|
|
@@ -126,4 +158,36 @@ describe('jira installations', () => {
|
|
|
126
158
|
expect(revoked?.status).toBe('revoked');
|
|
127
159
|
expect(missing).toBeUndefined();
|
|
128
160
|
});
|
|
161
|
+
|
|
162
|
+
it('serializes webhook registration lock contenders for one connection', async () => {
|
|
163
|
+
const connectionId = crypto.randomUUID();
|
|
164
|
+
let releaseFirst!: () => void;
|
|
165
|
+
let firstEntered!: () => void;
|
|
166
|
+
const firstReady = new Promise<void>((resolve) => {
|
|
167
|
+
firstEntered = resolve;
|
|
168
|
+
});
|
|
169
|
+
const firstRelease = new Promise<void>((resolve) => {
|
|
170
|
+
releaseFirst = resolve;
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const first = withJiraWebhookRegistrationLock(connectionId, async () => {
|
|
174
|
+
firstEntered();
|
|
175
|
+
await firstRelease;
|
|
176
|
+
return 'first';
|
|
177
|
+
});
|
|
178
|
+
await firstReady;
|
|
179
|
+
|
|
180
|
+
let secondFinished = false;
|
|
181
|
+
const second = withJiraWebhookRegistrationLock(connectionId, () => {
|
|
182
|
+
secondFinished = true;
|
|
183
|
+
return Promise.resolve('second');
|
|
184
|
+
});
|
|
185
|
+
await Promise.resolve();
|
|
186
|
+
expect(secondFinished).toBe(false);
|
|
187
|
+
|
|
188
|
+
releaseFirst();
|
|
189
|
+
await expect(first).resolves.toBe('first');
|
|
190
|
+
await expect(second).resolves.toBe('second');
|
|
191
|
+
expect(secondFinished).toBe(true);
|
|
192
|
+
});
|
|
129
193
|
});
|
package/src/db/installations.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {isUniqueViolation} from '@shipfox/node-drizzle';
|
|
2
|
-
import {pgClient} from '@shipfox/node-postgres';
|
|
2
|
+
import {pgClient, withPostgresSession} from '@shipfox/node-postgres';
|
|
3
3
|
import {eq, sql} from 'drizzle-orm';
|
|
4
4
|
import {
|
|
5
5
|
JiraConnectionAlreadyLinkedError,
|
|
@@ -9,6 +9,41 @@ 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_INSTALLATION_LOCK_RETRY_DELAY_MS = 100;
|
|
15
|
+
const JIRA_INSTALLATION_LOCK_MAX_RETRY_DELAY_MS = 1_000;
|
|
16
|
+
const JIRA_INSTALLATION_LOCK_TIMEOUT_MS = 30_000;
|
|
17
|
+
|
|
18
|
+
export async function withJiraInstallationLock<T>(lockKey: string, fn: () => Promise<T>) {
|
|
19
|
+
const advisoryKey = `jira-installation:${lockKey}`;
|
|
20
|
+
const deadline = Date.now() + JIRA_INSTALLATION_LOCK_TIMEOUT_MS;
|
|
21
|
+
let retryDelayMs = JIRA_INSTALLATION_LOCK_RETRY_DELAY_MS;
|
|
22
|
+
|
|
23
|
+
while (true) {
|
|
24
|
+
const attempt = await withPostgresSession(async (client) => {
|
|
25
|
+
const lock = await client.query<{acquired: boolean}>(
|
|
26
|
+
'SELECT pg_try_advisory_lock(hashtext($1)) AS acquired',
|
|
27
|
+
[advisoryKey],
|
|
28
|
+
);
|
|
29
|
+
if (lock.rows[0]?.acquired !== true) return {acquired: false as const};
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
return {acquired: true as const, value: await fn()};
|
|
33
|
+
} finally {
|
|
34
|
+
await client.query('SELECT pg_advisory_unlock(hashtext($1))', [advisoryKey]);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (attempt.acquired) return attempt.value;
|
|
39
|
+
if (Date.now() >= deadline) {
|
|
40
|
+
throw new Error(`Timed out waiting for Jira installation lock: ${lockKey}`);
|
|
41
|
+
}
|
|
42
|
+
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
43
|
+
retryDelayMs = Math.min(retryDelayMs * 2, JIRA_INSTALLATION_LOCK_MAX_RETRY_DELAY_MS);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
12
47
|
export type JiraInstallationStatus = 'installed' | 'revoked';
|
|
13
48
|
|
|
14
49
|
export interface JiraInstallation {
|
|
@@ -46,6 +81,12 @@ export interface UpdateJiraInstallationTokenExpiryParams {
|
|
|
46
81
|
scopes?: string[] | undefined;
|
|
47
82
|
}
|
|
48
83
|
|
|
84
|
+
export interface UpdateJiraInstallationWebhookParams {
|
|
85
|
+
connectionId: string;
|
|
86
|
+
webhookIds: number[];
|
|
87
|
+
webhookExpiresAt: Date | null;
|
|
88
|
+
}
|
|
89
|
+
|
|
49
90
|
type JiraDb = ReturnType<typeof db>;
|
|
50
91
|
type JiraTx = Parameters<Parameters<JiraDb['transaction']>[0]>[0];
|
|
51
92
|
|
|
@@ -55,7 +96,6 @@ export async function upsertJiraInstallation(
|
|
|
55
96
|
): Promise<JiraInstallation> {
|
|
56
97
|
const executor = (options.tx ?? db()) as JiraDb | JiraTx;
|
|
57
98
|
const now = new Date();
|
|
58
|
-
const webhookIds = params.webhookIds ?? [];
|
|
59
99
|
let row: typeof jiraInstallations.$inferSelect | undefined;
|
|
60
100
|
try {
|
|
61
101
|
[row] = await executor
|
|
@@ -67,7 +107,7 @@ export async function upsertJiraInstallation(
|
|
|
67
107
|
siteName: params.siteName,
|
|
68
108
|
authorizingAccountId: params.authorizingAccountId,
|
|
69
109
|
scopes: params.scopes,
|
|
70
|
-
webhookIds,
|
|
110
|
+
webhookIds: params.webhookIds ?? [],
|
|
71
111
|
webhookExpiresAt: params.webhookExpiresAt ?? null,
|
|
72
112
|
status: params.status,
|
|
73
113
|
tokenExpiresAt: params.tokenExpiresAt ?? null,
|
|
@@ -81,8 +121,10 @@ export async function upsertJiraInstallation(
|
|
|
81
121
|
siteName: params.siteName,
|
|
82
122
|
authorizingAccountId: params.authorizingAccountId,
|
|
83
123
|
scopes: params.scopes,
|
|
84
|
-
webhookIds,
|
|
85
|
-
|
|
124
|
+
...(params.webhookIds === undefined ? {} : {webhookIds: params.webhookIds}),
|
|
125
|
+
...(params.webhookExpiresAt === undefined
|
|
126
|
+
? {}
|
|
127
|
+
: {webhookExpiresAt: params.webhookExpiresAt}),
|
|
86
128
|
status: params.status,
|
|
87
129
|
tokenExpiresAt: params.tokenExpiresAt ?? null,
|
|
88
130
|
updatedAt: now,
|
|
@@ -134,6 +176,23 @@ export async function updateJiraInstallationTokenExpiry(
|
|
|
134
176
|
return row ? toJiraInstallation(row) : undefined;
|
|
135
177
|
}
|
|
136
178
|
|
|
179
|
+
export async function updateJiraInstallationWebhook(
|
|
180
|
+
params: UpdateJiraInstallationWebhookParams,
|
|
181
|
+
options: {tx?: unknown} = {},
|
|
182
|
+
): Promise<JiraInstallation | undefined> {
|
|
183
|
+
const executor = (options.tx ?? db()) as JiraDb | JiraTx;
|
|
184
|
+
const [row] = await executor
|
|
185
|
+
.update(jiraInstallations)
|
|
186
|
+
.set({
|
|
187
|
+
webhookIds: params.webhookIds,
|
|
188
|
+
webhookExpiresAt: params.webhookExpiresAt,
|
|
189
|
+
updatedAt: new Date(),
|
|
190
|
+
})
|
|
191
|
+
.where(eq(jiraInstallations.connectionId, params.connectionId))
|
|
192
|
+
.returning();
|
|
193
|
+
return row ? toJiraInstallation(row) : undefined;
|
|
194
|
+
}
|
|
195
|
+
|
|
137
196
|
export async function deleteJiraInstallationByConnectionId(
|
|
138
197
|
connectionId: string,
|
|
139
198
|
options: {tx?: unknown} = {},
|
|
@@ -154,6 +213,13 @@ export function withJiraRefreshLock<T>(
|
|
|
154
213
|
return withJiraRefreshLockClient(connectionId, fn);
|
|
155
214
|
}
|
|
156
215
|
|
|
216
|
+
export function withJiraWebhookRegistrationLock<T>(
|
|
217
|
+
lockKey: string,
|
|
218
|
+
fn: (tx?: unknown) => Promise<T>,
|
|
219
|
+
): Promise<T> {
|
|
220
|
+
return withJiraInstallationLock(lockKey, () => fn());
|
|
221
|
+
}
|
|
222
|
+
|
|
157
223
|
async function withJiraRefreshLockClient<T>(
|
|
158
224
|
connectionId: string,
|
|
159
225
|
fn: () => Promise<T>,
|
package/src/index.test.ts
CHANGED
|
@@ -11,4 +11,12 @@ describe('createJiraIntegrationProvider', () => {
|
|
|
11
11
|
routes: [],
|
|
12
12
|
});
|
|
13
13
|
});
|
|
14
|
+
|
|
15
|
+
it('rejects incomplete receiver wiring instead of mounting registration without a receiver', () => {
|
|
16
|
+
expect(() =>
|
|
17
|
+
createJiraIntegrationProvider({
|
|
18
|
+
routes: {tokenStore: {} as never} as never,
|
|
19
|
+
}),
|
|
20
|
+
).toThrow('requires all webhook receiver dependencies');
|
|
21
|
+
});
|
|
14
22
|
});
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import {JIRA_PROVIDER} from '@shipfox/api-integration-jira-dto';
|
|
2
2
|
import {createJiraApiClient, type JiraApiClient} from '#api/client.js';
|
|
3
3
|
import {config} from '#config.js';
|
|
4
|
+
import {createJiraWebhookProcessor} from '#core/webhook-processor.js';
|
|
5
|
+
import {registerJiraWebhook} from '#core/webhook-registration.js';
|
|
4
6
|
import {closeDb, db} from '#db/db.js';
|
|
5
7
|
import {getJiraInstallationByConnectionId} from '#db/installations.js';
|
|
6
8
|
import {migrationsPath} from '#db/migrations.js';
|
|
@@ -8,15 +10,28 @@ import {
|
|
|
8
10
|
type CreateJiraIntegrationRoutesOptions,
|
|
9
11
|
createJiraIntegrationRoutes,
|
|
10
12
|
} from '#presentation/routes/install.js';
|
|
13
|
+
import {
|
|
14
|
+
type CreateJiraWebhookRoutesOptions,
|
|
15
|
+
createJiraWebhookRoutes,
|
|
16
|
+
JIRA_WEBHOOK_ROUTE_PREFIX,
|
|
17
|
+
} from '#presentation/routes/webhooks.js';
|
|
18
|
+
|
|
19
|
+
const TRAILING_SLASHES_RE = /\/+$/;
|
|
11
20
|
|
|
12
21
|
export type {JiraProvider} from '@shipfox/api-integration-jira-dto';
|
|
13
22
|
export type {
|
|
14
23
|
JiraAccessibleResource,
|
|
15
24
|
JiraApiClient,
|
|
16
25
|
JiraAuthorization,
|
|
26
|
+
JiraDynamicWebhookRegistration,
|
|
17
27
|
JiraIdentity,
|
|
18
28
|
} from '#api/client.js';
|
|
19
|
-
export {
|
|
29
|
+
export {
|
|
30
|
+
createJiraApiClient,
|
|
31
|
+
JIRA_DYNAMIC_WEBHOOK_EVENTS,
|
|
32
|
+
JIRA_DYNAMIC_WEBHOOK_JQL,
|
|
33
|
+
mapJiraError,
|
|
34
|
+
} from '#api/client.js';
|
|
20
35
|
export type {DisconnectJiraInstallationParams} from '#core/disconnect.js';
|
|
21
36
|
export {disconnectJiraInstallation} from '#core/disconnect.js';
|
|
22
37
|
export {
|
|
@@ -58,9 +73,18 @@ export type {
|
|
|
58
73
|
StoreJiraTokensParams,
|
|
59
74
|
} from '#core/tokens.js';
|
|
60
75
|
export {createJiraTokenStore, jiraSecretsNamespace} from '#core/tokens.js';
|
|
76
|
+
export type {
|
|
77
|
+
CreateJiraWebhookProcessorOptions,
|
|
78
|
+
JiraWebhookProcessor,
|
|
79
|
+
} from '#core/webhook-processor.js';
|
|
80
|
+
export {createJiraWebhookProcessor} from '#core/webhook-processor.js';
|
|
81
|
+
export type {RegisterJiraWebhookParams} from '#core/webhook-registration.js';
|
|
82
|
+
export {JIRA_WEBHOOK_TTL_MS, registerJiraWebhook} from '#core/webhook-registration.js';
|
|
61
83
|
export type {
|
|
62
84
|
JiraInstallation,
|
|
85
|
+
JiraInstallationLock,
|
|
63
86
|
JiraInstallationStatus,
|
|
87
|
+
UpdateJiraInstallationWebhookParams,
|
|
64
88
|
UpsertJiraInstallationParams,
|
|
65
89
|
} from '#db/installations.js';
|
|
66
90
|
export {
|
|
@@ -70,23 +94,70 @@ export {
|
|
|
70
94
|
getJiraInstallationByWebhookId,
|
|
71
95
|
markJiraInstallationRevoked,
|
|
72
96
|
updateJiraInstallationTokenExpiry,
|
|
97
|
+
updateJiraInstallationWebhook,
|
|
73
98
|
upsertJiraInstallation,
|
|
74
99
|
withJiraRefreshLock,
|
|
100
|
+
withJiraWebhookRegistrationLock,
|
|
75
101
|
} from '#db/installations.js';
|
|
102
|
+
export type {CreateJiraWebhookRoutesOptions} from '#presentation/routes/webhooks.js';
|
|
103
|
+
export {createJiraWebhookRoutes} from '#presentation/routes/webhooks.js';
|
|
76
104
|
export {closeDb, config, db, migrationsPath};
|
|
77
105
|
|
|
78
106
|
export interface CreateJiraIntegrationProviderOptions {
|
|
79
107
|
jira?: JiraApiClient | undefined;
|
|
80
108
|
getJiraInstallationByConnectionId?: typeof getJiraInstallationByConnectionId | undefined;
|
|
81
|
-
routes?:
|
|
109
|
+
routes?: JiraIntegrationProviderRoutesOptions | undefined;
|
|
82
110
|
}
|
|
83
111
|
|
|
112
|
+
type JiraIntegrationProviderRoutesOptions = Omit<
|
|
113
|
+
CreateJiraIntegrationRoutesOptions,
|
|
114
|
+
'jira' | 'connectionCapabilities' | 'registerJiraWebhook'
|
|
115
|
+
> &
|
|
116
|
+
Omit<CreateJiraWebhookRoutesOptions, 'processor'>;
|
|
117
|
+
|
|
84
118
|
export function createJiraIntegrationProvider(options: CreateJiraIntegrationProviderOptions = {}) {
|
|
85
119
|
const jira = options.jira ?? createJiraApiClient();
|
|
86
120
|
const getInstallationByConnectionId =
|
|
87
121
|
options.getJiraInstallationByConnectionId ?? getJiraInstallationByConnectionId;
|
|
122
|
+
const webhookOptions = toJiraWebhookOptions(options.routes);
|
|
123
|
+
const webhookProcessor = webhookOptions ? createJiraWebhookProcessor(webhookOptions) : undefined;
|
|
124
|
+
const webhookRoutes = webhookOptions
|
|
125
|
+
? [
|
|
126
|
+
createJiraWebhookRoutes({
|
|
127
|
+
coreDb: webhookOptions.coreDb,
|
|
128
|
+
publishIntegrationEventReceived: webhookOptions.publishIntegrationEventReceived,
|
|
129
|
+
recordDeliveryOnly: webhookOptions.recordDeliveryOnly,
|
|
130
|
+
getIntegrationConnectionById: webhookOptions.getIntegrationConnectionById,
|
|
131
|
+
processor: webhookProcessor,
|
|
132
|
+
}),
|
|
133
|
+
]
|
|
134
|
+
: [];
|
|
88
135
|
const routes = options.routes
|
|
89
|
-
? [
|
|
136
|
+
? [
|
|
137
|
+
createJiraIntegrationRoutes({
|
|
138
|
+
jira,
|
|
139
|
+
connectionCapabilities: [],
|
|
140
|
+
...options.routes,
|
|
141
|
+
registerJiraWebhook: (input) =>
|
|
142
|
+
registerJiraWebhook({
|
|
143
|
+
jira,
|
|
144
|
+
connectionId: input.connectionId,
|
|
145
|
+
cloudId: input.cloudId,
|
|
146
|
+
accessToken: input.accessToken,
|
|
147
|
+
webhookUrl: jiraWebhookUrl(input.connectionId),
|
|
148
|
+
...(input.withRegistrationLock
|
|
149
|
+
? {withRegistrationLock: input.withRegistrationLock}
|
|
150
|
+
: {}),
|
|
151
|
+
...(input.onRegistrationSuccess
|
|
152
|
+
? {onRegistrationSuccess: input.onRegistrationSuccess}
|
|
153
|
+
: {}),
|
|
154
|
+
...(input.onRegistrationFailure
|
|
155
|
+
? {onRegistrationFailure: input.onRegistrationFailure}
|
|
156
|
+
: {}),
|
|
157
|
+
}).then(() => undefined),
|
|
158
|
+
}),
|
|
159
|
+
...webhookRoutes,
|
|
160
|
+
]
|
|
90
161
|
: [];
|
|
91
162
|
return {
|
|
92
163
|
provider: JIRA_PROVIDER,
|
|
@@ -96,5 +167,33 @@ export function createJiraIntegrationProvider(options: CreateJiraIntegrationProv
|
|
|
96
167
|
return (await getInstallationByConnectionId(connection.id))?.siteUrl;
|
|
97
168
|
},
|
|
98
169
|
routes,
|
|
170
|
+
webhookProcessors: webhookProcessor
|
|
171
|
+
? [{routeIds: ['jira'] as const, processor: webhookProcessor}]
|
|
172
|
+
: undefined,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function jiraWebhookUrl(connectionId: string): string {
|
|
177
|
+
return `${config.JIRA_WEBHOOK_BASE_URL.replace(TRAILING_SLASHES_RE, '')}${JIRA_WEBHOOK_ROUTE_PREFIX}/${connectionId}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function toJiraWebhookOptions(
|
|
181
|
+
routes: CreateJiraIntegrationProviderOptions['routes'],
|
|
182
|
+
): CreateJiraWebhookRoutesOptions | undefined {
|
|
183
|
+
if (!routes) return undefined;
|
|
184
|
+
if (
|
|
185
|
+
routes.coreDb === undefined ||
|
|
186
|
+
routes.publishIntegrationEventReceived === undefined ||
|
|
187
|
+
routes.recordDeliveryOnly === undefined ||
|
|
188
|
+
routes.getIntegrationConnectionById === undefined
|
|
189
|
+
)
|
|
190
|
+
throw new Error(
|
|
191
|
+
'Jira integration provider requires all webhook receiver dependencies: coreDb, publishIntegrationEventReceived, recordDeliveryOnly, and getIntegrationConnectionById',
|
|
192
|
+
);
|
|
193
|
+
return {
|
|
194
|
+
coreDb: routes.coreDb,
|
|
195
|
+
publishIntegrationEventReceived: routes.publishIntegrationEventReceived,
|
|
196
|
+
recordDeliveryOnly: routes.recordDeliveryOnly,
|
|
197
|
+
getIntegrationConnectionById: routes.getIntegrationConnectionById,
|
|
99
198
|
};
|
|
100
199
|
}
|
|
@@ -25,6 +25,7 @@ import type {JiraPendingSelectionStore} from '#core/pending.js';
|
|
|
25
25
|
import {formatJiraOAuthScopes} from '#core/scopes.js';
|
|
26
26
|
import {signJiraInstallState} from '#core/state.js';
|
|
27
27
|
import type {JiraTokenStore} from '#core/tokens.js';
|
|
28
|
+
import type {JiraInstallationLock} from '#db/installations.js';
|
|
28
29
|
import {toIntegrationConnectionDto} from '#presentation/dto/integrations.js';
|
|
29
30
|
import {jiraRouteErrorHandler} from './errors.js';
|
|
30
31
|
|
|
@@ -38,6 +39,17 @@ export interface CreateJiraIntegrationRoutesOptions {
|
|
|
38
39
|
connectJiraInstallation(
|
|
39
40
|
input: ConnectJiraInstallationInput,
|
|
40
41
|
): Promise<IntegrationConnection<'jira'>>;
|
|
42
|
+
registerJiraWebhook(input: {
|
|
43
|
+
connectionId: string;
|
|
44
|
+
cloudId: string;
|
|
45
|
+
accessToken: string;
|
|
46
|
+
withRegistrationLock?: JiraInstallationLock;
|
|
47
|
+
onRegistrationSuccess: (input: {tx?: unknown}) => Promise<void>;
|
|
48
|
+
onRegistrationFailure: (input: {tx?: unknown}) => Promise<void>;
|
|
49
|
+
}): Promise<void>;
|
|
50
|
+
withJiraInstallationLock?: JiraInstallationLock;
|
|
51
|
+
markConnectionActive(input: {connectionId: string; tx?: unknown}): Promise<void>;
|
|
52
|
+
markConnectionError(input: {connectionId: string; tx?: unknown}): Promise<void>;
|
|
41
53
|
disconnectJiraInstallation(input: {connectionId: string}): Promise<void>;
|
|
42
54
|
connectionCapabilities: IntegrationCapability[];
|
|
43
55
|
requireActiveWorkspaceMembership?: (input: {
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import {Buffer} from 'node:buffer';
|
|
2
|
+
import {WEBHOOK_MAX_RAW_BODY_BYTES} from '@shipfox/api-integration-spi';
|
|
3
|
+
import {closeApp, createApp} from '@shipfox/node-fastify';
|
|
4
|
+
import type {FastifyInstance} from 'fastify';
|
|
5
|
+
import type {JiraWebhookProcessor} from '#core/webhook-processor.js';
|
|
6
|
+
import {createJiraWebhookRoutes} from './webhooks.js';
|
|
7
|
+
|
|
8
|
+
function createTestApp(processor: JiraWebhookProcessor): Promise<FastifyInstance> {
|
|
9
|
+
return createApp({
|
|
10
|
+
routes: [
|
|
11
|
+
createJiraWebhookRoutes({
|
|
12
|
+
coreDb: vi.fn() as never,
|
|
13
|
+
publishIntegrationEventReceived: vi.fn() as never,
|
|
14
|
+
recordDeliveryOnly: vi.fn() as never,
|
|
15
|
+
getIntegrationConnectionById: vi.fn() as never,
|
|
16
|
+
processor,
|
|
17
|
+
}),
|
|
18
|
+
],
|
|
19
|
+
swagger: false,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('Jira webhook route', () => {
|
|
24
|
+
afterEach(async () => {
|
|
25
|
+
await closeApp();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('stores the connection path and lower-case authorization header', async () => {
|
|
29
|
+
const process = vi.fn().mockResolvedValue({outcome: 'processed', deliveryId: 'delivery-1'});
|
|
30
|
+
const processor = {process: process as JiraWebhookProcessor['process']};
|
|
31
|
+
const app = await createTestApp(processor);
|
|
32
|
+
const connectionId = 'c0a8012e-0b6d-4d8f-8d5c-6d74102602b0';
|
|
33
|
+
|
|
34
|
+
const response = await app.inject({
|
|
35
|
+
method: 'POST',
|
|
36
|
+
url: `/webhooks/integrations/jira/${connectionId}`,
|
|
37
|
+
headers: {
|
|
38
|
+
authorization: 'Bearer signed-token',
|
|
39
|
+
'content-type': 'application/json',
|
|
40
|
+
'x-atlassian-webhook-identifier': 'jira-delivery-1',
|
|
41
|
+
},
|
|
42
|
+
payload: '{}',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
expect(response.statusCode).toBe(200);
|
|
46
|
+
expect(process).toHaveBeenCalledWith(
|
|
47
|
+
expect.objectContaining({
|
|
48
|
+
route_id: 'jira',
|
|
49
|
+
path_parameters: {connection_id: connectionId},
|
|
50
|
+
headers: {
|
|
51
|
+
authorization: 'Bearer signed-token',
|
|
52
|
+
'content-type': 'application/json',
|
|
53
|
+
'x-atlassian-webhook-identifier': 'jira-delivery-1',
|
|
54
|
+
},
|
|
55
|
+
}),
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('returns 401 for an authentication failure without recording a delivery', async () => {
|
|
60
|
+
const process = vi.fn().mockResolvedValue({
|
|
61
|
+
outcome: 'discarded',
|
|
62
|
+
reason: 'invalid_signature',
|
|
63
|
+
deliveryId: 'delivery-1',
|
|
64
|
+
});
|
|
65
|
+
const processor = {process: process as JiraWebhookProcessor['process']};
|
|
66
|
+
const app = await createTestApp(processor);
|
|
67
|
+
|
|
68
|
+
const response = await app.inject({
|
|
69
|
+
method: 'POST',
|
|
70
|
+
url: '/webhooks/integrations/jira/c0a8012e-0b6d-4d8f-8d5c-6d74102602b0',
|
|
71
|
+
headers: {'content-type': 'application/json'},
|
|
72
|
+
payload: '{}',
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(response.statusCode).toBe(401);
|
|
76
|
+
expect(response.json()).toEqual({error: 'invalid authorization'});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it.each([
|
|
80
|
+
['a processed delivery', {outcome: 'processed', deliveryId: 'delivery-1'}],
|
|
81
|
+
['a duplicate delivery', {outcome: 'duplicate', deliveryId: 'delivery-1'}],
|
|
82
|
+
['a deliberate drop', {outcome: 'discarded', reason: 'connection_unavailable'}],
|
|
83
|
+
] as const)('returns 200 for %s', async (_description, result) => {
|
|
84
|
+
const process = vi.fn().mockResolvedValue(result);
|
|
85
|
+
const app = await createTestApp({process: process as JiraWebhookProcessor['process']});
|
|
86
|
+
|
|
87
|
+
const response = await app.inject({
|
|
88
|
+
method: 'POST',
|
|
89
|
+
url: '/webhooks/integrations/jira/c0a8012e-0b6d-4d8f-8d5c-6d74102602b0',
|
|
90
|
+
headers: {authorization: 'Bearer signed-token', 'content-type': 'application/json'},
|
|
91
|
+
payload: '{}',
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(response.statusCode).toBe(200);
|
|
95
|
+
expect(process).toHaveBeenCalledOnce();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('returns 400 for a malformed payload result', async () => {
|
|
99
|
+
const process = vi.fn().mockResolvedValue({
|
|
100
|
+
outcome: 'discarded',
|
|
101
|
+
reason: 'malformed_payload',
|
|
102
|
+
deliveryId: 'delivery-1',
|
|
103
|
+
});
|
|
104
|
+
const app = await createTestApp({process: process as JiraWebhookProcessor['process']});
|
|
105
|
+
|
|
106
|
+
const response = await app.inject({
|
|
107
|
+
method: 'POST',
|
|
108
|
+
url: '/webhooks/integrations/jira/c0a8012e-0b6d-4d8f-8d5c-6d74102602b0',
|
|
109
|
+
headers: {authorization: 'Bearer signed-token', 'content-type': 'application/json'},
|
|
110
|
+
payload: '{}',
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(response.statusCode).toBe(400);
|
|
114
|
+
expect(response.json()).toEqual({error: 'malformed JSON'});
|
|
115
|
+
expect(process).toHaveBeenCalledOnce();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('returns 413 for oversized input without invoking the processor', async () => {
|
|
119
|
+
const process = vi.fn();
|
|
120
|
+
const app = await createTestApp({process: process as JiraWebhookProcessor['process']});
|
|
121
|
+
|
|
122
|
+
const response = await app.inject({
|
|
123
|
+
method: 'POST',
|
|
124
|
+
url: '/webhooks/integrations/jira/c0a8012e-0b6d-4d8f-8d5c-6d74102602b0',
|
|
125
|
+
headers: {authorization: 'Bearer signed-token', 'content-type': 'application/json'},
|
|
126
|
+
payload: Buffer.alloc(WEBHOOK_MAX_RAW_BODY_BYTES + 1, 97),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(response.statusCode).toBe(413);
|
|
130
|
+
expect(process).not.toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import {randomUUID} from 'node:crypto';
|
|
2
|
+
import type {StoredWebhookRequest, WebhookProcessingResult} from '@shipfox/api-integration-spi';
|
|
3
|
+
import {createStoredWebhookRequest, WEBHOOK_MAX_RAW_BODY_BYTES} from '@shipfox/api-integration-spi';
|
|
4
|
+
import {
|
|
5
|
+
ClientError,
|
|
6
|
+
defineRoute,
|
|
7
|
+
type RouteGroup,
|
|
8
|
+
rawBodyPlugin,
|
|
9
|
+
WEBHOOK_BODY_LIMIT,
|
|
10
|
+
} from '@shipfox/node-fastify';
|
|
11
|
+
import {z} from 'zod';
|
|
12
|
+
import {
|
|
13
|
+
type CreateJiraWebhookProcessorOptions,
|
|
14
|
+
createJiraWebhookProcessor,
|
|
15
|
+
type JiraWebhookProcessor,
|
|
16
|
+
} from '#core/webhook-processor.js';
|
|
17
|
+
|
|
18
|
+
const jiraWebhookParamsSchema = z.object({connectionId: z.string().uuid()});
|
|
19
|
+
export const JIRA_WEBHOOK_ROUTE_PREFIX = '/webhooks/integrations/jira';
|
|
20
|
+
|
|
21
|
+
export interface CreateJiraWebhookRoutesOptions
|
|
22
|
+
extends Omit<CreateJiraWebhookProcessorOptions, 'getJiraInstallationByConnectionId'> {
|
|
23
|
+
processor?: JiraWebhookProcessor | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createJiraWebhookRoutes(options: CreateJiraWebhookRoutesOptions): RouteGroup {
|
|
27
|
+
const processor = options.processor ?? createJiraWebhookProcessor(options);
|
|
28
|
+
const route = defineRoute({
|
|
29
|
+
method: 'POST',
|
|
30
|
+
path: '/:connectionId',
|
|
31
|
+
auth: [],
|
|
32
|
+
description: 'Jira dynamic webhook receiver.',
|
|
33
|
+
options: {bodyLimit: WEBHOOK_BODY_LIMIT},
|
|
34
|
+
schema: {params: jiraWebhookParamsSchema},
|
|
35
|
+
handler: async (request, reply) => {
|
|
36
|
+
const body = request.body;
|
|
37
|
+
if (!(body instanceof Uint8Array)) {
|
|
38
|
+
throw new ClientError('Expected raw JSON body', 'invalid-webhook-request', {status: 400});
|
|
39
|
+
}
|
|
40
|
+
const result = await processor.process(
|
|
41
|
+
createJiraStoredWebhookRequest({
|
|
42
|
+
body,
|
|
43
|
+
connectionId: request.params.connectionId,
|
|
44
|
+
headers: request.headers,
|
|
45
|
+
rawQueryString: request.raw.url?.split('?')[1] ?? '',
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
return sendJiraWebhookResponse(reply, result);
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
prefix: JIRA_WEBHOOK_ROUTE_PREFIX,
|
|
54
|
+
auth: [],
|
|
55
|
+
plugins: [rawBodyPlugin],
|
|
56
|
+
routes: [route],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function createJiraStoredWebhookRequest(input: {
|
|
61
|
+
body: Uint8Array;
|
|
62
|
+
connectionId: string;
|
|
63
|
+
headers: Record<string, string | string[] | undefined>;
|
|
64
|
+
rawQueryString: string;
|
|
65
|
+
}): StoredWebhookRequest {
|
|
66
|
+
if (input.body.byteLength > WEBHOOK_MAX_RAW_BODY_BYTES) {
|
|
67
|
+
throw new ClientError('Webhook request body is too large', 'body-too-large', {status: 413});
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
return createStoredWebhookRequest({
|
|
71
|
+
requestId: randomUUID(),
|
|
72
|
+
routeId: 'jira',
|
|
73
|
+
receivedAt: new Date().toISOString(),
|
|
74
|
+
rawQueryString: input.rawQueryString,
|
|
75
|
+
headers: jiraWebhookHeaders(input.headers),
|
|
76
|
+
body: input.body,
|
|
77
|
+
connectionId: input.connectionId,
|
|
78
|
+
});
|
|
79
|
+
} catch (error) {
|
|
80
|
+
throw new ClientError('Webhook request metadata is invalid', 'invalid-webhook-request', {
|
|
81
|
+
cause: error,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function jiraWebhookHeaders(
|
|
87
|
+
headers: Record<string, string | string[] | undefined>,
|
|
88
|
+
): Record<string, string> {
|
|
89
|
+
return Object.fromEntries(
|
|
90
|
+
['authorization', 'content-type', 'x-atlassian-webhook-identifier'].flatMap((name) => {
|
|
91
|
+
const value = headers[name];
|
|
92
|
+
return typeof value === 'string' ? [[name, value]] : [];
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function sendJiraWebhookResponse(
|
|
98
|
+
reply: {code(statusCode: number): void},
|
|
99
|
+
result: WebhookProcessingResult,
|
|
100
|
+
) {
|
|
101
|
+
if (result.outcome !== 'discarded') {
|
|
102
|
+
reply.code(200);
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
if (result.reason === 'invalid_signature' || result.reason === 'missing_required_input') {
|
|
106
|
+
reply.code(401);
|
|
107
|
+
return {error: 'invalid authorization'};
|
|
108
|
+
}
|
|
109
|
+
if (result.reason === 'malformed_payload') {
|
|
110
|
+
reply.code(400);
|
|
111
|
+
return {error: 'malformed JSON'};
|
|
112
|
+
}
|
|
113
|
+
reply.code(200);
|
|
114
|
+
return null;
|
|
115
|
+
}
|
package/test/env.ts
CHANGED
|
@@ -8,7 +8,6 @@ process.env.TZ = 'UTC';
|
|
|
8
8
|
process.env.JIRA_OAUTH_CLIENT_ID = 'test-client-id';
|
|
9
9
|
process.env.JIRA_OAUTH_CLIENT_SECRET = 'test-client-secret';
|
|
10
10
|
process.env.JIRA_OAUTH_REDIRECT_URL = 'https://shipfox.example.com/integrations/jira/callback';
|
|
11
|
-
process.env.JIRA_WEBHOOK_SIGNING_SECRET = 'test-signing-secret';
|
|
12
11
|
process.env.JIRA_WEBHOOK_BASE_URL = 'https://shipfox.example.com';
|
|
13
12
|
process.env.JIRA_API_BASE_URL = 'http://127.0.0.1:0';
|
|
14
13
|
process.env.JIRA_AUTH_BASE_URL = 'http://127.0.0.1:0';
|