@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.
Files changed (65) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +33 -0
  3. package/README.md +1 -1
  4. package/dist/api/client.d.ts +15 -0
  5. package/dist/api/client.d.ts.map +1 -1
  6. package/dist/api/client.js +53 -0
  7. package/dist/api/client.js.map +1 -1
  8. package/dist/config.d.ts +0 -1
  9. package/dist/config.d.ts.map +1 -1
  10. package/dist/config.js +1 -4
  11. package/dist/config.js.map +1 -1
  12. package/dist/core/install.d.ts +22 -0
  13. package/dist/core/install.d.ts.map +1 -1
  14. package/dist/core/install.js +68 -20
  15. package/dist/core/install.js.map +1 -1
  16. package/dist/core/webhook-processor.d.ts +15 -0
  17. package/dist/core/webhook-processor.d.ts.map +1 -0
  18. package/dist/core/webhook-processor.js +137 -0
  19. package/dist/core/webhook-processor.js.map +1 -0
  20. package/dist/core/webhook-registration.d.ts +26 -0
  21. package/dist/core/webhook-registration.d.ts.map +1 -0
  22. package/dist/core/webhook-registration.js +165 -0
  23. package/dist/core/webhook-registration.js.map +1 -0
  24. package/dist/core/webhook.d.ts +18 -0
  25. package/dist/core/webhook.d.ts.map +1 -0
  26. package/dist/core/webhook.js +38 -0
  27. package/dist/core/webhook.js.map +1 -0
  28. package/dist/db/installations.d.ts +11 -0
  29. package/dist/db/installations.d.ts.map +1 -1
  30. package/dist/db/installations.js +54 -5
  31. package/dist/db/installations.js.map +1 -1
  32. package/dist/index.d.ts +17 -5
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +61 -5
  35. package/dist/index.js.map +1 -1
  36. package/dist/presentation/routes/install.d.ts +22 -0
  37. package/dist/presentation/routes/install.d.ts.map +1 -1
  38. package/dist/presentation/routes/install.js.map +1 -1
  39. package/dist/presentation/routes/webhooks.d.ts +8 -0
  40. package/dist/presentation/routes/webhooks.d.ts.map +1 -0
  41. package/dist/presentation/routes/webhooks.js +108 -0
  42. package/dist/presentation/routes/webhooks.js.map +1 -0
  43. package/dist/tsconfig.test.tsbuildinfo +1 -1
  44. package/package.json +9 -7
  45. package/src/api/client.test.ts +106 -0
  46. package/src/api/client.ts +85 -0
  47. package/src/config.test.ts +0 -1
  48. package/src/config.ts +1 -4
  49. package/src/core/install.test.ts +179 -0
  50. package/src/core/install.ts +85 -19
  51. package/src/core/tokens-refresh.test.ts +2 -0
  52. package/src/core/webhook-processor.test.ts +418 -0
  53. package/src/core/webhook-processor.ts +166 -0
  54. package/src/core/webhook-registration.test.ts +375 -0
  55. package/src/core/webhook-registration.ts +216 -0
  56. package/src/core/webhook.ts +69 -0
  57. package/src/db/installations.test.ts +64 -0
  58. package/src/db/installations.ts +71 -5
  59. package/src/index.test.ts +8 -0
  60. package/src/index.ts +102 -3
  61. package/src/presentation/routes/install.ts +12 -0
  62. package/src/presentation/routes/webhooks.test.ts +132 -0
  63. package/src/presentation/routes/webhooks.ts +115 -0
  64. package/test/env.ts +0 -1
  65. package/tsconfig.build.tsbuildinfo +1 -1
@@ -0,0 +1,137 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { createHash } from 'node:crypto';
3
+ import { jiraWebhookEnvelopeSchema } from '@shipfox/api-integration-jira-dto';
4
+ import { decodeWebhookBody } from '@shipfox/api-integration-spi';
5
+ import { extractBearerToken } from '@shipfox/node-fastify';
6
+ import { verifyHs256 } from '@shipfox/node-jwt';
7
+ import { logger } from '@shipfox/node-opentelemetry';
8
+ import { z } from 'zod';
9
+ import { config } from '#config.js';
10
+ import { handleJiraWebhook, isJiraInstallationUsable } from '#core/webhook.js';
11
+ import { getJiraInstallationByConnectionId } from '#db/installations.js';
12
+ const JIRA_PROVIDER = 'jira';
13
+ const jiraWebhookJwtClaimsSchema = z.object({
14
+ iat: z.number().int(),
15
+ exp: z.number().int()
16
+ }).passthrough();
17
+ export function createJiraWebhookProcessor(options) {
18
+ return {
19
+ process: (request)=>processJiraWebhookRequest(options, request)
20
+ };
21
+ }
22
+ async function processJiraWebhookRequest(options, request) {
23
+ if (request.route_id !== 'jira') {
24
+ throw new Error(`Jira processor cannot process ${request.route_id} requests`);
25
+ }
26
+ const rawBody = Buffer.from(decodeWebhookBody(request.body));
27
+ const deliveryId = jiraDeliveryId(request, rawBody);
28
+ const authorization = request.headers.authorization;
29
+ if (!authorization) return invalidAuthorization(deliveryId);
30
+ const token = extractBearerToken(authorization);
31
+ if (!token) return invalidAuthorization(deliveryId);
32
+ try {
33
+ await verifyHs256({
34
+ token,
35
+ secret: config.JIRA_OAUTH_CLIENT_SECRET,
36
+ schema: jiraWebhookJwtClaimsSchema,
37
+ verificationTime: new Date(request.received_at)
38
+ });
39
+ } catch (error) {
40
+ logger().warn({
41
+ deliveryId,
42
+ errName: error instanceof Error ? error.name : typeof error
43
+ }, 'Jira webhook authorization verification failed');
44
+ return invalidAuthorization(deliveryId);
45
+ }
46
+ let rawPayload;
47
+ try {
48
+ rawPayload = JSON.parse(rawBody.toString('utf8'));
49
+ } catch (error) {
50
+ logger().warn({
51
+ deliveryId,
52
+ err: error
53
+ }, 'Jira webhook payload JSON parse failed');
54
+ return {
55
+ outcome: 'discarded',
56
+ reason: 'malformed_payload',
57
+ deliveryId
58
+ };
59
+ }
60
+ const payload = jiraWebhookEnvelopeSchema.safeParse(rawPayload);
61
+ if (!payload.success) {
62
+ await recordDeliveryOnly(options, deliveryId);
63
+ return {
64
+ outcome: 'discarded',
65
+ reason: 'unsupported_event',
66
+ deliveryId
67
+ };
68
+ }
69
+ const connectionId = request.path_parameters.connection_id;
70
+ const connection = await options.getIntegrationConnectionById(connectionId);
71
+ if (!connection || connection.provider !== JIRA_PROVIDER || connection.lifecycleStatus !== 'active') {
72
+ return await discardUnavailableConnection(options, deliveryId);
73
+ }
74
+ const installation = await (options.getJiraInstallationByConnectionId ?? getJiraInstallationByConnectionId)(connectionId);
75
+ if (!isJiraInstallationUsable(installation) || !hasMatchingWebhookId(payload.data, installation.webhookIds)) {
76
+ return await discardUnavailableConnection(options, deliveryId);
77
+ }
78
+ const result = await options.coreDb().transaction(async (tx)=>handleJiraWebhook({
79
+ tx,
80
+ deliveryId,
81
+ receivedAt: request.received_at,
82
+ rawPayload: payload.data,
83
+ cloudId: installation.cloudId,
84
+ connection: connection,
85
+ authorizingAccountId: installation.authorizingAccountId,
86
+ publishIntegrationEventReceived: options.publishIntegrationEventReceived,
87
+ recordDeliveryOnly: options.recordDeliveryOnly
88
+ }));
89
+ if (result === 'duplicate') return {
90
+ outcome: 'duplicate',
91
+ deliveryId
92
+ };
93
+ if (result === 'discarded') return {
94
+ outcome: 'discarded',
95
+ reason: 'unsupported_event',
96
+ deliveryId
97
+ };
98
+ return {
99
+ outcome: 'processed',
100
+ deliveryId
101
+ };
102
+ }
103
+ function invalidAuthorization(deliveryId) {
104
+ return {
105
+ outcome: 'discarded',
106
+ reason: 'invalid_signature',
107
+ deliveryId
108
+ };
109
+ }
110
+ function jiraDeliveryId(request, rawBody) {
111
+ const connectionId = request.path_parameters.connection_id;
112
+ const jiraIdentifier = request.headers['x-atlassian-webhook-identifier'];
113
+ if (jiraIdentifier) return `${connectionId}:${jiraIdentifier}`;
114
+ return createHash('sha256').update(connectionId).update('\0').update(rawBody).digest('hex');
115
+ }
116
+ async function recordDeliveryOnly(options, deliveryId) {
117
+ await options.coreDb().transaction(async (tx)=>{
118
+ await options.recordDeliveryOnly({
119
+ tx,
120
+ provider: JIRA_PROVIDER,
121
+ deliveryId
122
+ });
123
+ });
124
+ }
125
+ async function discardUnavailableConnection(options, deliveryId) {
126
+ await recordDeliveryOnly(options, deliveryId);
127
+ return {
128
+ outcome: 'discarded',
129
+ reason: 'connection_unavailable',
130
+ deliveryId
131
+ };
132
+ }
133
+ function hasMatchingWebhookId(payload, storedWebhookIds) {
134
+ return payload.matchedWebhookIds?.some((webhookId)=>storedWebhookIds.includes(webhookId)) === true;
135
+ }
136
+
137
+ //# sourceMappingURL=webhook-processor.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/core/webhook-processor.ts"],"sourcesContent":["import {Buffer} from 'node:buffer';\nimport {createHash} from 'node:crypto';\nimport {\n type JiraWebhookEnvelopeDto,\n jiraWebhookEnvelopeSchema,\n} from '@shipfox/api-integration-jira-dto';\nimport {\n decodeWebhookBody,\n type GetIntegrationConnectionByIdFn,\n type PublishIntegrationEventReceivedFn,\n type RecordDeliveryOnlyFn,\n type StoredWebhookRequest,\n type WebhookProcessingResult,\n} from '@shipfox/api-integration-spi';\nimport {extractBearerToken} from '@shipfox/node-fastify';\nimport {verifyHs256} from '@shipfox/node-jwt';\nimport {logger} from '@shipfox/node-opentelemetry';\nimport type {NodePgDatabase} from 'drizzle-orm/node-postgres';\nimport {z} from 'zod';\nimport {config} from '#config.js';\nimport {handleJiraWebhook, isJiraInstallationUsable} from '#core/webhook.js';\nimport {getJiraInstallationByConnectionId} from '#db/installations.js';\n\nconst JIRA_PROVIDER = 'jira';\nconst jiraWebhookJwtClaimsSchema = z\n .object({iat: z.number().int(), exp: z.number().int()})\n .passthrough();\n\nexport interface CreateJiraWebhookProcessorOptions {\n coreDb: () => NodePgDatabase<Record<string, unknown>>;\n publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;\n recordDeliveryOnly: RecordDeliveryOnlyFn;\n getIntegrationConnectionById: GetIntegrationConnectionByIdFn;\n getJiraInstallationByConnectionId?: typeof getJiraInstallationByConnectionId;\n}\n\nexport interface JiraWebhookProcessor {\n process(request: StoredWebhookRequest): Promise<WebhookProcessingResult>;\n}\n\nexport function createJiraWebhookProcessor(\n options: CreateJiraWebhookProcessorOptions,\n): JiraWebhookProcessor {\n return {process: (request) => processJiraWebhookRequest(options, request)};\n}\n\nasync function processJiraWebhookRequest(\n options: CreateJiraWebhookProcessorOptions,\n request: StoredWebhookRequest,\n): Promise<WebhookProcessingResult> {\n if (request.route_id !== 'jira') {\n throw new Error(`Jira processor cannot process ${request.route_id} requests`);\n }\n\n const rawBody = Buffer.from(decodeWebhookBody(request.body));\n const deliveryId = jiraDeliveryId(request, rawBody);\n const authorization = request.headers.authorization;\n if (!authorization) return invalidAuthorization(deliveryId);\n\n const token = extractBearerToken(authorization);\n if (!token) return invalidAuthorization(deliveryId);\n\n try {\n await verifyHs256({\n token,\n secret: config.JIRA_OAUTH_CLIENT_SECRET,\n schema: jiraWebhookJwtClaimsSchema,\n verificationTime: new Date(request.received_at),\n });\n } catch (error) {\n logger().warn(\n {deliveryId, errName: error instanceof Error ? error.name : typeof error},\n 'Jira webhook authorization verification failed',\n );\n return invalidAuthorization(deliveryId);\n }\n\n let rawPayload: unknown;\n try {\n rawPayload = JSON.parse(rawBody.toString('utf8'));\n } catch (error) {\n logger().warn({deliveryId, err: error}, 'Jira webhook payload JSON parse failed');\n return {outcome: 'discarded', reason: 'malformed_payload', deliveryId};\n }\n\n const payload = jiraWebhookEnvelopeSchema.safeParse(rawPayload);\n if (!payload.success) {\n await recordDeliveryOnly(options, deliveryId);\n return {outcome: 'discarded', reason: 'unsupported_event', deliveryId};\n }\n\n const connectionId = request.path_parameters.connection_id;\n const connection = await options.getIntegrationConnectionById(connectionId);\n if (\n !connection ||\n connection.provider !== JIRA_PROVIDER ||\n connection.lifecycleStatus !== 'active'\n ) {\n return await discardUnavailableConnection(options, deliveryId);\n }\n const installation = await (\n options.getJiraInstallationByConnectionId ?? getJiraInstallationByConnectionId\n )(connectionId);\n if (\n !isJiraInstallationUsable(installation) ||\n !hasMatchingWebhookId(payload.data, installation.webhookIds)\n ) {\n return await discardUnavailableConnection(options, deliveryId);\n }\n\n const result = await options.coreDb().transaction(async (tx) =>\n handleJiraWebhook({\n tx,\n deliveryId,\n receivedAt: request.received_at,\n rawPayload: payload.data,\n cloudId: installation.cloudId,\n connection: connection as typeof connection & {provider: 'jira'},\n authorizingAccountId: installation.authorizingAccountId,\n publishIntegrationEventReceived: options.publishIntegrationEventReceived,\n recordDeliveryOnly: options.recordDeliveryOnly,\n }),\n );\n\n if (result === 'duplicate') return {outcome: 'duplicate', deliveryId};\n if (result === 'discarded')\n return {outcome: 'discarded', reason: 'unsupported_event', deliveryId};\n return {outcome: 'processed', deliveryId};\n}\n\nfunction invalidAuthorization(deliveryId: string): WebhookProcessingResult {\n return {outcome: 'discarded', reason: 'invalid_signature', deliveryId};\n}\n\nfunction jiraDeliveryId(request: StoredWebhookRequest, rawBody: Uint8Array): string {\n const connectionId = request.path_parameters.connection_id;\n const jiraIdentifier = request.headers['x-atlassian-webhook-identifier'];\n if (jiraIdentifier) return `${connectionId}:${jiraIdentifier}`;\n return createHash('sha256').update(connectionId).update('\\0').update(rawBody).digest('hex');\n}\n\nasync function recordDeliveryOnly(\n options: Pick<CreateJiraWebhookProcessorOptions, 'coreDb' | 'recordDeliveryOnly'>,\n deliveryId: string,\n): Promise<void> {\n await options.coreDb().transaction(async (tx) => {\n await options.recordDeliveryOnly({tx, provider: JIRA_PROVIDER, deliveryId});\n });\n}\n\nasync function discardUnavailableConnection(\n options: Pick<CreateJiraWebhookProcessorOptions, 'coreDb' | 'recordDeliveryOnly'>,\n deliveryId: string,\n): Promise<WebhookProcessingResult> {\n await recordDeliveryOnly(options, deliveryId);\n return {outcome: 'discarded', reason: 'connection_unavailable', deliveryId};\n}\n\nfunction hasMatchingWebhookId(\n payload: JiraWebhookEnvelopeDto,\n storedWebhookIds: number[],\n): boolean {\n return (\n payload.matchedWebhookIds?.some((webhookId) => storedWebhookIds.includes(webhookId)) === true\n );\n}\n"],"names":["Buffer","createHash","jiraWebhookEnvelopeSchema","decodeWebhookBody","extractBearerToken","verifyHs256","logger","z","config","handleJiraWebhook","isJiraInstallationUsable","getJiraInstallationByConnectionId","JIRA_PROVIDER","jiraWebhookJwtClaimsSchema","object","iat","number","int","exp","passthrough","createJiraWebhookProcessor","options","process","request","processJiraWebhookRequest","route_id","Error","rawBody","from","body","deliveryId","jiraDeliveryId","authorization","headers","invalidAuthorization","token","secret","JIRA_OAUTH_CLIENT_SECRET","schema","verificationTime","Date","received_at","error","warn","errName","name","rawPayload","JSON","parse","toString","err","outcome","reason","payload","safeParse","success","recordDeliveryOnly","connectionId","path_parameters","connection_id","connection","getIntegrationConnectionById","provider","lifecycleStatus","discardUnavailableConnection","installation","hasMatchingWebhookId","data","webhookIds","result","coreDb","transaction","tx","receivedAt","cloudId","authorizingAccountId","publishIntegrationEventReceived","jiraIdentifier","update","digest","storedWebhookIds","matchedWebhookIds","some","webhookId","includes"],"mappings":"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"}
@@ -0,0 +1,26 @@
1
+ import type { JiraApiClient } from '#api/client.js';
2
+ import { getJiraInstallationByConnectionId, type JiraInstallationLock, updateJiraInstallationWebhook } from '#db/installations.js';
3
+ export declare const JIRA_WEBHOOK_TTL_MS: number;
4
+ export interface RegisterJiraWebhookParams {
5
+ jira: Pick<JiraApiClient, 'registerDynamicWebhook' | 'deleteDynamicWebhook'>;
6
+ connectionId: string;
7
+ cloudId: string;
8
+ accessToken: string;
9
+ webhookUrl: string;
10
+ now?: () => Date;
11
+ getInstallation?: typeof getJiraInstallationByConnectionId;
12
+ updateInstallation?: typeof updateJiraInstallationWebhook;
13
+ withRegistrationLock?: JiraInstallationLock;
14
+ onRegistrationSuccess?: (input: {
15
+ tx?: unknown;
16
+ }) => Promise<void>;
17
+ onRegistrationFailure?: (input: {
18
+ tx?: unknown;
19
+ }) => Promise<void>;
20
+ }
21
+ /** Register Jira's curated webhook and replace local metadata as one serialized state transition. */
22
+ export declare function registerJiraWebhook(params: RegisterJiraWebhookParams): Promise<{
23
+ webhookId: number;
24
+ webhookExpiresAt: Date;
25
+ }>;
26
+ //# sourceMappingURL=webhook-registration.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webhook-registration.d.ts","sourceRoot":"","sources":["../../src/core/webhook-registration.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAC,aAAa,EAAC,MAAM,gBAAgB,CAAC;AAClD,OAAO,EACL,iCAAiC,EACjC,KAAK,oBAAoB,EACzB,6BAA6B,EAE9B,MAAM,sBAAsB,CAAC;AAE9B,eAAO,MAAM,mBAAmB,QAA2B,CAAC;AAE5D,MAAM,WAAW,yBAAyB;IACxC,IAAI,EAAE,IAAI,CAAC,aAAa,EAAE,wBAAwB,GAAG,sBAAsB,CAAC,CAAC;IAC7E,YAAY,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;IACjB,eAAe,CAAC,EAAE,OAAO,iCAAiC,CAAC;IAC3D,kBAAkB,CAAC,EAAE,OAAO,6BAA6B,CAAC;IAC1D,oBAAoB,CAAC,EAAE,oBAAoB,CAAC;IAC5C,qBAAqB,CAAC,EAAE,CAAC,KAAK,EAAE;QAAC,EAAE,CAAC,EAAE,OAAO,CAAA;KAAC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACjE,qBAAqB,CAAC,EAAE,CAAC,KAAK,EAAE;QAAC,EAAE,CAAC,EAAE,OAAO,CAAA;KAAC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAClE;AAED,qGAAqG;AACrG,wBAAsB,mBAAmB,CACvC,MAAM,EAAE,yBAAyB,GAChC,OAAO,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,gBAAgB,EAAE,IAAI,CAAA;CAAC,CAAC,CA8FtD"}
@@ -0,0 +1,165 @@
1
+ import { logger } from '@shipfox/node-opentelemetry';
2
+ import { getJiraInstallationByConnectionId, updateJiraInstallationWebhook, withJiraInstallationLock } from '#db/installations.js';
3
+ export const JIRA_WEBHOOK_TTL_MS = 30 * 24 * 60 * 60 * 1000;
4
+ /** Register Jira's curated webhook and replace local metadata as one serialized state transition. */ export async function registerJiraWebhook(params) {
5
+ const withRegistrationLock = params.withRegistrationLock ?? withJiraInstallationLock;
6
+ let callbackFailed = false;
7
+ let registeredWebhookId;
8
+ let previous;
9
+ let metadataPersisted = false;
10
+ let cleanupSupersededWebhooks;
11
+ let result;
12
+ try {
13
+ result = await withRegistrationLock(params.cloudId, async ()=>{
14
+ try {
15
+ const getInstallation = params.getInstallation ?? getJiraInstallationByConnectionId;
16
+ previous = await getInstallation(params.connectionId);
17
+ const registration = await params.jira.registerDynamicWebhook({
18
+ accessToken: params.accessToken,
19
+ cloudId: params.cloudId,
20
+ url: params.webhookUrl
21
+ });
22
+ registeredWebhookId = registration.webhookId;
23
+ const now = params.now ?? (()=>new Date());
24
+ const webhookExpiresAt = new Date(now().getTime() + JIRA_WEBHOOK_TTL_MS);
25
+ const updateInstallation = params.updateInstallation ?? updateJiraInstallationWebhook;
26
+ const updateInput = {
27
+ connectionId: params.connectionId,
28
+ webhookIds: [
29
+ ...new Set([
30
+ registration.webhookId,
31
+ ...previous?.webhookIds ?? []
32
+ ])
33
+ ],
34
+ webhookExpiresAt
35
+ };
36
+ const installation = await updateInstallation(updateInput);
37
+ if (!installation) throw new Error('Jira webhook registration lost its installation record');
38
+ metadataPersisted = true;
39
+ await params.onRegistrationSuccess?.({});
40
+ cleanupSupersededWebhooks = ()=>finishSupersededWebhookCleanup(params, previous, registration.webhookId, webhookExpiresAt);
41
+ return {
42
+ webhookId: registration.webhookId,
43
+ webhookExpiresAt
44
+ };
45
+ } catch (error) {
46
+ callbackFailed = true;
47
+ if (registeredWebhookId !== undefined) {
48
+ const deleted = await deleteNewWebhookAfterPersistenceFailure(params, registeredWebhookId);
49
+ if (metadataPersisted || !deleted) {
50
+ await restorePreviousWebhookMetadata(params, previous, deleted ? undefined : registeredWebhookId);
51
+ }
52
+ } else if (metadataPersisted) {
53
+ await restorePreviousWebhookMetadata(params, previous);
54
+ }
55
+ await bestEffortRegistrationFailure(params);
56
+ throw error;
57
+ }
58
+ });
59
+ } catch (error) {
60
+ if (!callbackFailed) {
61
+ if (registeredWebhookId !== undefined) {
62
+ const deleted = await deleteNewWebhookAfterPersistenceFailure(params, registeredWebhookId);
63
+ if (metadataPersisted || !deleted) {
64
+ await restorePreviousWebhookMetadata(params, previous, deleted ? undefined : registeredWebhookId);
65
+ }
66
+ } else if (metadataPersisted) {
67
+ await restorePreviousWebhookMetadata(params, previous);
68
+ }
69
+ await bestEffortRegistrationFailure(params);
70
+ }
71
+ throw error;
72
+ }
73
+ if (cleanupSupersededWebhooks) {
74
+ try {
75
+ await withRegistrationLock(params.cloudId, cleanupSupersededWebhooks);
76
+ } catch (error) {
77
+ logger().warn({
78
+ err: error,
79
+ connectionId: params.connectionId
80
+ }, 'Jira superseded webhook cleanup persistence failed');
81
+ }
82
+ }
83
+ return result;
84
+ }
85
+ async function finishSupersededWebhookCleanup(params, previous, registeredWebhookId, webhookExpiresAt) {
86
+ const supersededWebhookIds = (previous?.webhookIds ?? []).filter((webhookId)=>webhookId !== registeredWebhookId);
87
+ if (supersededWebhookIds.length === 0) return;
88
+ const failedCleanupIds = [];
89
+ for (const webhookId of supersededWebhookIds){
90
+ try {
91
+ await params.jira.deleteDynamicWebhook({
92
+ accessToken: params.accessToken,
93
+ cloudId: params.cloudId,
94
+ webhookId
95
+ });
96
+ } catch (error) {
97
+ failedCleanupIds.push(webhookId);
98
+ logger().warn({
99
+ err: error,
100
+ connectionId: params.connectionId,
101
+ webhookId
102
+ }, 'Jira superseded webhook cleanup failed');
103
+ }
104
+ }
105
+ const retainedWebhookIds = [
106
+ registeredWebhookId,
107
+ ...failedCleanupIds
108
+ ];
109
+ const updateInstallation = params.updateInstallation ?? updateJiraInstallationWebhook;
110
+ const cleanupMetadata = await updateInstallation({
111
+ connectionId: params.connectionId,
112
+ webhookIds: retainedWebhookIds,
113
+ webhookExpiresAt
114
+ });
115
+ if (!cleanupMetadata) throw new Error('Jira webhook cleanup metadata persistence returned no installation');
116
+ }
117
+ async function bestEffortRegistrationFailure(params) {
118
+ try {
119
+ await params.onRegistrationFailure?.({});
120
+ } catch (error) {
121
+ logger().warn({
122
+ err: error,
123
+ connectionId: params.connectionId
124
+ }, 'Jira connection error-state update failed after webhook registration rejection');
125
+ }
126
+ }
127
+ async function restorePreviousWebhookMetadata(params, previous, retainedWebhookId) {
128
+ try {
129
+ const updateInstallation = params.updateInstallation ?? updateJiraInstallationWebhook;
130
+ await updateInstallation({
131
+ connectionId: params.connectionId,
132
+ webhookIds: [
133
+ ...retainedWebhookId === undefined ? [] : [
134
+ retainedWebhookId
135
+ ],
136
+ ...(previous?.webhookIds ?? []).filter((webhookId)=>webhookId !== retainedWebhookId)
137
+ ],
138
+ webhookExpiresAt: previous?.webhookExpiresAt ?? null
139
+ });
140
+ } catch (error) {
141
+ logger().warn({
142
+ err: error,
143
+ connectionId: params.connectionId
144
+ }, 'Jira webhook metadata restoration failed after registration rejection');
145
+ }
146
+ }
147
+ async function deleteNewWebhookAfterPersistenceFailure(params, webhookId) {
148
+ try {
149
+ await params.jira.deleteDynamicWebhook({
150
+ accessToken: params.accessToken,
151
+ cloudId: params.cloudId,
152
+ webhookId
153
+ });
154
+ return true;
155
+ } catch (cleanupError) {
156
+ logger().warn({
157
+ err: cleanupError,
158
+ connectionId: params.connectionId,
159
+ webhookId
160
+ }, 'Jira webhook cleanup failed after metadata persistence rejection');
161
+ return false;
162
+ }
163
+ }
164
+
165
+ //# sourceMappingURL=webhook-registration.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/core/webhook-registration.ts"],"sourcesContent":["import {logger} from '@shipfox/node-opentelemetry';\nimport type {JiraApiClient} from '#api/client.js';\nimport {\n getJiraInstallationByConnectionId,\n type JiraInstallationLock,\n updateJiraInstallationWebhook,\n withJiraInstallationLock,\n} from '#db/installations.js';\n\nexport const JIRA_WEBHOOK_TTL_MS = 30 * 24 * 60 * 60 * 1000;\n\nexport interface RegisterJiraWebhookParams {\n jira: Pick<JiraApiClient, 'registerDynamicWebhook' | 'deleteDynamicWebhook'>;\n connectionId: string;\n cloudId: string;\n accessToken: string;\n webhookUrl: string;\n now?: () => Date;\n getInstallation?: typeof getJiraInstallationByConnectionId;\n updateInstallation?: typeof updateJiraInstallationWebhook;\n withRegistrationLock?: JiraInstallationLock;\n onRegistrationSuccess?: (input: {tx?: unknown}) => Promise<void>;\n onRegistrationFailure?: (input: {tx?: unknown}) => Promise<void>;\n}\n\n/** Register Jira's curated webhook and replace local metadata as one serialized state transition. */\nexport async function registerJiraWebhook(\n params: RegisterJiraWebhookParams,\n): Promise<{webhookId: number; webhookExpiresAt: Date}> {\n const withRegistrationLock = params.withRegistrationLock ?? withJiraInstallationLock;\n let callbackFailed = false;\n let registeredWebhookId: number | undefined;\n let previous: Awaited<ReturnType<typeof getJiraInstallationByConnectionId>>;\n let metadataPersisted = false;\n let cleanupSupersededWebhooks: (() => Promise<void>) | undefined;\n let result: {webhookId: number; webhookExpiresAt: Date};\n try {\n result = await withRegistrationLock(params.cloudId, async () => {\n try {\n const getInstallation = params.getInstallation ?? getJiraInstallationByConnectionId;\n previous = await getInstallation(params.connectionId);\n const registration = await params.jira.registerDynamicWebhook({\n accessToken: params.accessToken,\n cloudId: params.cloudId,\n url: params.webhookUrl,\n });\n registeredWebhookId = registration.webhookId;\n const now = params.now ?? (() => new Date());\n const webhookExpiresAt = new Date(now().getTime() + JIRA_WEBHOOK_TTL_MS);\n\n const updateInstallation = params.updateInstallation ?? updateJiraInstallationWebhook;\n const updateInput = {\n connectionId: params.connectionId,\n webhookIds: [...new Set([registration.webhookId, ...(previous?.webhookIds ?? [])])],\n webhookExpiresAt,\n };\n const installation = await updateInstallation(updateInput);\n if (!installation)\n throw new Error('Jira webhook registration lost its installation record');\n metadataPersisted = true;\n await params.onRegistrationSuccess?.({});\n\n cleanupSupersededWebhooks = () =>\n finishSupersededWebhookCleanup(\n params,\n previous,\n registration.webhookId,\n webhookExpiresAt,\n );\n return {webhookId: registration.webhookId, webhookExpiresAt};\n } catch (error) {\n callbackFailed = true;\n if (registeredWebhookId !== undefined) {\n const deleted = await deleteNewWebhookAfterPersistenceFailure(\n params,\n registeredWebhookId,\n );\n if (metadataPersisted || !deleted) {\n await restorePreviousWebhookMetadata(\n params,\n previous,\n deleted ? undefined : registeredWebhookId,\n );\n }\n } else if (metadataPersisted) {\n await restorePreviousWebhookMetadata(params, previous);\n }\n await bestEffortRegistrationFailure(params);\n throw error;\n }\n });\n } catch (error) {\n if (!callbackFailed) {\n if (registeredWebhookId !== undefined) {\n const deleted = await deleteNewWebhookAfterPersistenceFailure(params, registeredWebhookId);\n if (metadataPersisted || !deleted) {\n await restorePreviousWebhookMetadata(\n params,\n previous,\n deleted ? undefined : registeredWebhookId,\n );\n }\n } else if (metadataPersisted) {\n await restorePreviousWebhookMetadata(params, previous);\n }\n await bestEffortRegistrationFailure(params);\n }\n throw error;\n }\n\n if (cleanupSupersededWebhooks) {\n try {\n await withRegistrationLock(params.cloudId, cleanupSupersededWebhooks);\n } catch (error) {\n logger().warn(\n {err: error, connectionId: params.connectionId},\n 'Jira superseded webhook cleanup persistence failed',\n );\n }\n }\n\n return result;\n}\n\nasync function finishSupersededWebhookCleanup(\n params: RegisterJiraWebhookParams,\n previous: Awaited<ReturnType<typeof getJiraInstallationByConnectionId>>,\n registeredWebhookId: number,\n webhookExpiresAt: Date,\n): Promise<void> {\n const supersededWebhookIds = (previous?.webhookIds ?? []).filter(\n (webhookId) => webhookId !== registeredWebhookId,\n );\n if (supersededWebhookIds.length === 0) return;\n\n const failedCleanupIds: number[] = [];\n for (const webhookId of supersededWebhookIds) {\n try {\n await params.jira.deleteDynamicWebhook({\n accessToken: params.accessToken,\n cloudId: params.cloudId,\n webhookId,\n });\n } catch (error) {\n failedCleanupIds.push(webhookId);\n logger().warn(\n {err: error, connectionId: params.connectionId, webhookId},\n 'Jira superseded webhook cleanup failed',\n );\n }\n }\n\n const retainedWebhookIds = [registeredWebhookId, ...failedCleanupIds];\n const updateInstallation = params.updateInstallation ?? updateJiraInstallationWebhook;\n const cleanupMetadata = await updateInstallation({\n connectionId: params.connectionId,\n webhookIds: retainedWebhookIds,\n webhookExpiresAt,\n });\n if (!cleanupMetadata)\n throw new Error('Jira webhook cleanup metadata persistence returned no installation');\n}\n\nasync function bestEffortRegistrationFailure(params: RegisterJiraWebhookParams): Promise<void> {\n try {\n await params.onRegistrationFailure?.({});\n } catch (error) {\n logger().warn(\n {err: error, connectionId: params.connectionId},\n 'Jira connection error-state update failed after webhook registration rejection',\n );\n }\n}\n\nasync function restorePreviousWebhookMetadata(\n params: RegisterJiraWebhookParams,\n previous: Awaited<ReturnType<typeof getJiraInstallationByConnectionId>>,\n retainedWebhookId?: number,\n): Promise<void> {\n try {\n const updateInstallation = params.updateInstallation ?? updateJiraInstallationWebhook;\n await updateInstallation({\n connectionId: params.connectionId,\n webhookIds: [\n ...(retainedWebhookId === undefined ? [] : [retainedWebhookId]),\n ...(previous?.webhookIds ?? []).filter((webhookId) => webhookId !== retainedWebhookId),\n ],\n webhookExpiresAt: previous?.webhookExpiresAt ?? null,\n });\n } catch (error) {\n logger().warn(\n {err: error, connectionId: params.connectionId},\n 'Jira webhook metadata restoration failed after registration rejection',\n );\n }\n}\n\nasync function deleteNewWebhookAfterPersistenceFailure(\n params: RegisterJiraWebhookParams,\n webhookId: number,\n): Promise<boolean> {\n try {\n await params.jira.deleteDynamicWebhook({\n accessToken: params.accessToken,\n cloudId: params.cloudId,\n webhookId,\n });\n return true;\n } catch (cleanupError) {\n logger().warn(\n {err: cleanupError, connectionId: params.connectionId, webhookId},\n 'Jira webhook cleanup failed after metadata persistence rejection',\n );\n return false;\n }\n}\n"],"names":["logger","getJiraInstallationByConnectionId","updateJiraInstallationWebhook","withJiraInstallationLock","JIRA_WEBHOOK_TTL_MS","registerJiraWebhook","params","withRegistrationLock","callbackFailed","registeredWebhookId","previous","metadataPersisted","cleanupSupersededWebhooks","result","cloudId","getInstallation","connectionId","registration","jira","registerDynamicWebhook","accessToken","url","webhookUrl","webhookId","now","Date","webhookExpiresAt","getTime","updateInstallation","updateInput","webhookIds","Set","installation","Error","onRegistrationSuccess","finishSupersededWebhookCleanup","error","undefined","deleted","deleteNewWebhookAfterPersistenceFailure","restorePreviousWebhookMetadata","bestEffortRegistrationFailure","warn","err","supersededWebhookIds","filter","length","failedCleanupIds","deleteDynamicWebhook","push","retainedWebhookIds","cleanupMetadata","onRegistrationFailure","retainedWebhookId","cleanupError"],"mappings":"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"}
@@ -0,0 +1,18 @@
1
+ import type { JiraWebhookEnvelopeDto } from '@shipfox/api-integration-jira-dto';
2
+ import type { IntegrationConnection, IntegrationTx, PublishIntegrationEventReceivedFn, RecordDeliveryOnlyFn } from '@shipfox/api-integration-spi';
3
+ import type { JiraInstallation } from '#db/installations.js';
4
+ export interface HandleJiraWebhookParams {
5
+ tx: IntegrationTx;
6
+ deliveryId: string;
7
+ receivedAt: string;
8
+ rawPayload: JiraWebhookEnvelopeDto;
9
+ cloudId: string;
10
+ connection: IntegrationConnection<'jira'>;
11
+ authorizingAccountId: string;
12
+ publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
13
+ recordDeliveryOnly: RecordDeliveryOnlyFn;
14
+ }
15
+ export type HandleJiraWebhookResult = 'published' | 'duplicate' | 'discarded';
16
+ export declare function handleJiraWebhook(params: HandleJiraWebhookParams): Promise<HandleJiraWebhookResult>;
17
+ export declare function isJiraInstallationUsable(installation: JiraInstallation | undefined): installation is JiraInstallation;
18
+ //# sourceMappingURL=webhook.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webhook.d.ts","sourceRoot":"","sources":["../../src/core/webhook.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,sBAAsB,EAAC,MAAM,mCAAmC,CAAC;AAC9E,OAAO,KAAK,EACV,qBAAqB,EACrB,aAAa,EACb,iCAAiC,EACjC,oBAAoB,EACrB,MAAM,8BAA8B,CAAC;AACtC,OAAO,KAAK,EAAC,gBAAgB,EAAC,MAAM,sBAAsB,CAAC;AAI3D,MAAM,WAAW,uBAAuB;IACtC,EAAE,EAAE,aAAa,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,sBAAsB,CAAC;IACnC,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,qBAAqB,CAAC,MAAM,CAAC,CAAC;IAC1C,oBAAoB,EAAE,MAAM,CAAC;IAC7B,+BAA+B,EAAE,iCAAiC,CAAC;IACnE,kBAAkB,EAAE,oBAAoB,CAAC;CAC1C;AAED,MAAM,MAAM,uBAAuB,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,CAAC;AAE9E,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,uBAAuB,GAC9B,OAAO,CAAC,uBAAuB,CAAC,CA0BlC;AAWD,wBAAgB,wBAAwB,CACtC,YAAY,EAAE,gBAAgB,GAAG,SAAS,GACzC,YAAY,IAAI,gBAAgB,CAElC"}
@@ -0,0 +1,38 @@
1
+ const JIRA_PROVIDER = 'jira';
2
+ export async function handleJiraWebhook(params) {
3
+ if (isSelfAuthoredEvent(params.rawPayload, params.authorizingAccountId)) {
4
+ await params.recordDeliveryOnly({
5
+ tx: params.tx,
6
+ provider: JIRA_PROVIDER,
7
+ deliveryId: params.deliveryId
8
+ });
9
+ return 'discarded';
10
+ }
11
+ const payload = {
12
+ ...params.rawPayload,
13
+ cloudId: params.cloudId
14
+ };
15
+ const result = await params.publishIntegrationEventReceived({
16
+ tx: params.tx,
17
+ event: {
18
+ provider: JIRA_PROVIDER,
19
+ source: params.connection.slug,
20
+ event: params.rawPayload.webhookEvent,
21
+ workspaceId: params.connection.workspaceId,
22
+ connectionId: params.connection.id,
23
+ connectionName: params.connection.displayName,
24
+ deliveryId: params.deliveryId,
25
+ receivedAt: params.receivedAt,
26
+ payload
27
+ }
28
+ });
29
+ return result.published ? 'published' : 'duplicate';
30
+ }
31
+ function isSelfAuthoredEvent(payload, authorizingAccountId) {
32
+ return (payload.webhookEvent === 'comment_created' || payload.webhookEvent === 'comment_updated' || payload.webhookEvent === 'jira:issue_updated') && payload.user.accountId === authorizingAccountId;
33
+ }
34
+ export function isJiraInstallationUsable(installation) {
35
+ return installation?.status === 'installed';
36
+ }
37
+
38
+ //# sourceMappingURL=webhook.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/core/webhook.ts"],"sourcesContent":["import type {JiraWebhookEnvelopeDto} from '@shipfox/api-integration-jira-dto';\nimport type {\n IntegrationConnection,\n IntegrationTx,\n PublishIntegrationEventReceivedFn,\n RecordDeliveryOnlyFn,\n} from '@shipfox/api-integration-spi';\nimport type {JiraInstallation} from '#db/installations.js';\n\nconst JIRA_PROVIDER = 'jira';\n\nexport interface HandleJiraWebhookParams {\n tx: IntegrationTx;\n deliveryId: string;\n receivedAt: string;\n rawPayload: JiraWebhookEnvelopeDto;\n cloudId: string;\n connection: IntegrationConnection<'jira'>;\n authorizingAccountId: string;\n publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;\n recordDeliveryOnly: RecordDeliveryOnlyFn;\n}\n\nexport type HandleJiraWebhookResult = 'published' | 'duplicate' | 'discarded';\n\nexport async function handleJiraWebhook(\n params: HandleJiraWebhookParams,\n): Promise<HandleJiraWebhookResult> {\n if (isSelfAuthoredEvent(params.rawPayload, params.authorizingAccountId)) {\n await params.recordDeliveryOnly({\n tx: params.tx,\n provider: JIRA_PROVIDER,\n deliveryId: params.deliveryId,\n });\n return 'discarded';\n }\n\n const payload = {...params.rawPayload, cloudId: params.cloudId};\n const result = await params.publishIntegrationEventReceived({\n tx: params.tx,\n event: {\n provider: JIRA_PROVIDER,\n source: params.connection.slug,\n event: params.rawPayload.webhookEvent,\n workspaceId: params.connection.workspaceId,\n connectionId: params.connection.id,\n connectionName: params.connection.displayName,\n deliveryId: params.deliveryId,\n receivedAt: params.receivedAt,\n payload,\n },\n });\n return result.published ? 'published' : 'duplicate';\n}\n\nfunction isSelfAuthoredEvent(payload: JiraWebhookEnvelopeDto, authorizingAccountId: string) {\n return (\n (payload.webhookEvent === 'comment_created' ||\n payload.webhookEvent === 'comment_updated' ||\n payload.webhookEvent === 'jira:issue_updated') &&\n payload.user.accountId === authorizingAccountId\n );\n}\n\nexport function isJiraInstallationUsable(\n installation: JiraInstallation | undefined,\n): installation is JiraInstallation {\n return installation?.status === 'installed';\n}\n"],"names":["JIRA_PROVIDER","handleJiraWebhook","params","isSelfAuthoredEvent","rawPayload","authorizingAccountId","recordDeliveryOnly","tx","provider","deliveryId","payload","cloudId","result","publishIntegrationEventReceived","event","source","connection","slug","webhookEvent","workspaceId","connectionId","id","connectionName","displayName","receivedAt","published","user","accountId","isJiraInstallationUsable","installation","status"],"mappings":"AASA,MAAMA,gBAAgB;AAgBtB,OAAO,eAAeC,kBACpBC,MAA+B;IAE/B,IAAIC,oBAAoBD,OAAOE,UAAU,EAAEF,OAAOG,oBAAoB,GAAG;QACvE,MAAMH,OAAOI,kBAAkB,CAAC;YAC9BC,IAAIL,OAAOK,EAAE;YACbC,UAAUR;YACVS,YAAYP,OAAOO,UAAU;QAC/B;QACA,OAAO;IACT;IAEA,MAAMC,UAAU;QAAC,GAAGR,OAAOE,UAAU;QAAEO,SAAST,OAAOS,OAAO;IAAA;IAC9D,MAAMC,SAAS,MAAMV,OAAOW,+BAA+B,CAAC;QAC1DN,IAAIL,OAAOK,EAAE;QACbO,OAAO;YACLN,UAAUR;YACVe,QAAQb,OAAOc,UAAU,CAACC,IAAI;YAC9BH,OAAOZ,OAAOE,UAAU,CAACc,YAAY;YACrCC,aAAajB,OAAOc,UAAU,CAACG,WAAW;YAC1CC,cAAclB,OAAOc,UAAU,CAACK,EAAE;YAClCC,gBAAgBpB,OAAOc,UAAU,CAACO,WAAW;YAC7Cd,YAAYP,OAAOO,UAAU;YAC7Be,YAAYtB,OAAOsB,UAAU;YAC7Bd;QACF;IACF;IACA,OAAOE,OAAOa,SAAS,GAAG,cAAc;AAC1C;AAEA,SAAStB,oBAAoBO,OAA+B,EAAEL,oBAA4B;IACxF,OACE,AAACK,CAAAA,QAAQQ,YAAY,KAAK,qBACxBR,QAAQQ,YAAY,KAAK,qBACzBR,QAAQQ,YAAY,KAAK,oBAAmB,KAC9CR,QAAQgB,IAAI,CAACC,SAAS,KAAKtB;AAE/B;AAEA,OAAO,SAASuB,yBACdC,YAA0C;IAE1C,OAAOA,cAAcC,WAAW;AAClC"}
@@ -1,3 +1,5 @@
1
+ export type JiraInstallationLock = <T>(lockKey: string, fn: () => Promise<T>) => Promise<T>;
2
+ export declare function withJiraInstallationLock<T>(lockKey: string, fn: () => Promise<T>): Promise<T>;
1
3
  export type JiraInstallationStatus = 'installed' | 'revoked';
2
4
  export interface JiraInstallation {
3
5
  id: string;
@@ -31,6 +33,11 @@ export interface UpdateJiraInstallationTokenExpiryParams {
31
33
  tokenExpiresAt: Date | null;
32
34
  scopes?: string[] | undefined;
33
35
  }
36
+ export interface UpdateJiraInstallationWebhookParams {
37
+ connectionId: string;
38
+ webhookIds: number[];
39
+ webhookExpiresAt: Date | null;
40
+ }
34
41
  export declare function upsertJiraInstallation(params: UpsertJiraInstallationParams, options?: {
35
42
  tx?: unknown;
36
43
  }): Promise<JiraInstallation>;
@@ -40,6 +47,9 @@ export declare function getJiraInstallationByCloudId(cloudId: string, options?:
40
47
  export declare function updateJiraInstallationTokenExpiry(params: UpdateJiraInstallationTokenExpiryParams, options?: {
41
48
  tx?: unknown;
42
49
  }): Promise<JiraInstallation | undefined>;
50
+ export declare function updateJiraInstallationWebhook(params: UpdateJiraInstallationWebhookParams, options?: {
51
+ tx?: unknown;
52
+ }): Promise<JiraInstallation | undefined>;
43
53
  export declare function deleteJiraInstallationByConnectionId(connectionId: string, options?: {
44
54
  tx?: unknown;
45
55
  }): Promise<boolean>;
@@ -50,6 +60,7 @@ export type JiraRefreshLockResult<T> = {
50
60
  acquired: false;
51
61
  };
52
62
  export declare function withJiraRefreshLock<T>(connectionId: string, fn: () => Promise<T>): Promise<JiraRefreshLockResult<T>>;
63
+ export declare function withJiraWebhookRegistrationLock<T>(lockKey: string, fn: (tx?: unknown) => Promise<T>): Promise<T>;
53
64
  export declare function getJiraInstallationByConnectionId(connectionId: string, options?: {
54
65
  tx?: unknown;
55
66
  }): Promise<JiraInstallation | undefined>;
@@ -1 +1 @@
1
- {"version":3,"file":"installations.d.ts","sourceRoot":"","sources":["../../src/db/installations.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"installations.d.ts","sourceRoot":"","sources":["../../src/db/installations.ts"],"names":[],"mappings":"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"}
@@ -1,13 +1,46 @@
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 { JiraConnectionAlreadyLinkedError, JiraInstallationAlreadyLinkedError, JiraInstallationSiteMismatchError } from '#core/errors.js';
5
5
  import { db } from './db.js';
6
6
  import { jiraInstallations, toJiraInstallation } from './schema/installations.js';
7
+ const JIRA_INSTALLATION_LOCK_RETRY_DELAY_MS = 100;
8
+ const JIRA_INSTALLATION_LOCK_MAX_RETRY_DELAY_MS = 1_000;
9
+ const JIRA_INSTALLATION_LOCK_TIMEOUT_MS = 30_000;
10
+ export async function withJiraInstallationLock(lockKey, fn) {
11
+ const advisoryKey = `jira-installation:${lockKey}`;
12
+ const deadline = Date.now() + JIRA_INSTALLATION_LOCK_TIMEOUT_MS;
13
+ let retryDelayMs = JIRA_INSTALLATION_LOCK_RETRY_DELAY_MS;
14
+ while(true){
15
+ const attempt = await withPostgresSession(async (client)=>{
16
+ const lock = await client.query('SELECT pg_try_advisory_lock(hashtext($1)) AS acquired', [
17
+ advisoryKey
18
+ ]);
19
+ if (lock.rows[0]?.acquired !== true) return {
20
+ acquired: false
21
+ };
22
+ try {
23
+ return {
24
+ acquired: true,
25
+ value: await fn()
26
+ };
27
+ } finally{
28
+ await client.query('SELECT pg_advisory_unlock(hashtext($1))', [
29
+ advisoryKey
30
+ ]);
31
+ }
32
+ });
33
+ if (attempt.acquired) return attempt.value;
34
+ if (Date.now() >= deadline) {
35
+ throw new Error(`Timed out waiting for Jira installation lock: ${lockKey}`);
36
+ }
37
+ await new Promise((resolve)=>setTimeout(resolve, retryDelayMs));
38
+ retryDelayMs = Math.min(retryDelayMs * 2, JIRA_INSTALLATION_LOCK_MAX_RETRY_DELAY_MS);
39
+ }
40
+ }
7
41
  export async function upsertJiraInstallation(params, options = {}) {
8
42
  const executor = options.tx ?? db();
9
43
  const now = new Date();
10
- const webhookIds = params.webhookIds ?? [];
11
44
  let row;
12
45
  try {
13
46
  [row] = await executor.insert(jiraInstallations).values({
@@ -17,7 +50,7 @@ export async function upsertJiraInstallation(params, options = {}) {
17
50
  siteName: params.siteName,
18
51
  authorizingAccountId: params.authorizingAccountId,
19
52
  scopes: params.scopes,
20
- webhookIds,
53
+ webhookIds: params.webhookIds ?? [],
21
54
  webhookExpiresAt: params.webhookExpiresAt ?? null,
22
55
  status: params.status,
23
56
  tokenExpiresAt: params.tokenExpiresAt ?? null
@@ -30,8 +63,12 @@ export async function upsertJiraInstallation(params, options = {}) {
30
63
  siteName: params.siteName,
31
64
  authorizingAccountId: params.authorizingAccountId,
32
65
  scopes: params.scopes,
33
- webhookIds,
34
- webhookExpiresAt: params.webhookExpiresAt ?? null,
66
+ ...params.webhookIds === undefined ? {} : {
67
+ webhookIds: params.webhookIds
68
+ },
69
+ ...params.webhookExpiresAt === undefined ? {} : {
70
+ webhookExpiresAt: params.webhookExpiresAt
71
+ },
35
72
  status: params.status,
36
73
  tokenExpiresAt: params.tokenExpiresAt ?? null,
37
74
  updatedAt: now
@@ -66,6 +103,15 @@ export async function updateJiraInstallationTokenExpiry(params, options = {}) {
66
103
  }).where(eq(jiraInstallations.connectionId, params.connectionId)).returning();
67
104
  return row ? toJiraInstallation(row) : undefined;
68
105
  }
106
+ export async function updateJiraInstallationWebhook(params, options = {}) {
107
+ const executor = options.tx ?? db();
108
+ const [row] = await executor.update(jiraInstallations).set({
109
+ webhookIds: params.webhookIds,
110
+ webhookExpiresAt: params.webhookExpiresAt,
111
+ updatedAt: new Date()
112
+ }).where(eq(jiraInstallations.connectionId, params.connectionId)).returning();
113
+ return row ? toJiraInstallation(row) : undefined;
114
+ }
69
115
  export async function deleteJiraInstallationByConnectionId(connectionId, options = {}) {
70
116
  const executor = options.tx ?? db();
71
117
  const result = await executor.delete(jiraInstallations).where(eq(jiraInstallations.connectionId, connectionId));
@@ -74,6 +120,9 @@ export async function deleteJiraInstallationByConnectionId(connectionId, options
74
120
  export function withJiraRefreshLock(connectionId, fn) {
75
121
  return withJiraRefreshLockClient(connectionId, fn);
76
122
  }
123
+ export function withJiraWebhookRegistrationLock(lockKey, fn) {
124
+ return withJiraInstallationLock(lockKey, ()=>fn());
125
+ }
77
126
  async function withJiraRefreshLockClient(connectionId, fn) {
78
127
  const client = await pgClient().connect();
79
128
  let acquired = false;