@shipfox/api-integration-jira 11.0.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 +26 -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
@@ -1,2 +1,2 @@
1
1
  $ shipfox-swc
2
- Successfully compiled: 20 files with swc (337.94ms)
2
+ Successfully compiled: 24 files with swc (237.83ms)
package/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # @shipfox/api-integration-jira
2
2
 
3
+ ## 12.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - f13e8bb: Add Jira dynamic webhook registration and authenticated event ingestion through
8
+ the shared stored-webhook workflow. Update the SPI webhook request exports and
9
+ serialize Jira installation replacement across API replicas. Preserve Jira
10
+ delivery identifiers and require lifecycle callbacks for registration. Remove
11
+ the unused Jira webhook signing-secret configuration and allow HS256 verification
12
+ at a supplied receipt time.
13
+
14
+ ### Patch Changes
15
+
16
+ - Updated dependencies [f78740d]
17
+ - Updated dependencies [24ef475]
18
+ - Updated dependencies [f13e8bb]
19
+ - Updated dependencies [869a792]
20
+ - Updated dependencies [54c820e]
21
+ - @shipfox/node-fastify@0.4.1
22
+ - @shipfox/api-integration-spi@1.0.0
23
+ - @shipfox/node-jwt@0.4.0
24
+ - @shipfox/node-postgres@0.5.0
25
+ - @shipfox/api-auth-context@12.0.0
26
+ - @shipfox/api-integration-jira-dto@12.0.0
27
+ - @shipfox/node-drizzle@0.3.5
28
+
3
29
  ## 11.0.0
4
30
 
5
31
  ### Patch Changes
package/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  # Jira integration
2
2
 
3
- This package owns Jira provider persistence and token storage. The provider is non-functional until ENG-1001 adds the OAuth install flow.
3
+ This package owns Jira OAuth installation, webhook registration and ingestion, persistence, and token storage.
4
4
 
5
5
  Connect Jira with a dedicated Shipfox service account. Jira 3LO actions are authored by the authorizing account, so events from that account are dropped to prevent agent actions from triggering themselves.
@@ -1,3 +1,5 @@
1
+ export declare const JIRA_DYNAMIC_WEBHOOK_EVENTS: readonly ["jira:issue_created", "jira:issue_updated", "jira:issue_deleted", "comment_created", "comment_updated", "comment_deleted"];
2
+ export declare const JIRA_DYNAMIC_WEBHOOK_JQL = "";
1
3
  export interface JiraAuthorization {
2
4
  accessToken: string;
3
5
  refreshToken?: string | undefined;
@@ -13,6 +15,9 @@ export interface JiraAccessibleResource {
13
15
  export interface JiraIdentity {
14
16
  accountId: string;
15
17
  }
18
+ export interface JiraDynamicWebhookRegistration {
19
+ webhookId: number;
20
+ }
16
21
  export interface JiraApiClient {
17
22
  exchangeAuthorizationCode(input: {
18
23
  code: string;
@@ -27,6 +32,16 @@ export interface JiraApiClient {
27
32
  accessToken: string;
28
33
  cloudId: string;
29
34
  }): Promise<JiraIdentity>;
35
+ registerDynamicWebhook(input: {
36
+ accessToken: string;
37
+ cloudId: string;
38
+ url: string;
39
+ }): Promise<JiraDynamicWebhookRegistration>;
40
+ deleteDynamicWebhook(input: {
41
+ accessToken: string;
42
+ cloudId: string;
43
+ webhookId: number;
44
+ }): Promise<void>;
30
45
  }
31
46
  export declare function createJiraApiClient(): JiraApiClient;
32
47
  export declare function mapJiraError<T>(operation: string, request: () => Promise<T>): Promise<T>;
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/api/client.ts"],"names":[],"mappings":"AAQA,MAAM,WAAW,iBAAiB;IAChC,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,SAAS,CAAC,EAAE,IAAI,GAAG,SAAS,CAAC;IAC7B,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,YAAY;IAC3B,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B,yBAAyB,CAAC,KAAK,EAAE;QAAC,IAAI,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAC;IAC7E,kBAAkB,CAAC,KAAK,EAAE;QAAC,YAAY,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAC;IAC9E,sBAAsB,CAAC,KAAK,EAAE;QAAC,WAAW,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC,sBAAsB,EAAE,CAAC,CAAC;IACxF,SAAS,CAAC,KAAK,EAAE;QAAC,WAAW,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;CACjF;AAoBD,wBAAgB,mBAAmB,IAAI,aAAa,CAmEnD;AAmDD,wBAAsB,YAAY,CAAC,CAAC,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAgC9F"}
1
+ {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../../src/api/client.ts"],"names":[],"mappings":"AAQA,eAAO,MAAM,2BAA2B,sIAAwB,CAAC;AACjE,eAAO,MAAM,wBAAwB,KAAK,CAAC;AAE3C,MAAM,WAAW,iBAAiB;IAChC,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,SAAS,CAAC,EAAE,IAAI,GAAG,SAAS,CAAC;IAC7B,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,sBAAsB;IACrC,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,YAAY;IAC3B,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,8BAA8B;IAC7C,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,aAAa;IAC5B,yBAAyB,CAAC,KAAK,EAAE;QAAC,IAAI,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAC;IAC7E,kBAAkB,CAAC,KAAK,EAAE;QAAC,YAAY,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAC;IAC9E,sBAAsB,CAAC,KAAK,EAAE;QAAC,WAAW,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC,sBAAsB,EAAE,CAAC,CAAC;IACxF,SAAS,CAAC,KAAK,EAAE;QAAC,WAAW,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;IAChF,sBAAsB,CAAC,KAAK,EAAE;QAC5B,WAAW,EAAE,MAAM,CAAC;QACpB,OAAO,EAAE,MAAM,CAAC;QAChB,GAAG,EAAE,MAAM,CAAC;KACb,GAAG,OAAO,CAAC,8BAA8B,CAAC,CAAC;IAC5C,oBAAoB,CAAC,KAAK,EAAE;QAC1B,WAAW,EAAE,MAAM,CAAC;QACpB,OAAO,EAAE,MAAM,CAAC;QAChB,SAAS,EAAE,MAAM,CAAC;KACnB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACnB;AAwBD,wBAAgB,mBAAmB,IAAI,aAAa,CAkGnD;AAoFD,wBAAsB,YAAY,CAAC,CAAC,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAgC9F"}
@@ -1,9 +1,12 @@
1
+ import { jiraWebhookEventNames } from '@shipfox/api-integration-jira-dto';
1
2
  import { logger } from '@shipfox/node-opentelemetry';
2
3
  import ky, { HTTPError, TimeoutError } from 'ky';
3
4
  import { config } from '#config.js';
4
5
  import { JiraIntegrationProviderError } from '#core/errors.js';
5
6
  const JIRA_API_TIMEOUT_MS = 10_000;
6
7
  const SCOPE_SEPARATOR_RE = /[,\s]+/;
8
+ export const JIRA_DYNAMIC_WEBHOOK_EVENTS = jiraWebhookEventNames;
9
+ export const JIRA_DYNAMIC_WEBHOOK_JQL = '';
7
10
  export function createJiraApiClient() {
8
11
  return {
9
12
  async exchangeAuthorizationCode (input) {
@@ -56,9 +59,59 @@ export function createJiraApiClient() {
56
59
  return {
57
60
  accountId: body.accountId
58
61
  };
62
+ },
63
+ async registerDynamicWebhook (input) {
64
+ const body = await mapJiraError('register-dynamic-webhook', ()=>ky.post(`${config.JIRA_API_BASE_URL}/ex/jira/${input.cloudId}/rest/api/3/webhook`, {
65
+ headers: {
66
+ authorization: `Bearer ${input.accessToken}`
67
+ },
68
+ json: {
69
+ url: input.url,
70
+ webhooks: [
71
+ {
72
+ events: JIRA_DYNAMIC_WEBHOOK_EVENTS,
73
+ jqlFilter: JIRA_DYNAMIC_WEBHOOK_JQL
74
+ }
75
+ ]
76
+ },
77
+ timeout: JIRA_API_TIMEOUT_MS
78
+ }).json());
79
+ return parseDynamicWebhookRegistration(body);
80
+ },
81
+ async deleteDynamicWebhook (input) {
82
+ await mapJiraError('delete-dynamic-webhook', ()=>ky.delete(`${config.JIRA_API_BASE_URL}/ex/jira/${input.cloudId}/rest/api/3/webhook`, {
83
+ headers: {
84
+ authorization: `Bearer ${input.accessToken}`
85
+ },
86
+ json: {
87
+ webhookIds: [
88
+ input.webhookId
89
+ ]
90
+ },
91
+ timeout: JIRA_API_TIMEOUT_MS
92
+ }));
59
93
  }
60
94
  };
61
95
  }
96
+ function parseDynamicWebhookRegistration(body) {
97
+ if (!body || typeof body !== 'object' || !Array.isArray(body.webhookRegistrationResult) || body.webhookRegistrationResult.length !== 1) {
98
+ throw malformed('Jira webhook registration response did not contain exactly one result');
99
+ }
100
+ const result = body.webhookRegistrationResult[0];
101
+ if (!result || typeof result !== 'object') {
102
+ throw malformed('Jira webhook registration result was malformed');
103
+ }
104
+ const { createdWebhookId, errors } = result;
105
+ if (errors !== undefined && (!Array.isArray(errors) || errors.length > 0)) {
106
+ throw malformed('Jira webhook registration returned errors');
107
+ }
108
+ if (typeof createdWebhookId !== 'number' || !Number.isSafeInteger(createdWebhookId) || createdWebhookId <= 0) {
109
+ throw malformed('Jira webhook registration did not return a created webhook id');
110
+ }
111
+ return {
112
+ webhookId: createdWebhookId
113
+ };
114
+ }
62
115
  function parseAuthorization(body) {
63
116
  if (typeof body.access_token !== 'string' || body.access_token.length === 0) {
64
117
  throw malformed('Jira authorization response did not include an access token');
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/api/client.ts"],"sourcesContent":["import {logger} from '@shipfox/node-opentelemetry';\nimport ky, {HTTPError, TimeoutError} from 'ky';\nimport {config} from '#config.js';\nimport {JiraIntegrationProviderError} from '#core/errors.js';\n\nconst JIRA_API_TIMEOUT_MS = 10_000;\nconst SCOPE_SEPARATOR_RE = /[,\\s]+/;\n\nexport interface JiraAuthorization {\n accessToken: string;\n refreshToken?: string | undefined;\n expiresAt?: Date | undefined;\n scopes: string[];\n}\n\nexport interface JiraAccessibleResource {\n cloudId: string;\n name: string;\n url: string;\n scopes: string[];\n}\n\nexport interface JiraIdentity {\n accountId: string;\n}\n\nexport interface JiraApiClient {\n exchangeAuthorizationCode(input: {code: string}): Promise<JiraAuthorization>;\n refreshAccessToken(input: {refreshToken: string}): Promise<JiraAuthorization>;\n getAccessibleResources(input: {accessToken: string}): Promise<JiraAccessibleResource[]>;\n getMyself(input: {accessToken: string; cloudId: string}): Promise<JiraIdentity>;\n}\n\ninterface JiraTokenResponse {\n access_token?: unknown;\n refresh_token?: unknown;\n expires_in?: unknown;\n scope?: unknown;\n}\n\ninterface JiraResourceResponse {\n id?: unknown;\n name?: unknown;\n url?: unknown;\n scopes?: unknown;\n}\n\ninterface JiraMyselfResponse {\n accountId?: unknown;\n}\n\nexport function createJiraApiClient(): JiraApiClient {\n return {\n async exchangeAuthorizationCode(input) {\n const body = await mapJiraError('exchange-authorization-code', () =>\n ky\n .post(`${config.JIRA_AUTH_BASE_URL}/oauth/token`, {\n body: new URLSearchParams({\n grant_type: 'authorization_code',\n client_id: config.JIRA_OAUTH_CLIENT_ID,\n client_secret: config.JIRA_OAUTH_CLIENT_SECRET,\n code: input.code,\n redirect_uri: config.JIRA_OAUTH_REDIRECT_URL,\n }),\n timeout: JIRA_API_TIMEOUT_MS,\n })\n .json<JiraTokenResponse>(),\n );\n return parseAuthorization(body);\n },\n\n async refreshAccessToken(input) {\n const body = await mapJiraError('refresh-access-token', () =>\n ky\n .post(`${config.JIRA_AUTH_BASE_URL}/oauth/token`, {\n body: new URLSearchParams({\n grant_type: 'refresh_token',\n client_id: config.JIRA_OAUTH_CLIENT_ID,\n client_secret: config.JIRA_OAUTH_CLIENT_SECRET,\n refresh_token: input.refreshToken,\n }),\n timeout: JIRA_API_TIMEOUT_MS,\n })\n .json<JiraTokenResponse>(),\n );\n return parseAuthorization(body);\n },\n\n async getAccessibleResources(input) {\n const body = await mapJiraError('get-accessible-resources', () =>\n ky\n .get(`${config.JIRA_API_BASE_URL}/oauth/token/accessible-resources`, {\n headers: {authorization: `Bearer ${input.accessToken}`},\n timeout: JIRA_API_TIMEOUT_MS,\n })\n .json<unknown>(),\n );\n if (!Array.isArray(body)) {\n throw malformed('Jira accessible-resources response was not an array');\n }\n return body.map(parseAccessibleResource);\n },\n\n async getMyself(input) {\n const body = await mapJiraError('get-myself', () =>\n ky\n .get(`${config.JIRA_API_BASE_URL}/ex/jira/${input.cloudId}/rest/api/3/myself`, {\n headers: {authorization: `Bearer ${input.accessToken}`},\n timeout: JIRA_API_TIMEOUT_MS,\n })\n .json<JiraMyselfResponse>(),\n );\n if (typeof body.accountId !== 'string' || body.accountId.length === 0) {\n throw malformed('Jira identity response did not include an accountId');\n }\n return {accountId: body.accountId};\n },\n };\n}\n\nfunction parseAuthorization(body: JiraTokenResponse): JiraAuthorization {\n if (typeof body.access_token !== 'string' || body.access_token.length === 0) {\n throw malformed('Jira authorization response did not include an access token');\n }\n return {\n accessToken: body.access_token,\n refreshToken: typeof body.refresh_token === 'string' ? body.refresh_token : undefined,\n expiresAt: parseExpiresAt(body.expires_in),\n scopes: parseScopes(body.scope),\n };\n}\n\nfunction parseAccessibleResource(value: unknown): JiraAccessibleResource {\n if (!value || typeof value !== 'object') throw malformed('Jira site response was malformed');\n const {id, name, url, scopes} = value as JiraResourceResponse;\n if (\n typeof id !== 'string' ||\n id.length === 0 ||\n typeof name !== 'string' ||\n name.length === 0 ||\n typeof url !== 'string' ||\n url.length === 0 ||\n !Array.isArray(scopes) ||\n !scopes.every((scope) => typeof scope === 'string')\n ) {\n throw malformed('Jira site response did not include a valid cloud id, name, URL, and scopes');\n }\n return {cloudId: id, name, url, scopes};\n}\n\nfunction parseScopes(scope: unknown): string[] {\n if (typeof scope === 'string')\n return scope\n .split(SCOPE_SEPARATOR_RE)\n .map((value) => value.trim())\n .filter(Boolean);\n if (Array.isArray(scope) && scope.every((value) => typeof value === 'string')) return scope;\n if (scope === undefined) return [];\n throw malformed('Jira authorization response included malformed scopes');\n}\n\nfunction parseExpiresAt(expiresIn: unknown): Date | undefined {\n if (expiresIn === undefined) return undefined;\n if (typeof expiresIn !== 'number' || !Number.isFinite(expiresIn) || expiresIn <= 0) {\n throw malformed('Jira authorization response included a malformed expiry');\n }\n return new Date(Date.now() + expiresIn * 1000);\n}\n\nexport async function mapJiraError<T>(operation: string, request: () => Promise<T>): Promise<T> {\n try {\n return await request();\n } catch (error) {\n if (error instanceof JiraIntegrationProviderError) throw error;\n if (error instanceof HTTPError) {\n const {status, statusText, headers} = error.response;\n logger().warn({operation, status, statusText}, 'Jira API request rejected');\n if (status === 429) {\n throw new JiraIntegrationProviderError(\n 'rate-limited',\n 'Jira request was rate limited',\n retryAfterSeconds(headers),\n );\n }\n if (status >= 500)\n throw new JiraIntegrationProviderError('provider-unavailable', 'Jira request failed');\n if (status === 401 || status === 403) {\n throw new JiraIntegrationProviderError('access-denied', 'Jira request was rejected');\n }\n throw malformed('Jira request was rejected');\n }\n if (error instanceof TimeoutError) {\n logger().warn({operation}, 'Jira API request timed out');\n throw new JiraIntegrationProviderError('timeout', 'Jira request timed out');\n }\n logger().warn(\n {operation, errName: error instanceof Error ? error.name : typeof error},\n 'Jira API request failed',\n );\n throw new JiraIntegrationProviderError('provider-unavailable', 'Jira request failed');\n }\n}\n\nfunction malformed(message: string): JiraIntegrationProviderError {\n return new JiraIntegrationProviderError('malformed-provider-response', message);\n}\n\nfunction retryAfterSeconds(headers: Headers): number | undefined {\n const value = headers.get('retry-after');\n if (!value) return undefined;\n const parsed = Number.parseInt(value, 10);\n return Number.isNaN(parsed) ? undefined : parsed;\n}\n"],"names":["logger","ky","HTTPError","TimeoutError","config","JiraIntegrationProviderError","JIRA_API_TIMEOUT_MS","SCOPE_SEPARATOR_RE","createJiraApiClient","exchangeAuthorizationCode","input","body","mapJiraError","post","JIRA_AUTH_BASE_URL","URLSearchParams","grant_type","client_id","JIRA_OAUTH_CLIENT_ID","client_secret","JIRA_OAUTH_CLIENT_SECRET","code","redirect_uri","JIRA_OAUTH_REDIRECT_URL","timeout","json","parseAuthorization","refreshAccessToken","refresh_token","refreshToken","getAccessibleResources","get","JIRA_API_BASE_URL","headers","authorization","accessToken","Array","isArray","malformed","map","parseAccessibleResource","getMyself","cloudId","accountId","length","access_token","undefined","expiresAt","parseExpiresAt","expires_in","scopes","parseScopes","scope","value","id","name","url","every","split","trim","filter","Boolean","expiresIn","Number","isFinite","Date","now","operation","request","error","status","statusText","response","warn","retryAfterSeconds","errName","Error","message","parsed","parseInt","isNaN"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/api/client.ts"],"sourcesContent":["import {jiraWebhookEventNames} from '@shipfox/api-integration-jira-dto';\nimport {logger} from '@shipfox/node-opentelemetry';\nimport ky, {HTTPError, TimeoutError} from 'ky';\nimport {config} from '#config.js';\nimport {JiraIntegrationProviderError} from '#core/errors.js';\n\nconst JIRA_API_TIMEOUT_MS = 10_000;\nconst SCOPE_SEPARATOR_RE = /[,\\s]+/;\nexport const JIRA_DYNAMIC_WEBHOOK_EVENTS = jiraWebhookEventNames;\nexport const JIRA_DYNAMIC_WEBHOOK_JQL = '';\n\nexport interface JiraAuthorization {\n accessToken: string;\n refreshToken?: string | undefined;\n expiresAt?: Date | undefined;\n scopes: string[];\n}\n\nexport interface JiraAccessibleResource {\n cloudId: string;\n name: string;\n url: string;\n scopes: string[];\n}\n\nexport interface JiraIdentity {\n accountId: string;\n}\n\nexport interface JiraDynamicWebhookRegistration {\n webhookId: number;\n}\n\nexport interface JiraApiClient {\n exchangeAuthorizationCode(input: {code: string}): Promise<JiraAuthorization>;\n refreshAccessToken(input: {refreshToken: string}): Promise<JiraAuthorization>;\n getAccessibleResources(input: {accessToken: string}): Promise<JiraAccessibleResource[]>;\n getMyself(input: {accessToken: string; cloudId: string}): Promise<JiraIdentity>;\n registerDynamicWebhook(input: {\n accessToken: string;\n cloudId: string;\n url: string;\n }): Promise<JiraDynamicWebhookRegistration>;\n deleteDynamicWebhook(input: {\n accessToken: string;\n cloudId: string;\n webhookId: number;\n }): Promise<void>;\n}\n\ninterface JiraTokenResponse {\n access_token?: unknown;\n refresh_token?: unknown;\n expires_in?: unknown;\n scope?: unknown;\n}\n\ninterface JiraResourceResponse {\n id?: unknown;\n name?: unknown;\n url?: unknown;\n scopes?: unknown;\n}\n\ninterface JiraMyselfResponse {\n accountId?: unknown;\n}\n\ninterface JiraDynamicWebhookRegistrationResponse {\n webhookRegistrationResult?: unknown;\n}\n\nexport function createJiraApiClient(): JiraApiClient {\n return {\n async exchangeAuthorizationCode(input) {\n const body = await mapJiraError('exchange-authorization-code', () =>\n ky\n .post(`${config.JIRA_AUTH_BASE_URL}/oauth/token`, {\n body: new URLSearchParams({\n grant_type: 'authorization_code',\n client_id: config.JIRA_OAUTH_CLIENT_ID,\n client_secret: config.JIRA_OAUTH_CLIENT_SECRET,\n code: input.code,\n redirect_uri: config.JIRA_OAUTH_REDIRECT_URL,\n }),\n timeout: JIRA_API_TIMEOUT_MS,\n })\n .json<JiraTokenResponse>(),\n );\n return parseAuthorization(body);\n },\n\n async refreshAccessToken(input) {\n const body = await mapJiraError('refresh-access-token', () =>\n ky\n .post(`${config.JIRA_AUTH_BASE_URL}/oauth/token`, {\n body: new URLSearchParams({\n grant_type: 'refresh_token',\n client_id: config.JIRA_OAUTH_CLIENT_ID,\n client_secret: config.JIRA_OAUTH_CLIENT_SECRET,\n refresh_token: input.refreshToken,\n }),\n timeout: JIRA_API_TIMEOUT_MS,\n })\n .json<JiraTokenResponse>(),\n );\n return parseAuthorization(body);\n },\n\n async getAccessibleResources(input) {\n const body = await mapJiraError('get-accessible-resources', () =>\n ky\n .get(`${config.JIRA_API_BASE_URL}/oauth/token/accessible-resources`, {\n headers: {authorization: `Bearer ${input.accessToken}`},\n timeout: JIRA_API_TIMEOUT_MS,\n })\n .json<unknown>(),\n );\n if (!Array.isArray(body)) {\n throw malformed('Jira accessible-resources response was not an array');\n }\n return body.map(parseAccessibleResource);\n },\n\n async getMyself(input) {\n const body = await mapJiraError('get-myself', () =>\n ky\n .get(`${config.JIRA_API_BASE_URL}/ex/jira/${input.cloudId}/rest/api/3/myself`, {\n headers: {authorization: `Bearer ${input.accessToken}`},\n timeout: JIRA_API_TIMEOUT_MS,\n })\n .json<JiraMyselfResponse>(),\n );\n if (typeof body.accountId !== 'string' || body.accountId.length === 0) {\n throw malformed('Jira identity response did not include an accountId');\n }\n return {accountId: body.accountId};\n },\n\n async registerDynamicWebhook(input) {\n const body = await mapJiraError('register-dynamic-webhook', () =>\n ky\n .post(`${config.JIRA_API_BASE_URL}/ex/jira/${input.cloudId}/rest/api/3/webhook`, {\n headers: {authorization: `Bearer ${input.accessToken}`},\n json: {\n url: input.url,\n webhooks: [\n {\n events: JIRA_DYNAMIC_WEBHOOK_EVENTS,\n jqlFilter: JIRA_DYNAMIC_WEBHOOK_JQL,\n },\n ],\n },\n timeout: JIRA_API_TIMEOUT_MS,\n })\n .json<JiraDynamicWebhookRegistrationResponse>(),\n );\n return parseDynamicWebhookRegistration(body);\n },\n\n async deleteDynamicWebhook(input) {\n await mapJiraError('delete-dynamic-webhook', () =>\n ky.delete(`${config.JIRA_API_BASE_URL}/ex/jira/${input.cloudId}/rest/api/3/webhook`, {\n headers: {authorization: `Bearer ${input.accessToken}`},\n json: {webhookIds: [input.webhookId]},\n timeout: JIRA_API_TIMEOUT_MS,\n }),\n );\n },\n };\n}\n\nfunction parseDynamicWebhookRegistration(\n body: JiraDynamicWebhookRegistrationResponse,\n): JiraDynamicWebhookRegistration {\n if (\n !body ||\n typeof body !== 'object' ||\n !Array.isArray(body.webhookRegistrationResult) ||\n body.webhookRegistrationResult.length !== 1\n ) {\n throw malformed('Jira webhook registration response did not contain exactly one result');\n }\n\n const result = body.webhookRegistrationResult[0];\n if (!result || typeof result !== 'object') {\n throw malformed('Jira webhook registration result was malformed');\n }\n const {createdWebhookId, errors} = result as {\n createdWebhookId?: unknown;\n errors?: unknown;\n };\n if (errors !== undefined && (!Array.isArray(errors) || errors.length > 0)) {\n throw malformed('Jira webhook registration returned errors');\n }\n if (\n typeof createdWebhookId !== 'number' ||\n !Number.isSafeInteger(createdWebhookId) ||\n createdWebhookId <= 0\n ) {\n throw malformed('Jira webhook registration did not return a created webhook id');\n }\n return {webhookId: createdWebhookId};\n}\n\nfunction parseAuthorization(body: JiraTokenResponse): JiraAuthorization {\n if (typeof body.access_token !== 'string' || body.access_token.length === 0) {\n throw malformed('Jira authorization response did not include an access token');\n }\n return {\n accessToken: body.access_token,\n refreshToken: typeof body.refresh_token === 'string' ? body.refresh_token : undefined,\n expiresAt: parseExpiresAt(body.expires_in),\n scopes: parseScopes(body.scope),\n };\n}\n\nfunction parseAccessibleResource(value: unknown): JiraAccessibleResource {\n if (!value || typeof value !== 'object') throw malformed('Jira site response was malformed');\n const {id, name, url, scopes} = value as JiraResourceResponse;\n if (\n typeof id !== 'string' ||\n id.length === 0 ||\n typeof name !== 'string' ||\n name.length === 0 ||\n typeof url !== 'string' ||\n url.length === 0 ||\n !Array.isArray(scopes) ||\n !scopes.every((scope) => typeof scope === 'string')\n ) {\n throw malformed('Jira site response did not include a valid cloud id, name, URL, and scopes');\n }\n return {cloudId: id, name, url, scopes};\n}\n\nfunction parseScopes(scope: unknown): string[] {\n if (typeof scope === 'string')\n return scope\n .split(SCOPE_SEPARATOR_RE)\n .map((value) => value.trim())\n .filter(Boolean);\n if (Array.isArray(scope) && scope.every((value) => typeof value === 'string')) return scope;\n if (scope === undefined) return [];\n throw malformed('Jira authorization response included malformed scopes');\n}\n\nfunction parseExpiresAt(expiresIn: unknown): Date | undefined {\n if (expiresIn === undefined) return undefined;\n if (typeof expiresIn !== 'number' || !Number.isFinite(expiresIn) || expiresIn <= 0) {\n throw malformed('Jira authorization response included a malformed expiry');\n }\n return new Date(Date.now() + expiresIn * 1000);\n}\n\nexport async function mapJiraError<T>(operation: string, request: () => Promise<T>): Promise<T> {\n try {\n return await request();\n } catch (error) {\n if (error instanceof JiraIntegrationProviderError) throw error;\n if (error instanceof HTTPError) {\n const {status, statusText, headers} = error.response;\n logger().warn({operation, status, statusText}, 'Jira API request rejected');\n if (status === 429) {\n throw new JiraIntegrationProviderError(\n 'rate-limited',\n 'Jira request was rate limited',\n retryAfterSeconds(headers),\n );\n }\n if (status >= 500)\n throw new JiraIntegrationProviderError('provider-unavailable', 'Jira request failed');\n if (status === 401 || status === 403) {\n throw new JiraIntegrationProviderError('access-denied', 'Jira request was rejected');\n }\n throw malformed('Jira request was rejected');\n }\n if (error instanceof TimeoutError) {\n logger().warn({operation}, 'Jira API request timed out');\n throw new JiraIntegrationProviderError('timeout', 'Jira request timed out');\n }\n logger().warn(\n {operation, errName: error instanceof Error ? error.name : typeof error},\n 'Jira API request failed',\n );\n throw new JiraIntegrationProviderError('provider-unavailable', 'Jira request failed');\n }\n}\n\nfunction malformed(message: string): JiraIntegrationProviderError {\n return new JiraIntegrationProviderError('malformed-provider-response', message);\n}\n\nfunction retryAfterSeconds(headers: Headers): number | undefined {\n const value = headers.get('retry-after');\n if (!value) return undefined;\n const parsed = Number.parseInt(value, 10);\n return Number.isNaN(parsed) ? undefined : parsed;\n}\n"],"names":["jiraWebhookEventNames","logger","ky","HTTPError","TimeoutError","config","JiraIntegrationProviderError","JIRA_API_TIMEOUT_MS","SCOPE_SEPARATOR_RE","JIRA_DYNAMIC_WEBHOOK_EVENTS","JIRA_DYNAMIC_WEBHOOK_JQL","createJiraApiClient","exchangeAuthorizationCode","input","body","mapJiraError","post","JIRA_AUTH_BASE_URL","URLSearchParams","grant_type","client_id","JIRA_OAUTH_CLIENT_ID","client_secret","JIRA_OAUTH_CLIENT_SECRET","code","redirect_uri","JIRA_OAUTH_REDIRECT_URL","timeout","json","parseAuthorization","refreshAccessToken","refresh_token","refreshToken","getAccessibleResources","get","JIRA_API_BASE_URL","headers","authorization","accessToken","Array","isArray","malformed","map","parseAccessibleResource","getMyself","cloudId","accountId","length","registerDynamicWebhook","url","webhooks","events","jqlFilter","parseDynamicWebhookRegistration","deleteDynamicWebhook","delete","webhookIds","webhookId","webhookRegistrationResult","result","createdWebhookId","errors","undefined","Number","isSafeInteger","access_token","expiresAt","parseExpiresAt","expires_in","scopes","parseScopes","scope","value","id","name","every","split","trim","filter","Boolean","expiresIn","isFinite","Date","now","operation","request","error","status","statusText","response","warn","retryAfterSeconds","errName","Error","message","parsed","parseInt","isNaN"],"mappings":"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"}
package/dist/config.d.ts CHANGED
@@ -2,7 +2,6 @@ export declare const config: Readonly<{
2
2
  JIRA_OAUTH_CLIENT_ID: string;
3
3
  JIRA_OAUTH_CLIENT_SECRET: string;
4
4
  JIRA_OAUTH_REDIRECT_URL: string;
5
- JIRA_WEBHOOK_SIGNING_SECRET: string;
6
5
  JIRA_WEBHOOK_BASE_URL: string;
7
6
  JIRA_API_BASE_URL: string;
8
7
  JIRA_AUTH_BASE_URL: string;
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,MAAM;;;;;;;;kDAwBjB,CAAC"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,MAAM;;;;;;;kDAqBjB,CAAC"}
package/dist/config.js CHANGED
@@ -4,14 +4,11 @@ export const config = createConfig({
4
4
  desc: 'OAuth client ID of the Jira app, used to start and complete the Jira connect flow. Required.'
5
5
  }),
6
6
  JIRA_OAUTH_CLIENT_SECRET: str({
7
- desc: 'OAuth client secret of the Jira app, used to exchange authorization codes for access and refresh tokens. Required.'
7
+ desc: 'OAuth client secret of the Jira app, used for authorization-code exchange and inbound webhook JWT verification. Required.'
8
8
  }),
9
9
  JIRA_OAUTH_REDIRECT_URL: url({
10
10
  desc: 'Public client callback URL Jira redirects to after OAuth authorization, such as https://shipfox.example.com/integrations/jira/callback. Required.'
11
11
  }),
12
- JIRA_WEBHOOK_SIGNING_SECRET: str({
13
- desc: 'Secret used to verify incoming Jira webhooks. It must match the webhook signing secret configured for the Jira app. Required.'
14
- }),
15
12
  JIRA_WEBHOOK_BASE_URL: url({
16
13
  desc: 'Public base URL Jira sends webhooks to. Its origin must be added to the Jira app webhook allowlist. Required.'
17
14
  }),
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/config.ts"],"sourcesContent":["import {createConfig, str, url} from '@shipfox/config';\n\nexport const config = createConfig({\n JIRA_OAUTH_CLIENT_ID: str({\n desc: 'OAuth client ID of the Jira app, used to start and complete the Jira connect flow. Required.',\n }),\n JIRA_OAUTH_CLIENT_SECRET: str({\n desc: 'OAuth client secret of the Jira app, used to exchange authorization codes for access and refresh tokens. Required.',\n }),\n JIRA_OAUTH_REDIRECT_URL: url({\n desc: 'Public client callback URL Jira redirects to after OAuth authorization, such as https://shipfox.example.com/integrations/jira/callback. Required.',\n }),\n JIRA_WEBHOOK_SIGNING_SECRET: str({\n desc: 'Secret used to verify incoming Jira webhooks. It must match the webhook signing secret configured for the Jira app. Required.',\n }),\n JIRA_WEBHOOK_BASE_URL: url({\n desc: 'Public base URL Jira sends webhooks to. Its origin must be added to the Jira app webhook allowlist. Required.',\n }),\n JIRA_API_BASE_URL: url({\n desc: 'Jira API base URL. Override this only when routing Jira API requests through a proxy or test server.',\n default: 'https://api.atlassian.com',\n }),\n JIRA_AUTH_BASE_URL: url({\n desc: 'Jira OAuth base URL. Override this only when routing OAuth requests through a proxy or test server.',\n default: 'https://auth.atlassian.com',\n }),\n});\n"],"names":["createConfig","str","url","config","JIRA_OAUTH_CLIENT_ID","desc","JIRA_OAUTH_CLIENT_SECRET","JIRA_OAUTH_REDIRECT_URL","JIRA_WEBHOOK_SIGNING_SECRET","JIRA_WEBHOOK_BASE_URL","JIRA_API_BASE_URL","default","JIRA_AUTH_BASE_URL"],"mappings":"AAAA,SAAQA,YAAY,EAAEC,GAAG,EAAEC,GAAG,QAAO,kBAAkB;AAEvD,OAAO,MAAMC,SAASH,aAAa;IACjCI,sBAAsBH,IAAI;QACxBI,MAAM;IACR;IACAC,0BAA0BL,IAAI;QAC5BI,MAAM;IACR;IACAE,yBAAyBL,IAAI;QAC3BG,MAAM;IACR;IACAG,6BAA6BP,IAAI;QAC/BI,MAAM;IACR;IACAI,uBAAuBP,IAAI;QACzBG,MAAM;IACR;IACAK,mBAAmBR,IAAI;QACrBG,MAAM;QACNM,SAAS;IACX;IACAC,oBAAoBV,IAAI;QACtBG,MAAM;QACNM,SAAS;IACX;AACF,GAAG"}
1
+ {"version":3,"sources":["../src/config.ts"],"sourcesContent":["import {createConfig, str, url} from '@shipfox/config';\n\nexport const config = createConfig({\n JIRA_OAUTH_CLIENT_ID: str({\n desc: 'OAuth client ID of the Jira app, used to start and complete the Jira connect flow. Required.',\n }),\n JIRA_OAUTH_CLIENT_SECRET: str({\n desc: 'OAuth client secret of the Jira app, used for authorization-code exchange and inbound webhook JWT verification. Required.',\n }),\n JIRA_OAUTH_REDIRECT_URL: url({\n desc: 'Public client callback URL Jira redirects to after OAuth authorization, such as https://shipfox.example.com/integrations/jira/callback. Required.',\n }),\n JIRA_WEBHOOK_BASE_URL: url({\n desc: 'Public base URL Jira sends webhooks to. Its origin must be added to the Jira app webhook allowlist. Required.',\n }),\n JIRA_API_BASE_URL: url({\n desc: 'Jira API base URL. Override this only when routing Jira API requests through a proxy or test server.',\n default: 'https://api.atlassian.com',\n }),\n JIRA_AUTH_BASE_URL: url({\n desc: 'Jira OAuth base URL. Override this only when routing OAuth requests through a proxy or test server.',\n default: 'https://auth.atlassian.com',\n }),\n});\n"],"names":["createConfig","str","url","config","JIRA_OAUTH_CLIENT_ID","desc","JIRA_OAUTH_CLIENT_SECRET","JIRA_OAUTH_REDIRECT_URL","JIRA_WEBHOOK_BASE_URL","JIRA_API_BASE_URL","default","JIRA_AUTH_BASE_URL"],"mappings":"AAAA,SAAQA,YAAY,EAAEC,GAAG,EAAEC,GAAG,QAAO,kBAAkB;AAEvD,OAAO,MAAMC,SAASH,aAAa;IACjCI,sBAAsBH,IAAI;QACxBI,MAAM;IACR;IACAC,0BAA0BL,IAAI;QAC5BI,MAAM;IACR;IACAE,yBAAyBL,IAAI;QAC3BG,MAAM;IACR;IACAG,uBAAuBN,IAAI;QACzBG,MAAM;IACR;IACAI,mBAAmBP,IAAI;QACrBG,MAAM;QACNK,SAAS;IACX;IACAC,oBAAoBT,IAAI;QACtBG,MAAM;QACNK,SAAS;IACX;AACF,GAAG"}
@@ -3,6 +3,7 @@ import type { IntegrationConnection } from '@shipfox/api-integration-spi';
3
3
  import type { JiraAccessibleResource, JiraApiClient } from '#api/client.js';
4
4
  import type { JiraPendingSelectionStore } from '#core/pending.js';
5
5
  import type { JiraTokenStore } from '#core/tokens.js';
6
+ import { type JiraInstallationLock } from '#db/installations.js';
6
7
  export interface ConnectJiraInstallationInput {
7
8
  workspaceId: string;
8
9
  cloudId: string;
@@ -30,6 +31,27 @@ export interface HandleJiraCallbackParams {
30
31
  cloudId: string;
31
32
  }): Promise<IntegrationConnection<'jira'> | undefined>;
32
33
  connectJiraInstallation(input: ConnectJiraInstallationInput): Promise<IntegrationConnection<'jira'>>;
34
+ registerJiraWebhook(input: {
35
+ connectionId: string;
36
+ cloudId: string;
37
+ accessToken: string;
38
+ withRegistrationLock?: JiraInstallationLock;
39
+ onRegistrationSuccess: (input: {
40
+ tx?: unknown;
41
+ }) => Promise<void>;
42
+ onRegistrationFailure: (input: {
43
+ tx?: unknown;
44
+ }) => Promise<void>;
45
+ }): Promise<void>;
46
+ withJiraInstallationLock?: JiraInstallationLock;
47
+ markConnectionActive(input: {
48
+ connectionId: string;
49
+ tx?: unknown;
50
+ }): Promise<void>;
51
+ markConnectionError(input: {
52
+ connectionId: string;
53
+ tx?: unknown;
54
+ }): Promise<void>;
33
55
  disconnectJiraInstallation(input: {
34
56
  connectionId: string;
35
57
  }): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"install.d.ts","sourceRoot":"","sources":["../../src/core/install.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,qBAAqB,EAAC,MAAM,2BAA2B,CAAC;AACrE,OAAO,KAAK,EAAC,qBAAqB,EAAC,MAAM,8BAA8B,CAAC;AAExE,OAAO,KAAK,EAAC,sBAAsB,EAAE,aAAa,EAAoB,MAAM,gBAAgB,CAAC;AAC7F,OAAO,KAAK,EAAC,yBAAyB,EAAC,MAAM,kBAAkB,CAAC;AAChE,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,iBAAiB,CAAC;AAapD,MAAM,WAAW,4BAA4B;IAC3C,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,cAAc,EAAE,IAAI,GAAG,IAAI,CAAC;IAC5B,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,wBAAwB;IACvC,IAAI,EAAE,aAAa,CAAC;IACpB,UAAU,EAAE,IAAI,CAAC,cAAc,EAAE,aAAa,CAAC,CAAC;IAChD,YAAY,EAAE,IAAI,CAAC,yBAAyB,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,CAAC;IACzE,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,aAAa,EAAE,MAAM,CAAC;IACtB,kBAAkB,EAAE,aAAa,CAAC,qBAAqB,CAAC,CAAC;IACzD,0BAA0B,CAAC,KAAK,EAAE;QAChC,WAAW,EAAE,MAAM,CAAC;QACpB,MAAM,EAAE,MAAM,CAAC;QACf,WAAW,EAAE,aAAa,CAAC,qBAAqB,CAAC,CAAC;KACnD,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACrB,yBAAyB,CAAC,KAAK,EAAE;QAC/B,OAAO,EAAE,MAAM,CAAC;KACjB,GAAG,OAAO,CAAC,qBAAqB,CAAC,MAAM,CAAC,GAAG,SAAS,CAAC,CAAC;IACvD,uBAAuB,CACrB,KAAK,EAAE,4BAA4B,GAClC,OAAO,CAAC,qBAAqB,CAAC,MAAM,CAAC,CAAC,CAAC;IAC1C,0BAA0B,CAAC,KAAK,EAAE;QAAC,YAAY,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1E;AAED,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,wBAAwB,GAC/B,OAAO,CAAC,qBAAqB,CAAC,MAAM,CAAC,GAAG;IAAC,KAAK,EAAE,sBAAsB,EAAE,CAAA;CAAC,CAAC,CA4B5E;AAED,wBAAsB,uBAAuB,CAC3C,MAAM,EAAE,IAAI,CAAC,wBAAwB,EAAE,MAAM,CAAC,GAAG;IAAC,OAAO,EAAE,MAAM,CAAA;CAAC,GACjE,OAAO,CAAC,qBAAqB,CAAC,MAAM,CAAC,CAAC,CAkBxC;AAED,wBAAsB,4BAA4B,CAAC,MAAM,EAAE;IACzD,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,gBAAgB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACtC,aAAa,EAAE,MAAM,CAAC;IACtB,kBAAkB,EAAE,aAAa,CAAC,qBAAqB,CAAC,CAAC;IACzD,0BAA0B,CAAC,KAAK,EAAE;QAChC,WAAW,EAAE,MAAM,CAAC;QACpB,MAAM,EAAE,MAAM,CAAC;QACf,WAAW,EAAE,aAAa,CAAC,qBAAqB,CAAC,CAAC;KACnD,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CACtB,GAAG,OAAO,CAAC,KAAK,CAAC,CAGjB"}
1
+ {"version":3,"file":"install.d.ts","sourceRoot":"","sources":["../../src/core/install.ts"],"names":[],"mappings":"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"}
@@ -1,4 +1,5 @@
1
1
  import { logger } from '@shipfox/node-opentelemetry';
2
+ import { withJiraInstallationLock } from '#db/installations.js';
2
3
  import { JiraInstallationAlreadyLinkedError, JiraInstallStateActorMismatchError, JiraIntegrationProviderError, JiraOAuthCallbackError, JiraOfflineAccessNotGrantedError, JiraPendingSelectionNotFoundError, JiraSiteSelectionMismatchError } from './errors.js';
3
4
  import { assertJiraAuthorizationScopes } from './scopes.js';
4
5
  import { verifyJiraInstallState } from './state.js';
@@ -71,18 +72,18 @@ async function verifyClaims(params, state) {
71
72
  });
72
73
  return claims;
73
74
  }
74
- async function resolveJiraSite(params) {
75
- const existing = await params.getExistingJiraConnection({
76
- cloudId: params.site.cloudId
77
- });
78
- if (existing && existing.workspaceId !== params.claims.workspaceId) throw new JiraInstallationAlreadyLinkedError(params.site.cloudId);
79
- let connectionId;
80
- try {
75
+ function resolveJiraSite(params) {
76
+ const withInstallationLock = params.withJiraInstallationLock ?? withJiraInstallationLock;
77
+ return withInstallationLock(params.site.cloudId, async ()=>{
78
+ const existing = await params.getExistingJiraConnection({
79
+ cloudId: params.site.cloudId
80
+ });
81
+ if (existing && existing.workspaceId !== params.claims.workspaceId) throw new JiraInstallationAlreadyLinkedError(params.site.cloudId);
81
82
  const identity = await params.jira.getMyself({
82
83
  accessToken: params.authorization.accessToken,
83
84
  cloudId: params.site.cloudId
84
85
  });
85
- const connection = await params.connectJiraInstallation({
86
+ const installationInput = {
86
87
  workspaceId: params.claims.workspaceId,
87
88
  cloudId: params.site.cloudId,
88
89
  siteUrl: params.site.url,
@@ -91,19 +92,46 @@ async function resolveJiraSite(params) {
91
92
  scopes: params.site.scopes,
92
93
  tokenExpiresAt: params.authorization.expiresAt ?? null,
93
94
  displayName: `Jira ${params.site.name}`
94
- });
95
- connectionId = connection.id;
96
- await params.tokenStore.storeTokens({
97
- connectionId: connection.id,
98
- accessToken: params.authorization.accessToken,
99
- refreshToken: params.authorization.refreshToken,
100
- editedBy: params.claims.userId
101
- });
95
+ };
96
+ let connection = existing;
97
+ try {
98
+ if (!connection) connection = await params.connectJiraInstallation(installationInput);
99
+ await params.tokenStore.storeTokens({
100
+ connectionId: connection.id,
101
+ accessToken: params.authorization.accessToken,
102
+ refreshToken: params.authorization.refreshToken,
103
+ editedBy: params.claims.userId
104
+ });
105
+ if (existing) connection = await params.connectJiraInstallation(installationInput);
106
+ } catch (error) {
107
+ if (existing) {
108
+ const markedError = await bestEffortMarkConnectionError(params, existing.id);
109
+ if (!markedError) await bestEffortMarkConnectionError(params, existing.id);
110
+ } else if (connection) {
111
+ await bestEffortDisconnect(params, connection.id);
112
+ }
113
+ throw error;
114
+ }
115
+ let registrationFailureHandled = false;
116
+ try {
117
+ await params.registerJiraWebhook({
118
+ connectionId: connection.id,
119
+ cloudId: params.site.cloudId,
120
+ accessToken: params.authorization.accessToken,
121
+ withRegistrationLock: async (_lockKey, fn)=>fn(),
122
+ onRegistrationSuccess: async ({ tx })=>{
123
+ await params.markConnectionActive(connectionLifecycleInput(connection.id, tx));
124
+ },
125
+ onRegistrationFailure: async ({ tx })=>{
126
+ registrationFailureHandled = await bestEffortMarkConnectionError(params, connection.id, tx);
127
+ }
128
+ });
129
+ } catch (error) {
130
+ if (!registrationFailureHandled) await bestEffortMarkConnectionError(params, connection.id);
131
+ throw error;
132
+ }
102
133
  return connection;
103
- } catch (error) {
104
- if (connectionId && !existing) await bestEffortDisconnect(params, connectionId);
105
- throw error;
106
- }
134
+ });
107
135
  }
108
136
  async function bestEffortDisconnect(params, connectionId) {
109
137
  try {
@@ -117,5 +145,25 @@ async function bestEffortDisconnect(params, connectionId) {
117
145
  }, 'Jira connect compensation failed after token storage rejection');
118
146
  }
119
147
  }
148
+ async function bestEffortMarkConnectionError(params, connectionId, tx) {
149
+ try {
150
+ await params.markConnectionError(connectionLifecycleInput(connectionId, tx));
151
+ return true;
152
+ } catch (error) {
153
+ logger().warn({
154
+ err: error,
155
+ connectionId
156
+ }, 'Jira connection error-state update failed after webhook registration rejection');
157
+ return false;
158
+ }
159
+ }
160
+ function connectionLifecycleInput(connectionId, tx) {
161
+ return tx === undefined ? {
162
+ connectionId
163
+ } : {
164
+ connectionId,
165
+ tx
166
+ };
167
+ }
120
168
 
121
169
  //# sourceMappingURL=install.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/core/install.ts"],"sourcesContent":["import type {UserContextMembership} from '@shipfox/api-auth-context';\nimport type {IntegrationConnection} from '@shipfox/api-integration-spi';\nimport {logger} from '@shipfox/node-opentelemetry';\nimport type {JiraAccessibleResource, JiraApiClient, JiraAuthorization} from '#api/client.js';\nimport type {JiraPendingSelectionStore} from '#core/pending.js';\nimport type {JiraTokenStore} from '#core/tokens.js';\nimport {\n JiraInstallationAlreadyLinkedError,\n JiraInstallStateActorMismatchError,\n JiraIntegrationProviderError,\n JiraOAuthCallbackError,\n JiraOfflineAccessNotGrantedError,\n JiraPendingSelectionNotFoundError,\n JiraSiteSelectionMismatchError,\n} from './errors.js';\nimport {assertJiraAuthorizationScopes} from './scopes.js';\nimport {verifyJiraInstallState} from './state.js';\n\nexport interface ConnectJiraInstallationInput {\n workspaceId: string;\n cloudId: string;\n siteUrl: string;\n siteName: string;\n authorizingAccountId: string;\n scopes: string[];\n tokenExpiresAt: Date | null;\n displayName: string;\n}\n\nexport interface HandleJiraCallbackParams {\n jira: JiraApiClient;\n tokenStore: Pick<JiraTokenStore, 'storeTokens'>;\n pendingStore: Pick<JiraPendingSelectionStore, 'save' | 'load' | 'clear'>;\n code: string;\n state: string;\n sessionUserId: string;\n sessionMemberships: ReadonlyArray<UserContextMembership>;\n requireWorkspaceMembership(input: {\n workspaceId: string;\n userId: string;\n memberships: ReadonlyArray<UserContextMembership>;\n }): Promise<unknown>;\n getExistingJiraConnection(input: {\n cloudId: string;\n }): Promise<IntegrationConnection<'jira'> | undefined>;\n connectJiraInstallation(\n input: ConnectJiraInstallationInput,\n ): Promise<IntegrationConnection<'jira'>>;\n disconnectJiraInstallation(input: {connectionId: string}): Promise<void>;\n}\n\nexport async function handleJiraCallback(\n params: HandleJiraCallbackParams,\n): Promise<IntegrationConnection<'jira'> | {sites: JiraAccessibleResource[]}> {\n const claims = await verifyClaims(params, params.state);\n const authorization = await params.jira.exchangeAuthorizationCode({code: params.code});\n if (!authorization.refreshToken) throw new JiraOfflineAccessNotGrantedError();\n const sites = await params.jira.getAccessibleResources({accessToken: authorization.accessToken});\n if (sites.length === 0)\n throw new JiraIntegrationProviderError(\n 'access-denied',\n 'Jira authorization did not grant access to a site',\n );\n if (sites.length > 1) {\n await params.pendingStore.save({\n workspaceId: claims.workspaceId,\n state: params.state,\n authorization,\n sites,\n });\n return {sites};\n }\n const site = sites[0];\n if (!site) {\n throw new JiraIntegrationProviderError(\n 'malformed-provider-response',\n 'Jira accessible-resources response did not contain a selected site',\n );\n }\n assertJiraAuthorizationScopes(site.scopes);\n return resolveJiraSite({...params, authorization, site, claims});\n}\n\nexport async function handleJiraSiteSelection(\n params: Omit<HandleJiraCallbackParams, 'code'> & {cloudId: string},\n): Promise<IntegrationConnection<'jira'>> {\n const claims = await verifyClaims(params, params.state);\n const pending = await params.pendingStore.load({\n workspaceId: claims.workspaceId,\n state: params.state,\n });\n if (!pending) throw new JiraPendingSelectionNotFoundError();\n const site = pending.sites.find((candidate) => candidate.cloudId === params.cloudId);\n if (!site) throw new JiraSiteSelectionMismatchError(params.cloudId);\n assertJiraAuthorizationScopes(site.scopes);\n const connection = await resolveJiraSite({\n ...params,\n authorization: pending.authorization,\n site,\n claims,\n });\n await params.pendingStore.clear({workspaceId: claims.workspaceId, state: params.state});\n return connection;\n}\n\nexport async function handleJiraOAuthCallbackError(params: {\n state: string;\n error: string;\n errorDescription?: string | undefined;\n sessionUserId: string;\n sessionMemberships: ReadonlyArray<UserContextMembership>;\n requireWorkspaceMembership(input: {\n workspaceId: string;\n userId: string;\n memberships: ReadonlyArray<UserContextMembership>;\n }): Promise<unknown>;\n}): Promise<never> {\n await verifyClaims(params, params.state);\n throw new JiraOAuthCallbackError(params.error, params.errorDescription);\n}\n\nasync function verifyClaims(\n params: Pick<\n HandleJiraCallbackParams,\n 'sessionUserId' | 'sessionMemberships' | 'requireWorkspaceMembership'\n >,\n state: string,\n) {\n const claims = verifyJiraInstallState(state);\n if (claims.userId !== params.sessionUserId) throw new JiraInstallStateActorMismatchError();\n await params.requireWorkspaceMembership({\n workspaceId: claims.workspaceId,\n userId: claims.userId,\n memberships: params.sessionMemberships,\n });\n return claims;\n}\n\nasync function resolveJiraSite(\n params: Omit<HandleJiraCallbackParams, 'code'> & {\n authorization: JiraAuthorization;\n site: JiraAccessibleResource;\n claims: {workspaceId: string; userId: string};\n },\n): Promise<IntegrationConnection<'jira'>> {\n const existing = await params.getExistingJiraConnection({cloudId: params.site.cloudId});\n if (existing && existing.workspaceId !== params.claims.workspaceId)\n throw new JiraInstallationAlreadyLinkedError(params.site.cloudId);\n let connectionId: string | undefined;\n try {\n const identity = await params.jira.getMyself({\n accessToken: params.authorization.accessToken,\n cloudId: params.site.cloudId,\n });\n const connection = await params.connectJiraInstallation({\n workspaceId: params.claims.workspaceId,\n cloudId: params.site.cloudId,\n siteUrl: params.site.url,\n siteName: params.site.name,\n authorizingAccountId: identity.accountId,\n scopes: params.site.scopes,\n tokenExpiresAt: params.authorization.expiresAt ?? null,\n displayName: `Jira ${params.site.name}`,\n });\n connectionId = connection.id;\n await params.tokenStore.storeTokens({\n connectionId: connection.id,\n accessToken: params.authorization.accessToken,\n refreshToken: params.authorization.refreshToken,\n editedBy: params.claims.userId,\n });\n return connection;\n } catch (error) {\n if (connectionId && !existing) await bestEffortDisconnect(params, connectionId);\n throw error;\n }\n}\n\nasync function bestEffortDisconnect(\n params: Pick<HandleJiraCallbackParams, 'disconnectJiraInstallation'>,\n connectionId: string,\n): Promise<void> {\n try {\n await params.disconnectJiraInstallation({connectionId});\n } catch (error) {\n logger().warn(\n {err: error, connectionId},\n 'Jira connect compensation failed after token storage rejection',\n );\n }\n}\n"],"names":["logger","JiraInstallationAlreadyLinkedError","JiraInstallStateActorMismatchError","JiraIntegrationProviderError","JiraOAuthCallbackError","JiraOfflineAccessNotGrantedError","JiraPendingSelectionNotFoundError","JiraSiteSelectionMismatchError","assertJiraAuthorizationScopes","verifyJiraInstallState","handleJiraCallback","params","claims","verifyClaims","state","authorization","jira","exchangeAuthorizationCode","code","refreshToken","sites","getAccessibleResources","accessToken","length","pendingStore","save","workspaceId","site","scopes","resolveJiraSite","handleJiraSiteSelection","pending","load","find","candidate","cloudId","connection","clear","handleJiraOAuthCallbackError","error","errorDescription","userId","sessionUserId","requireWorkspaceMembership","memberships","sessionMemberships","existing","getExistingJiraConnection","connectionId","identity","getMyself","connectJiraInstallation","siteUrl","url","siteName","name","authorizingAccountId","accountId","tokenExpiresAt","expiresAt","displayName","id","tokenStore","storeTokens","editedBy","bestEffortDisconnect","disconnectJiraInstallation","warn","err"],"mappings":"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"}
1
+ {"version":3,"sources":["../../src/core/install.ts"],"sourcesContent":["import type {UserContextMembership} from '@shipfox/api-auth-context';\nimport type {IntegrationConnection} from '@shipfox/api-integration-spi';\nimport {logger} from '@shipfox/node-opentelemetry';\nimport type {JiraAccessibleResource, JiraApiClient, JiraAuthorization} from '#api/client.js';\nimport type {JiraPendingSelectionStore} from '#core/pending.js';\nimport type {JiraTokenStore} from '#core/tokens.js';\nimport {type JiraInstallationLock, withJiraInstallationLock} from '#db/installations.js';\nimport {\n JiraInstallationAlreadyLinkedError,\n JiraInstallStateActorMismatchError,\n JiraIntegrationProviderError,\n JiraOAuthCallbackError,\n JiraOfflineAccessNotGrantedError,\n JiraPendingSelectionNotFoundError,\n JiraSiteSelectionMismatchError,\n} from './errors.js';\nimport {assertJiraAuthorizationScopes} from './scopes.js';\nimport {verifyJiraInstallState} from './state.js';\n\nexport interface ConnectJiraInstallationInput {\n workspaceId: string;\n cloudId: string;\n siteUrl: string;\n siteName: string;\n authorizingAccountId: string;\n scopes: string[];\n tokenExpiresAt: Date | null;\n displayName: string;\n}\n\nexport interface HandleJiraCallbackParams {\n jira: JiraApiClient;\n tokenStore: Pick<JiraTokenStore, 'storeTokens'>;\n pendingStore: Pick<JiraPendingSelectionStore, 'save' | 'load' | 'clear'>;\n code: string;\n state: string;\n sessionUserId: string;\n sessionMemberships: ReadonlyArray<UserContextMembership>;\n requireWorkspaceMembership(input: {\n workspaceId: string;\n userId: string;\n memberships: ReadonlyArray<UserContextMembership>;\n }): Promise<unknown>;\n getExistingJiraConnection(input: {\n cloudId: string;\n }): Promise<IntegrationConnection<'jira'> | undefined>;\n connectJiraInstallation(\n input: ConnectJiraInstallationInput,\n ): Promise<IntegrationConnection<'jira'>>;\n registerJiraWebhook(input: {\n connectionId: string;\n cloudId: string;\n accessToken: string;\n withRegistrationLock?: JiraInstallationLock;\n onRegistrationSuccess: (input: {tx?: unknown}) => Promise<void>;\n onRegistrationFailure: (input: {tx?: unknown}) => Promise<void>;\n }): Promise<void>;\n withJiraInstallationLock?: JiraInstallationLock;\n markConnectionActive(input: {connectionId: string; tx?: unknown}): Promise<void>;\n markConnectionError(input: {connectionId: string; tx?: unknown}): Promise<void>;\n disconnectJiraInstallation(input: {connectionId: string}): Promise<void>;\n}\n\nexport async function handleJiraCallback(\n params: HandleJiraCallbackParams,\n): Promise<IntegrationConnection<'jira'> | {sites: JiraAccessibleResource[]}> {\n const claims = await verifyClaims(params, params.state);\n const authorization = await params.jira.exchangeAuthorizationCode({code: params.code});\n if (!authorization.refreshToken) throw new JiraOfflineAccessNotGrantedError();\n const sites = await params.jira.getAccessibleResources({accessToken: authorization.accessToken});\n if (sites.length === 0)\n throw new JiraIntegrationProviderError(\n 'access-denied',\n 'Jira authorization did not grant access to a site',\n );\n if (sites.length > 1) {\n await params.pendingStore.save({\n workspaceId: claims.workspaceId,\n state: params.state,\n authorization,\n sites,\n });\n return {sites};\n }\n const site = sites[0];\n if (!site) {\n throw new JiraIntegrationProviderError(\n 'malformed-provider-response',\n 'Jira accessible-resources response did not contain a selected site',\n );\n }\n assertJiraAuthorizationScopes(site.scopes);\n return resolveJiraSite({...params, authorization, site, claims});\n}\n\nexport async function handleJiraSiteSelection(\n params: Omit<HandleJiraCallbackParams, 'code'> & {cloudId: string},\n): Promise<IntegrationConnection<'jira'>> {\n const claims = await verifyClaims(params, params.state);\n const pending = await params.pendingStore.load({\n workspaceId: claims.workspaceId,\n state: params.state,\n });\n if (!pending) throw new JiraPendingSelectionNotFoundError();\n const site = pending.sites.find((candidate) => candidate.cloudId === params.cloudId);\n if (!site) throw new JiraSiteSelectionMismatchError(params.cloudId);\n assertJiraAuthorizationScopes(site.scopes);\n const connection = await resolveJiraSite({\n ...params,\n authorization: pending.authorization,\n site,\n claims,\n });\n await params.pendingStore.clear({workspaceId: claims.workspaceId, state: params.state});\n return connection;\n}\n\nexport async function handleJiraOAuthCallbackError(params: {\n state: string;\n error: string;\n errorDescription?: string | undefined;\n sessionUserId: string;\n sessionMemberships: ReadonlyArray<UserContextMembership>;\n requireWorkspaceMembership(input: {\n workspaceId: string;\n userId: string;\n memberships: ReadonlyArray<UserContextMembership>;\n }): Promise<unknown>;\n}): Promise<never> {\n await verifyClaims(params, params.state);\n throw new JiraOAuthCallbackError(params.error, params.errorDescription);\n}\n\nasync function verifyClaims(\n params: Pick<\n HandleJiraCallbackParams,\n 'sessionUserId' | 'sessionMemberships' | 'requireWorkspaceMembership'\n >,\n state: string,\n) {\n const claims = verifyJiraInstallState(state);\n if (claims.userId !== params.sessionUserId) throw new JiraInstallStateActorMismatchError();\n await params.requireWorkspaceMembership({\n workspaceId: claims.workspaceId,\n userId: claims.userId,\n memberships: params.sessionMemberships,\n });\n return claims;\n}\n\nfunction resolveJiraSite(\n params: Omit<HandleJiraCallbackParams, 'code'> & {\n authorization: JiraAuthorization;\n site: JiraAccessibleResource;\n claims: {workspaceId: string; userId: string};\n },\n): Promise<IntegrationConnection<'jira'>> {\n const withInstallationLock = params.withJiraInstallationLock ?? withJiraInstallationLock;\n return withInstallationLock(params.site.cloudId, async () => {\n const existing = await params.getExistingJiraConnection({cloudId: params.site.cloudId});\n if (existing && existing.workspaceId !== params.claims.workspaceId)\n throw new JiraInstallationAlreadyLinkedError(params.site.cloudId);\n const identity = await params.jira.getMyself({\n accessToken: params.authorization.accessToken,\n cloudId: params.site.cloudId,\n });\n const installationInput = {\n workspaceId: params.claims.workspaceId,\n cloudId: params.site.cloudId,\n siteUrl: params.site.url,\n siteName: params.site.name,\n authorizingAccountId: identity.accountId,\n scopes: params.site.scopes,\n tokenExpiresAt: params.authorization.expiresAt ?? null,\n displayName: `Jira ${params.site.name}`,\n };\n let connection = existing;\n try {\n if (!connection) connection = await params.connectJiraInstallation(installationInput);\n await params.tokenStore.storeTokens({\n connectionId: connection.id,\n accessToken: params.authorization.accessToken,\n refreshToken: params.authorization.refreshToken,\n editedBy: params.claims.userId,\n });\n if (existing) connection = await params.connectJiraInstallation(installationInput);\n } catch (error) {\n if (existing) {\n const markedError = await bestEffortMarkConnectionError(params, existing.id);\n if (!markedError) await bestEffortMarkConnectionError(params, existing.id);\n } else if (connection) {\n await bestEffortDisconnect(params, connection.id);\n }\n throw error;\n }\n\n let registrationFailureHandled = false;\n try {\n await params.registerJiraWebhook({\n connectionId: connection.id,\n cloudId: params.site.cloudId,\n accessToken: params.authorization.accessToken,\n withRegistrationLock: async (_lockKey, fn) => fn(),\n onRegistrationSuccess: async ({tx}) => {\n await params.markConnectionActive(connectionLifecycleInput(connection.id, tx));\n },\n onRegistrationFailure: async ({tx}) => {\n registrationFailureHandled = await bestEffortMarkConnectionError(\n params,\n connection.id,\n tx,\n );\n },\n });\n } catch (error) {\n if (!registrationFailureHandled) await bestEffortMarkConnectionError(params, connection.id);\n throw error;\n }\n\n return connection;\n });\n}\n\nasync function bestEffortDisconnect(\n params: Pick<HandleJiraCallbackParams, 'disconnectJiraInstallation'>,\n connectionId: string,\n): Promise<void> {\n try {\n await params.disconnectJiraInstallation({connectionId});\n } catch (error) {\n logger().warn(\n {err: error, connectionId},\n 'Jira connect compensation failed after token storage rejection',\n );\n }\n}\n\nasync function bestEffortMarkConnectionError(\n params: Pick<HandleJiraCallbackParams, 'markConnectionError'>,\n connectionId: string,\n tx?: unknown,\n): Promise<boolean> {\n try {\n await params.markConnectionError(connectionLifecycleInput(connectionId, tx));\n return true;\n } catch (error) {\n logger().warn(\n {err: error, connectionId},\n 'Jira connection error-state update failed after webhook registration rejection',\n );\n return false;\n }\n}\n\nfunction connectionLifecycleInput(connectionId: string, tx?: unknown) {\n return tx === undefined ? {connectionId} : {connectionId, tx};\n}\n"],"names":["logger","withJiraInstallationLock","JiraInstallationAlreadyLinkedError","JiraInstallStateActorMismatchError","JiraIntegrationProviderError","JiraOAuthCallbackError","JiraOfflineAccessNotGrantedError","JiraPendingSelectionNotFoundError","JiraSiteSelectionMismatchError","assertJiraAuthorizationScopes","verifyJiraInstallState","handleJiraCallback","params","claims","verifyClaims","state","authorization","jira","exchangeAuthorizationCode","code","refreshToken","sites","getAccessibleResources","accessToken","length","pendingStore","save","workspaceId","site","scopes","resolveJiraSite","handleJiraSiteSelection","pending","load","find","candidate","cloudId","connection","clear","handleJiraOAuthCallbackError","error","errorDescription","userId","sessionUserId","requireWorkspaceMembership","memberships","sessionMemberships","withInstallationLock","existing","getExistingJiraConnection","identity","getMyself","installationInput","siteUrl","url","siteName","name","authorizingAccountId","accountId","tokenExpiresAt","expiresAt","displayName","connectJiraInstallation","tokenStore","storeTokens","connectionId","id","editedBy","markedError","bestEffortMarkConnectionError","bestEffortDisconnect","registrationFailureHandled","registerJiraWebhook","withRegistrationLock","_lockKey","fn","onRegistrationSuccess","tx","markConnectionActive","connectionLifecycleInput","onRegistrationFailure","disconnectJiraInstallation","warn","err","markConnectionError","undefined"],"mappings":"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"}
@@ -0,0 +1,15 @@
1
+ import { type GetIntegrationConnectionByIdFn, type PublishIntegrationEventReceivedFn, type RecordDeliveryOnlyFn, type StoredWebhookRequest, type WebhookProcessingResult } from '@shipfox/api-integration-spi';
2
+ import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
3
+ import { getJiraInstallationByConnectionId } from '#db/installations.js';
4
+ export interface CreateJiraWebhookProcessorOptions {
5
+ coreDb: () => NodePgDatabase<Record<string, unknown>>;
6
+ publishIntegrationEventReceived: PublishIntegrationEventReceivedFn;
7
+ recordDeliveryOnly: RecordDeliveryOnlyFn;
8
+ getIntegrationConnectionById: GetIntegrationConnectionByIdFn;
9
+ getJiraInstallationByConnectionId?: typeof getJiraInstallationByConnectionId;
10
+ }
11
+ export interface JiraWebhookProcessor {
12
+ process(request: StoredWebhookRequest): Promise<WebhookProcessingResult>;
13
+ }
14
+ export declare function createJiraWebhookProcessor(options: CreateJiraWebhookProcessorOptions): JiraWebhookProcessor;
15
+ //# sourceMappingURL=webhook-processor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webhook-processor.d.ts","sourceRoot":"","sources":["../../src/core/webhook-processor.ts"],"names":[],"mappings":"AAMA,OAAO,EAEL,KAAK,8BAA8B,EACnC,KAAK,iCAAiC,EACtC,KAAK,oBAAoB,EACzB,KAAK,oBAAoB,EACzB,KAAK,uBAAuB,EAC7B,MAAM,8BAA8B,CAAC;AAItC,OAAO,KAAK,EAAC,cAAc,EAAC,MAAM,2BAA2B,CAAC;AAI9D,OAAO,EAAC,iCAAiC,EAAC,MAAM,sBAAsB,CAAC;AAOvE,MAAM,WAAW,iCAAiC;IAChD,MAAM,EAAE,MAAM,cAAc,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IACtD,+BAA+B,EAAE,iCAAiC,CAAC;IACnE,kBAAkB,EAAE,oBAAoB,CAAC;IACzC,4BAA4B,EAAE,8BAA8B,CAAC;IAC7D,iCAAiC,CAAC,EAAE,OAAO,iCAAiC,CAAC;CAC9E;AAED,MAAM,WAAW,oBAAoB;IACnC,OAAO,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,uBAAuB,CAAC,CAAC;CAC1E;AAED,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,iCAAiC,GACzC,oBAAoB,CAEtB"}