@shipfox/api-integration-jira 4.0.0 → 5.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 (99) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +21 -0
  3. package/dist/api/client.d.ts +33 -0
  4. package/dist/api/client.d.ts.map +1 -0
  5. package/dist/api/client.js +143 -0
  6. package/dist/api/client.js.map +1 -0
  7. package/dist/core/disconnect.d.ts +18 -0
  8. package/dist/core/disconnect.d.ts.map +1 -0
  9. package/dist/core/disconnect.js +21 -0
  10. package/dist/core/disconnect.js.map +1 -0
  11. package/dist/core/errors.d.ts +37 -0
  12. package/dist/core/errors.d.ts.map +1 -1
  13. package/dist/core/errors.js +63 -0
  14. package/dist/core/errors.js.map +1 -1
  15. package/dist/core/install.d.ts +55 -0
  16. package/dist/core/install.d.ts.map +1 -0
  17. package/dist/core/install.js +121 -0
  18. package/dist/core/install.js.map +1 -0
  19. package/dist/core/pending.d.ts +43 -0
  20. package/dist/core/pending.d.ts.map +1 -0
  21. package/dist/core/pending.js +107 -0
  22. package/dist/core/pending.js.map +1 -0
  23. package/dist/core/scopes.d.ts +4 -0
  24. package/dist/core/scopes.d.ts.map +1 -0
  25. package/dist/core/scopes.js +23 -0
  26. package/dist/core/scopes.js.map +1 -0
  27. package/dist/core/state.d.ts +12 -0
  28. package/dist/core/state.d.ts.map +1 -0
  29. package/dist/core/state.js +51 -0
  30. package/dist/core/state.js.map +1 -0
  31. package/dist/core/tokens.d.ts +6 -0
  32. package/dist/core/tokens.d.ts.map +1 -1
  33. package/dist/core/tokens.js +82 -6
  34. package/dist/core/tokens.js.map +1 -1
  35. package/dist/db/db.d.ts +188 -0
  36. package/dist/db/db.d.ts.map +1 -1
  37. package/dist/db/db.js +3 -1
  38. package/dist/db/db.js.map +1 -1
  39. package/dist/db/installations.d.ts +21 -0
  40. package/dist/db/installations.d.ts.map +1 -1
  41. package/dist/db/installations.js +82 -19
  42. package/dist/db/installations.js.map +1 -1
  43. package/dist/db/pending-selections.d.ts +17 -0
  44. package/dist/db/pending-selections.d.ts.map +1 -0
  45. package/dist/db/pending-selections.js +27 -0
  46. package/dist/db/pending-selections.js.map +1 -0
  47. package/dist/db/schema/installations.js +2 -2
  48. package/dist/db/schema/installations.js.map +1 -1
  49. package/dist/db/schema/pending-selections.d.ts +105 -0
  50. package/dist/db/schema/pending-selections.d.ts.map +1 -0
  51. package/dist/db/schema/pending-selections.js +24 -0
  52. package/dist/db/schema/pending-selections.js.map +1 -0
  53. package/dist/index.d.ts +26 -3
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +27 -4
  56. package/dist/index.js.map +1 -1
  57. package/dist/presentation/dto/integrations.d.ts +2 -0
  58. package/dist/presentation/dto/integrations.d.ts.map +1 -0
  59. package/dist/presentation/dto/integrations.js +3 -0
  60. package/dist/presentation/dto/integrations.js.map +1 -0
  61. package/dist/presentation/routes/errors.d.ts +2 -0
  62. package/dist/presentation/routes/errors.d.ts.map +1 -0
  63. package/dist/presentation/routes/errors.js +58 -0
  64. package/dist/presentation/routes/errors.js.map +1 -0
  65. package/dist/presentation/routes/install.d.ts +21 -0
  66. package/dist/presentation/routes/install.d.ts.map +1 -0
  67. package/dist/presentation/routes/install.js +130 -0
  68. package/dist/presentation/routes/install.js.map +1 -0
  69. package/dist/tsconfig.test.tsbuildinfo +1 -1
  70. package/drizzle/0000_whole_master_chief.sql +8 -1
  71. package/drizzle/meta/0000_snapshot.json +47 -3
  72. package/package.json +21 -24
  73. package/src/api/client.test.ts +29 -0
  74. package/src/api/client.ts +213 -0
  75. package/src/core/disconnect.test.ts +24 -0
  76. package/src/core/disconnect.ts +25 -0
  77. package/src/core/errors.ts +79 -0
  78. package/src/core/install.test.ts +129 -0
  79. package/src/core/install.ts +191 -0
  80. package/src/core/pending.ts +146 -0
  81. package/src/core/scopes.test.ts +19 -0
  82. package/src/core/scopes.ts +20 -0
  83. package/src/core/state.test.ts +38 -0
  84. package/src/core/state.ts +79 -0
  85. package/src/core/tokens-refresh.test.ts +106 -0
  86. package/src/core/tokens.ts +145 -8
  87. package/src/db/db.ts +2 -1
  88. package/src/db/installations.test.ts +37 -1
  89. package/src/db/installations.ts +120 -23
  90. package/src/db/pending-selections.ts +55 -0
  91. package/src/db/schema/installations.ts +2 -2
  92. package/src/db/schema/pending-selections.ts +31 -0
  93. package/src/index.test.ts +7 -2
  94. package/src/index.ts +66 -2
  95. package/src/presentation/dto/integrations.ts +1 -0
  96. package/src/presentation/routes/errors.ts +61 -0
  97. package/src/presentation/routes/install.ts +147 -0
  98. package/tsconfig.build.tsbuildinfo +1 -1
  99. package/turbo.json +9 -0
@@ -0,0 +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-core-dto';\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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"}
@@ -0,0 +1,43 @@
1
+ import type { JiraAccessibleResource, JiraAuthorization } from '#api/client.js';
2
+ export interface JiraPendingSelectionSecretsStore {
3
+ getSecret(params: {
4
+ workspaceId: string;
5
+ namespace: string;
6
+ key: string;
7
+ }): Promise<string | null>;
8
+ setSecrets(params: {
9
+ workspaceId: string;
10
+ namespace: string;
11
+ values: Record<string, string>;
12
+ editedBy?: string | null | undefined;
13
+ }): Promise<void>;
14
+ deleteSecrets(params: {
15
+ workspaceId: string;
16
+ namespace: string;
17
+ }): Promise<number>;
18
+ }
19
+ export interface JiraPendingSelectionStore {
20
+ save(params: {
21
+ workspaceId: string;
22
+ state: string;
23
+ authorization: JiraAuthorization;
24
+ sites: JiraAccessibleResource[];
25
+ }): Promise<void>;
26
+ load(params: {
27
+ workspaceId: string;
28
+ state: string;
29
+ }): Promise<{
30
+ authorization: JiraAuthorization;
31
+ sites: JiraAccessibleResource[];
32
+ } | undefined>;
33
+ clear(params: {
34
+ workspaceId: string;
35
+ state: string;
36
+ }): Promise<void>;
37
+ pruneExpiredPendingSelections(now: Date): Promise<void>;
38
+ }
39
+ export declare function jiraPendingSecretsNamespace(state: string): string;
40
+ export declare function createJiraPendingSelectionStore(params: {
41
+ secrets: JiraPendingSelectionSecretsStore;
42
+ }): JiraPendingSelectionStore;
43
+ //# sourceMappingURL=pending.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pending.d.ts","sourceRoot":"","sources":["../../src/core/pending.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAC,sBAAsB,EAAE,iBAAiB,EAAC,MAAM,gBAAgB,CAAC;AAa9E,MAAM,WAAW,gCAAgC;IAC/C,SAAS,CAAC,MAAM,EAAE;QAAC,WAAW,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACjG,UAAU,CAAC,MAAM,EAAE;QACjB,WAAW,EAAE,MAAM,CAAC;QACpB,SAAS,EAAE,MAAM,CAAC;QAClB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC/B,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;KACtC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAClB,aAAa,CAAC,MAAM,EAAE;QAAC,WAAW,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CAClF;AAED,MAAM,WAAW,yBAAyB;IACxC,IAAI,CAAC,MAAM,EAAE;QACX,WAAW,EAAE,MAAM,CAAC;QACpB,KAAK,EAAE,MAAM,CAAC;QACd,aAAa,EAAE,iBAAiB,CAAC;QACjC,KAAK,EAAE,sBAAsB,EAAE,CAAC;KACjC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAClB,IAAI,CAAC,MAAM,EAAE;QACX,WAAW,EAAE,MAAM,CAAC;QACpB,KAAK,EAAE,MAAM,CAAC;KACf,GAAG,OAAO,CAAC;QAAC,aAAa,EAAE,iBAAiB,CAAC;QAAC,KAAK,EAAE,sBAAsB,EAAE,CAAA;KAAC,GAAG,SAAS,CAAC,CAAC;IAC7F,KAAK,CAAC,MAAM,EAAE;QAAC,WAAW,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnE,6BAA6B,CAAC,GAAG,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACzD;AAED,wBAAgB,2BAA2B,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEjE;AAED,wBAAgB,+BAA+B,CAAC,MAAM,EAAE;IACtD,OAAO,EAAE,gCAAgC,CAAC;CAC3C,GAAG,yBAAyB,CA+F5B"}
@@ -0,0 +1,107 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { deleteJiraPendingSelection, getJiraPendingSelection, listExpiredJiraPendingSelections, saveJiraPendingSelection } from '#db/pending-selections.js';
3
+ const PENDING_SELECTION_TTL_MS = 30 * 60 * 1000;
4
+ const ACCESS_TOKEN_KEY = 'ACCESS_TOKEN';
5
+ const REFRESH_TOKEN_KEY = 'REFRESH_TOKEN';
6
+ const TOKEN_EXPIRES_AT_KEY = 'TOKEN_EXPIRES_AT';
7
+ export function jiraPendingSecretsNamespace(state) {
8
+ return `system/integrations/jira/pending/${stateHash(state)}`;
9
+ }
10
+ export function createJiraPendingSelectionStore(params) {
11
+ async function clear(input) {
12
+ const hash = stateHash(input.state);
13
+ await Promise.all([
14
+ deleteJiraPendingSelection({
15
+ stateHash: hash,
16
+ workspaceId: input.workspaceId
17
+ }),
18
+ params.secrets.deleteSecrets({
19
+ workspaceId: input.workspaceId,
20
+ namespace: jiraPendingSecretsNamespace(input.state)
21
+ })
22
+ ]);
23
+ }
24
+ return {
25
+ async save (input) {
26
+ const expiresAt = new Date(Date.now() + PENDING_SELECTION_TTL_MS);
27
+ const values = {
28
+ [ACCESS_TOKEN_KEY]: input.authorization.accessToken
29
+ };
30
+ if (input.authorization.refreshToken) values[REFRESH_TOKEN_KEY] = input.authorization.refreshToken;
31
+ if (input.authorization.expiresAt) values[TOKEN_EXPIRES_AT_KEY] = input.authorization.expiresAt.toISOString();
32
+ await params.secrets.setSecrets({
33
+ workspaceId: input.workspaceId,
34
+ namespace: jiraPendingSecretsNamespace(input.state),
35
+ values
36
+ });
37
+ await saveJiraPendingSelection({
38
+ stateHash: stateHash(input.state),
39
+ workspaceId: input.workspaceId,
40
+ expiresAt,
41
+ sites: input.sites
42
+ });
43
+ },
44
+ async load (input) {
45
+ const pending = await getJiraPendingSelection({
46
+ stateHash: stateHash(input.state),
47
+ workspaceId: input.workspaceId
48
+ });
49
+ if (!pending) return undefined;
50
+ if (pending.expiresAt < new Date()) {
51
+ await clear(input);
52
+ return undefined;
53
+ }
54
+ const namespace = jiraPendingSecretsNamespace(input.state);
55
+ const [accessToken, refreshToken, tokenExpiresAt] = await Promise.all([
56
+ params.secrets.getSecret({
57
+ workspaceId: input.workspaceId,
58
+ namespace,
59
+ key: ACCESS_TOKEN_KEY
60
+ }),
61
+ params.secrets.getSecret({
62
+ workspaceId: input.workspaceId,
63
+ namespace,
64
+ key: REFRESH_TOKEN_KEY
65
+ }),
66
+ params.secrets.getSecret({
67
+ workspaceId: input.workspaceId,
68
+ namespace,
69
+ key: TOKEN_EXPIRES_AT_KEY
70
+ })
71
+ ]);
72
+ if (!accessToken || !refreshToken) {
73
+ await clear(input);
74
+ return undefined;
75
+ }
76
+ const expiresAt = tokenExpiresAt ? new Date(tokenExpiresAt) : undefined;
77
+ return {
78
+ authorization: {
79
+ accessToken,
80
+ refreshToken,
81
+ expiresAt: expiresAt && !Number.isNaN(expiresAt.getTime()) ? expiresAt : undefined,
82
+ scopes: []
83
+ },
84
+ sites: pending.sites
85
+ };
86
+ },
87
+ clear,
88
+ async pruneExpiredPendingSelections (now) {
89
+ const expired = await listExpiredJiraPendingSelections(now);
90
+ await Promise.all(expired.map(async (pending)=>{
91
+ await params.secrets.deleteSecrets({
92
+ workspaceId: pending.workspaceId,
93
+ namespace: `system/integrations/jira/pending/${pending.stateHash}`
94
+ });
95
+ await deleteJiraPendingSelection({
96
+ stateHash: pending.stateHash,
97
+ workspaceId: pending.workspaceId
98
+ });
99
+ }));
100
+ }
101
+ };
102
+ }
103
+ function stateHash(state) {
104
+ return createHash('sha256').update(state).digest('hex');
105
+ }
106
+
107
+ //# sourceMappingURL=pending.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/core/pending.ts"],"sourcesContent":["import {createHash} from 'node:crypto';\nimport type {JiraAccessibleResource, JiraAuthorization} from '#api/client.js';\nimport {\n deleteJiraPendingSelection,\n getJiraPendingSelection,\n listExpiredJiraPendingSelections,\n saveJiraPendingSelection,\n} from '#db/pending-selections.js';\n\nconst PENDING_SELECTION_TTL_MS = 30 * 60 * 1000;\nconst ACCESS_TOKEN_KEY = 'ACCESS_TOKEN';\nconst REFRESH_TOKEN_KEY = 'REFRESH_TOKEN';\nconst TOKEN_EXPIRES_AT_KEY = 'TOKEN_EXPIRES_AT';\n\nexport interface JiraPendingSelectionSecretsStore {\n getSecret(params: {workspaceId: string; namespace: string; key: string}): Promise<string | null>;\n setSecrets(params: {\n workspaceId: string;\n namespace: string;\n values: Record<string, string>;\n editedBy?: string | null | undefined;\n }): Promise<void>;\n deleteSecrets(params: {workspaceId: string; namespace: string}): Promise<number>;\n}\n\nexport interface JiraPendingSelectionStore {\n save(params: {\n workspaceId: string;\n state: string;\n authorization: JiraAuthorization;\n sites: JiraAccessibleResource[];\n }): Promise<void>;\n load(params: {\n workspaceId: string;\n state: string;\n }): Promise<{authorization: JiraAuthorization; sites: JiraAccessibleResource[]} | undefined>;\n clear(params: {workspaceId: string; state: string}): Promise<void>;\n pruneExpiredPendingSelections(now: Date): Promise<void>;\n}\n\nexport function jiraPendingSecretsNamespace(state: string): string {\n return `system/integrations/jira/pending/${stateHash(state)}`;\n}\n\nexport function createJiraPendingSelectionStore(params: {\n secrets: JiraPendingSelectionSecretsStore;\n}): JiraPendingSelectionStore {\n async function clear(input: {workspaceId: string; state: string}): Promise<void> {\n const hash = stateHash(input.state);\n await Promise.all([\n deleteJiraPendingSelection({stateHash: hash, workspaceId: input.workspaceId}),\n params.secrets.deleteSecrets({\n workspaceId: input.workspaceId,\n namespace: jiraPendingSecretsNamespace(input.state),\n }),\n ]);\n }\n\n return {\n async save(input) {\n const expiresAt = new Date(Date.now() + PENDING_SELECTION_TTL_MS);\n const values: Record<string, string> = {[ACCESS_TOKEN_KEY]: input.authorization.accessToken};\n if (input.authorization.refreshToken)\n values[REFRESH_TOKEN_KEY] = input.authorization.refreshToken;\n if (input.authorization.expiresAt)\n values[TOKEN_EXPIRES_AT_KEY] = input.authorization.expiresAt.toISOString();\n await params.secrets.setSecrets({\n workspaceId: input.workspaceId,\n namespace: jiraPendingSecretsNamespace(input.state),\n values,\n });\n await saveJiraPendingSelection({\n stateHash: stateHash(input.state),\n workspaceId: input.workspaceId,\n expiresAt,\n sites: input.sites,\n });\n },\n\n async load(input) {\n const pending = await getJiraPendingSelection({\n stateHash: stateHash(input.state),\n workspaceId: input.workspaceId,\n });\n if (!pending) return undefined;\n if (pending.expiresAt < new Date()) {\n await clear(input);\n return undefined;\n }\n const namespace = jiraPendingSecretsNamespace(input.state);\n const [accessToken, refreshToken, tokenExpiresAt] = await Promise.all([\n params.secrets.getSecret({\n workspaceId: input.workspaceId,\n namespace,\n key: ACCESS_TOKEN_KEY,\n }),\n params.secrets.getSecret({\n workspaceId: input.workspaceId,\n namespace,\n key: REFRESH_TOKEN_KEY,\n }),\n params.secrets.getSecret({\n workspaceId: input.workspaceId,\n namespace,\n key: TOKEN_EXPIRES_AT_KEY,\n }),\n ]);\n if (!accessToken || !refreshToken) {\n await clear(input);\n return undefined;\n }\n const expiresAt = tokenExpiresAt ? new Date(tokenExpiresAt) : undefined;\n return {\n authorization: {\n accessToken,\n refreshToken,\n expiresAt: expiresAt && !Number.isNaN(expiresAt.getTime()) ? expiresAt : undefined,\n scopes: [],\n },\n sites: pending.sites,\n };\n },\n\n clear,\n\n async pruneExpiredPendingSelections(now) {\n const expired = await listExpiredJiraPendingSelections(now);\n await Promise.all(\n expired.map(async (pending) => {\n await params.secrets.deleteSecrets({\n workspaceId: pending.workspaceId,\n namespace: `system/integrations/jira/pending/${pending.stateHash}`,\n });\n await deleteJiraPendingSelection({\n stateHash: pending.stateHash,\n workspaceId: pending.workspaceId,\n });\n }),\n );\n },\n };\n}\n\nfunction stateHash(state: string): string {\n return createHash('sha256').update(state).digest('hex');\n}\n"],"names":["createHash","deleteJiraPendingSelection","getJiraPendingSelection","listExpiredJiraPendingSelections","saveJiraPendingSelection","PENDING_SELECTION_TTL_MS","ACCESS_TOKEN_KEY","REFRESH_TOKEN_KEY","TOKEN_EXPIRES_AT_KEY","jiraPendingSecretsNamespace","state","stateHash","createJiraPendingSelectionStore","params","clear","input","hash","Promise","all","workspaceId","secrets","deleteSecrets","namespace","save","expiresAt","Date","now","values","authorization","accessToken","refreshToken","toISOString","setSecrets","sites","load","pending","undefined","tokenExpiresAt","getSecret","key","Number","isNaN","getTime","scopes","pruneExpiredPendingSelections","expired","map","update","digest"],"mappings":"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"}
@@ -0,0 +1,4 @@
1
+ export declare const JIRA_OAUTH_SCOPES: readonly ["read:jira-work", "write:jira-work", "read:jira-user", "manage:jira-webhook", "offline_access"];
2
+ export declare function formatJiraOAuthScopes(): string;
3
+ export declare function assertJiraAuthorizationScopes(scopes: readonly string[]): void;
4
+ //# sourceMappingURL=scopes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scopes.d.ts","sourceRoot":"","sources":["../../src/core/scopes.ts"],"names":[],"mappings":"AAGA,eAAO,MAAM,iBAAiB,2GAMpB,CAAC;AAEX,wBAAgB,qBAAqB,IAAI,MAAM,CAE9C;AAED,wBAAgB,6BAA6B,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,CAI7E"}
@@ -0,0 +1,23 @@
1
+ import { logger } from '@shipfox/node-opentelemetry';
2
+ import { JiraAuthorizationScopeMismatchError } from './errors.js';
3
+ export const JIRA_OAUTH_SCOPES = [
4
+ 'read:jira-work',
5
+ 'write:jira-work',
6
+ 'read:jira-user',
7
+ 'manage:jira-webhook',
8
+ 'offline_access'
9
+ ];
10
+ export function formatJiraOAuthScopes() {
11
+ return JIRA_OAUTH_SCOPES.join(' ');
12
+ }
13
+ export function assertJiraAuthorizationScopes(scopes) {
14
+ const usableScopes = scopes.filter((scope)=>scope.trim().length > 0);
15
+ if (usableScopes.length === 0) throw new JiraAuthorizationScopeMismatchError([
16
+ 'site scopes'
17
+ ]);
18
+ logger().info({
19
+ scopes: usableScopes
20
+ }, 'Jira site authorization scopes granted');
21
+ }
22
+
23
+ //# sourceMappingURL=scopes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/core/scopes.ts"],"sourcesContent":["import {logger} from '@shipfox/node-opentelemetry';\nimport {JiraAuthorizationScopeMismatchError} from './errors.js';\n\nexport const JIRA_OAUTH_SCOPES = [\n 'read:jira-work',\n 'write:jira-work',\n 'read:jira-user',\n 'manage:jira-webhook',\n 'offline_access',\n] as const;\n\nexport function formatJiraOAuthScopes(): string {\n return JIRA_OAUTH_SCOPES.join(' ');\n}\n\nexport function assertJiraAuthorizationScopes(scopes: readonly string[]): void {\n const usableScopes = scopes.filter((scope) => scope.trim().length > 0);\n if (usableScopes.length === 0) throw new JiraAuthorizationScopeMismatchError(['site scopes']);\n logger().info({scopes: usableScopes}, 'Jira site authorization scopes granted');\n}\n"],"names":["logger","JiraAuthorizationScopeMismatchError","JIRA_OAUTH_SCOPES","formatJiraOAuthScopes","join","assertJiraAuthorizationScopes","scopes","usableScopes","filter","scope","trim","length","info"],"mappings":"AAAA,SAAQA,MAAM,QAAO,8BAA8B;AACnD,SAAQC,mCAAmC,QAAO,cAAc;AAEhE,OAAO,MAAMC,oBAAoB;IAC/B;IACA;IACA;IACA;IACA;CACD,CAAU;AAEX,OAAO,SAASC;IACd,OAAOD,kBAAkBE,IAAI,CAAC;AAChC;AAEA,OAAO,SAASC,8BAA8BC,MAAyB;IACrE,MAAMC,eAAeD,OAAOE,MAAM,CAAC,CAACC,QAAUA,MAAMC,IAAI,GAAGC,MAAM,GAAG;IACpE,IAAIJ,aAAaI,MAAM,KAAK,GAAG,MAAM,IAAIV,oCAAoC;QAAC;KAAc;IAC5FD,SAASY,IAAI,CAAC;QAACN,QAAQC;IAAY,GAAG;AACxC"}
@@ -0,0 +1,12 @@
1
+ export interface JiraInstallStateClaims {
2
+ workspaceId: string;
3
+ userId: string;
4
+ }
5
+ export declare function signJiraInstallState(params: {
6
+ workspaceId: string;
7
+ userId: string;
8
+ nonce?: string | undefined;
9
+ now?: Date | undefined;
10
+ }): string;
11
+ export declare function verifyJiraInstallState(state: string, now?: Date): JiraInstallStateClaims;
12
+ //# sourceMappingURL=state.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../../src/core/state.ts"],"names":[],"mappings":"AAaA,MAAM,WAAW,sBAAsB;IACrC,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,wBAAgB,oBAAoB,CAAC,MAAM,EAAE;IAC3C,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,GAAG,CAAC,EAAE,IAAI,GAAG,SAAS,CAAC;CACxB,GAAG,MAAM,CAUT;AAED,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,MAAM,EACb,GAAG,GAAE,IAAiB,GACrB,sBAAsB,CAWxB"}
@@ -0,0 +1,51 @@
1
+ import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
2
+ import { config } from '#config.js';
3
+ import { JiraInstallStateError } from './errors.js';
4
+ const STATE_TTL_SECONDS = 30 * 60;
5
+ export function signJiraInstallState(params) {
6
+ const now = params.now ?? new Date();
7
+ const payload = {
8
+ workspaceId: params.workspaceId,
9
+ userId: params.userId,
10
+ nonce: params.nonce ?? randomUUID(),
11
+ expiresAt: Math.floor(now.getTime() / 1000) + STATE_TTL_SECONDS
12
+ };
13
+ const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url');
14
+ return `${encodedPayload}.${sign(encodedPayload)}`;
15
+ }
16
+ export function verifyJiraInstallState(state, now = new Date()) {
17
+ const [encodedPayload, signature, extra] = state.split('.');
18
+ if (!encodedPayload || !signature || extra !== undefined) throw new JiraInstallStateError();
19
+ if (!constantTimeEqual(signature, sign(encodedPayload))) {
20
+ throw new JiraInstallStateError('Invalid Jira install state signature');
21
+ }
22
+ const payload = parsePayload(encodedPayload);
23
+ if (payload.expiresAt < Math.floor(now.getTime() / 1000)) {
24
+ throw new JiraInstallStateError('Expired Jira install state');
25
+ }
26
+ return {
27
+ workspaceId: payload.workspaceId,
28
+ userId: payload.userId
29
+ };
30
+ }
31
+ function sign(encodedPayload) {
32
+ return createHmac('sha256', config.JIRA_OAUTH_CLIENT_SECRET).update(encodedPayload).digest('base64url');
33
+ }
34
+ function constantTimeEqual(a, b) {
35
+ const left = Buffer.from(a);
36
+ const right = Buffer.from(b);
37
+ return left.length === right.length && timingSafeEqual(left, right);
38
+ }
39
+ function parsePayload(encodedPayload) {
40
+ try {
41
+ const parsed = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8'));
42
+ if (typeof parsed.workspaceId !== 'string' || typeof parsed.userId !== 'string' || typeof parsed.nonce !== 'string' || typeof parsed.expiresAt !== 'number') {
43
+ throw new Error('Invalid payload shape');
44
+ }
45
+ return parsed;
46
+ } catch {
47
+ throw new JiraInstallStateError('Invalid Jira install state payload');
48
+ }
49
+ }
50
+
51
+ //# sourceMappingURL=state.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/core/state.ts"],"sourcesContent":["import {createHmac, randomUUID, timingSafeEqual} from 'node:crypto';\nimport {config} from '#config.js';\nimport {JiraInstallStateError} from './errors.js';\n\nconst STATE_TTL_SECONDS = 30 * 60;\n\ninterface JiraInstallStatePayload {\n workspaceId: string;\n userId: string;\n nonce: string;\n expiresAt: number;\n}\n\nexport interface JiraInstallStateClaims {\n workspaceId: string;\n userId: string;\n}\n\nexport function signJiraInstallState(params: {\n workspaceId: string;\n userId: string;\n nonce?: string | undefined;\n now?: Date | undefined;\n}): string {\n const now = params.now ?? new Date();\n const payload: JiraInstallStatePayload = {\n workspaceId: params.workspaceId,\n userId: params.userId,\n nonce: params.nonce ?? randomUUID(),\n expiresAt: Math.floor(now.getTime() / 1000) + STATE_TTL_SECONDS,\n };\n const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url');\n return `${encodedPayload}.${sign(encodedPayload)}`;\n}\n\nexport function verifyJiraInstallState(\n state: string,\n now: Date = new Date(),\n): JiraInstallStateClaims {\n const [encodedPayload, signature, extra] = state.split('.');\n if (!encodedPayload || !signature || extra !== undefined) throw new JiraInstallStateError();\n if (!constantTimeEqual(signature, sign(encodedPayload))) {\n throw new JiraInstallStateError('Invalid Jira install state signature');\n }\n const payload = parsePayload(encodedPayload);\n if (payload.expiresAt < Math.floor(now.getTime() / 1000)) {\n throw new JiraInstallStateError('Expired Jira install state');\n }\n return {workspaceId: payload.workspaceId, userId: payload.userId};\n}\n\nfunction sign(encodedPayload: string): string {\n return createHmac('sha256', config.JIRA_OAUTH_CLIENT_SECRET)\n .update(encodedPayload)\n .digest('base64url');\n}\n\nfunction constantTimeEqual(a: string, b: string): boolean {\n const left = Buffer.from(a);\n const right = Buffer.from(b);\n return left.length === right.length && timingSafeEqual(left, right);\n}\n\nfunction parsePayload(encodedPayload: string): JiraInstallStatePayload {\n try {\n const parsed = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8'));\n if (\n typeof parsed.workspaceId !== 'string' ||\n typeof parsed.userId !== 'string' ||\n typeof parsed.nonce !== 'string' ||\n typeof parsed.expiresAt !== 'number'\n ) {\n throw new Error('Invalid payload shape');\n }\n return parsed;\n } catch {\n throw new JiraInstallStateError('Invalid Jira install state payload');\n }\n}\n"],"names":["createHmac","randomUUID","timingSafeEqual","config","JiraInstallStateError","STATE_TTL_SECONDS","signJiraInstallState","params","now","Date","payload","workspaceId","userId","nonce","expiresAt","Math","floor","getTime","encodedPayload","Buffer","from","JSON","stringify","toString","sign","verifyJiraInstallState","state","signature","extra","split","undefined","constantTimeEqual","parsePayload","JIRA_OAUTH_CLIENT_SECRET","update","digest","a","b","left","right","length","parsed","parse","Error"],"mappings":"AAAA,SAAQA,UAAU,EAAEC,UAAU,EAAEC,eAAe,QAAO,cAAc;AACpE,SAAQC,MAAM,QAAO,aAAa;AAClC,SAAQC,qBAAqB,QAAO,cAAc;AAElD,MAAMC,oBAAoB,KAAK;AAc/B,OAAO,SAASC,qBAAqBC,MAKpC;IACC,MAAMC,MAAMD,OAAOC,GAAG,IAAI,IAAIC;IAC9B,MAAMC,UAAmC;QACvCC,aAAaJ,OAAOI,WAAW;QAC/BC,QAAQL,OAAOK,MAAM;QACrBC,OAAON,OAAOM,KAAK,IAAIZ;QACvBa,WAAWC,KAAKC,KAAK,CAACR,IAAIS,OAAO,KAAK,QAAQZ;IAChD;IACA,MAAMa,iBAAiBC,OAAOC,IAAI,CAACC,KAAKC,SAAS,CAACZ,UAAUa,QAAQ,CAAC;IACrE,OAAO,GAAGL,eAAe,CAAC,EAAEM,KAAKN,iBAAiB;AACpD;AAEA,OAAO,SAASO,uBACdC,KAAa,EACblB,MAAY,IAAIC,MAAM;IAEtB,MAAM,CAACS,gBAAgBS,WAAWC,MAAM,GAAGF,MAAMG,KAAK,CAAC;IACvD,IAAI,CAACX,kBAAkB,CAACS,aAAaC,UAAUE,WAAW,MAAM,IAAI1B;IACpE,IAAI,CAAC2B,kBAAkBJ,WAAWH,KAAKN,kBAAkB;QACvD,MAAM,IAAId,sBAAsB;IAClC;IACA,MAAMM,UAAUsB,aAAad;IAC7B,IAAIR,QAAQI,SAAS,GAAGC,KAAKC,KAAK,CAACR,IAAIS,OAAO,KAAK,OAAO;QACxD,MAAM,IAAIb,sBAAsB;IAClC;IACA,OAAO;QAACO,aAAaD,QAAQC,WAAW;QAAEC,QAAQF,QAAQE,MAAM;IAAA;AAClE;AAEA,SAASY,KAAKN,cAAsB;IAClC,OAAOlB,WAAW,UAAUG,OAAO8B,wBAAwB,EACxDC,MAAM,CAAChB,gBACPiB,MAAM,CAAC;AACZ;AAEA,SAASJ,kBAAkBK,CAAS,EAAEC,CAAS;IAC7C,MAAMC,OAAOnB,OAAOC,IAAI,CAACgB;IACzB,MAAMG,QAAQpB,OAAOC,IAAI,CAACiB;IAC1B,OAAOC,KAAKE,MAAM,KAAKD,MAAMC,MAAM,IAAItC,gBAAgBoC,MAAMC;AAC/D;AAEA,SAASP,aAAad,cAAsB;IAC1C,IAAI;QACF,MAAMuB,SAASpB,KAAKqB,KAAK,CAACvB,OAAOC,IAAI,CAACF,gBAAgB,aAAaK,QAAQ,CAAC;QAC5E,IACE,OAAOkB,OAAO9B,WAAW,KAAK,YAC9B,OAAO8B,OAAO7B,MAAM,KAAK,YACzB,OAAO6B,OAAO5B,KAAK,KAAK,YACxB,OAAO4B,OAAO3B,SAAS,KAAK,UAC5B;YACA,MAAM,IAAI6B,MAAM;QAClB;QACA,OAAOF;IACT,EAAE,OAAM;QACN,MAAM,IAAIrC,sBAAsB;IAClC;AACF"}
@@ -1,3 +1,4 @@
1
+ import { type JiraApiClient } from '#api/client.js';
1
2
  export interface JiraConnectionResolverResult {
2
3
  workspaceId: string;
3
4
  }
@@ -17,6 +18,10 @@ export interface JiraSecretsStore {
17
18
  export interface CreateJiraTokenStoreParams {
18
19
  resolveConnection(connectionId: string): Promise<JiraConnectionResolverResult | undefined>;
19
20
  secrets: JiraSecretsStore;
21
+ client?: JiraApiClient | undefined;
22
+ markConnectionError?: (params: {
23
+ connectionId: string;
24
+ }) => Promise<void>;
20
25
  }
21
26
  export interface StoreJiraTokensParams {
22
27
  connectionId: string;
@@ -26,6 +31,7 @@ export interface StoreJiraTokensParams {
26
31
  }
27
32
  export interface GetJiraAccessTokenParams {
28
33
  connectionId: string;
34
+ forceRefresh?: boolean | undefined;
29
35
  }
30
36
  export interface JiraTokenStore {
31
37
  storeTokens(params: StoreJiraTokensParams): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../../src/core/tokens.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,4BAA4B;IAC3C,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAC/B,SAAS,CAAC,MAAM,EAAE;QAAC,WAAW,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACjG,UAAU,CAAC,MAAM,EAAE;QACjB,WAAW,EAAE,MAAM,CAAC;QACpB,SAAS,EAAE,MAAM,CAAC;QAClB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC/B,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;KACtC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACnB;AAED,MAAM,WAAW,0BAA0B;IACzC,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,4BAA4B,GAAG,SAAS,CAAC,CAAC;IAC3F,OAAO,EAAE,gBAAgB,CAAC;CAC3B;AAED,MAAM,WAAW,qBAAqB;IACpC,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;CACtC;AAED,MAAM,WAAW,wBAAwB;IACvC,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,cAAc;IAC7B,WAAW,CAAC,MAAM,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1D,cAAc,CAAC,MAAM,EAAE,wBAAwB,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CACnE;AAED,wBAAgB,oBAAoB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAEjE;AAED,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,0BAA0B,GAAG,cAAc,CAgCvF"}
1
+ {"version":3,"file":"tokens.d.ts","sourceRoot":"","sources":["../../src/core/tokens.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,KAAK,aAAa,EAAC,MAAM,gBAAgB,CAAC;AAkBvE,MAAM,WAAW,4BAA4B;IAC3C,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,gBAAgB;IAC/B,SAAS,CAAC,MAAM,EAAE;QAAC,WAAW,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAC,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACjG,UAAU,CAAC,MAAM,EAAE;QACjB,WAAW,EAAE,MAAM,CAAC;QACpB,SAAS,EAAE,MAAM,CAAC;QAClB,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC/B,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;KACtC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACnB;AAED,MAAM,WAAW,0BAA0B;IACzC,iBAAiB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,4BAA4B,GAAG,SAAS,CAAC,CAAC;IAC3F,OAAO,EAAE,gBAAgB,CAAC;IAC1B,MAAM,CAAC,EAAE,aAAa,GAAG,SAAS,CAAC;IACnC,mBAAmB,CAAC,EAAE,CAAC,MAAM,EAAE;QAAC,YAAY,EAAE,MAAM,CAAA;KAAC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACzE;AAED,MAAM,WAAW,qBAAqB;IACpC,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;CACtC;AAED,MAAM,WAAW,wBAAwB;IACvC,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;CACpC;AAED,MAAM,WAAW,cAAc;IAC7B,WAAW,CAAC,MAAM,EAAE,qBAAqB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC1D,cAAc,CAAC,MAAM,EAAE,wBAAwB,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;CACnE;AAED,wBAAgB,oBAAoB,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAEjE;AAED,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,0BAA0B,GAAG,cAAc,CA6DvF"}
@@ -1,15 +1,30 @@
1
- import { JiraAccessTokenMissingError, JiraConnectionNotFoundError } from '#core/errors.js';
1
+ import { createJiraApiClient } from '#api/client.js';
2
+ import { JiraAccessTokenMissingError, JiraConnectionNotFoundError, JiraIntegrationProviderError, JiraTokenUnrefreshableError } from '#core/errors.js';
3
+ import { getJiraInstallationByConnectionId, updateJiraInstallationTokenExpiry, withJiraRefreshLock } from '#db/installations.js';
2
4
  const ACCESS_TOKEN_KEY = 'ACCESS_TOKEN';
3
5
  const REFRESH_TOKEN_KEY = 'REFRESH_TOKEN';
6
+ const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000;
7
+ const tokenRefreshes = new Map();
4
8
  export function jiraSecretsNamespace(connectionId) {
5
9
  return `system/integrations/jira/${connectionId}`;
6
10
  }
7
11
  export function createJiraTokenStore(params) {
12
+ const client = params.client ?? createJiraApiClient();
8
13
  async function resolveWorkspaceId(connectionId) {
9
14
  const connection = await params.resolveConnection(connectionId);
10
15
  if (!connection) throw new JiraConnectionNotFoundError(connectionId);
11
16
  return connection.workspaceId;
12
17
  }
18
+ const readSecretToken = (workspaceId, connectionId, key)=>params.secrets.getSecret({
19
+ workspaceId,
20
+ namespace: jiraSecretsNamespace(connectionId),
21
+ key
22
+ });
23
+ async function readAccessToken(workspaceId, connectionId) {
24
+ const token = await readSecretToken(workspaceId, connectionId, ACCESS_TOKEN_KEY);
25
+ if (!token) throw new JiraAccessTokenMissingError(connectionId);
26
+ return token;
27
+ }
13
28
  return {
14
29
  async storeTokens (input) {
15
30
  const workspaceId = await resolveWorkspaceId(input.connectionId);
@@ -26,15 +41,76 @@ export function createJiraTokenStore(params) {
26
41
  },
27
42
  async getAccessToken (input) {
28
43
  const workspaceId = await resolveWorkspaceId(input.connectionId);
29
- const token = await params.secrets.getSecret({
44
+ const accessToken = await readAccessToken(workspaceId, input.connectionId);
45
+ if (!input.forceRefresh && !shouldRefresh((await getJiraInstallationByConnectionId(input.connectionId))?.tokenExpiresAt ?? null)) return accessToken;
46
+ const inFlight = tokenRefreshes.get(input.connectionId);
47
+ if (inFlight) return inFlight;
48
+ const refresh = refreshAccessTokenWithLock({
49
+ connectionId: input.connectionId,
30
50
  workspaceId,
31
- namespace: jiraSecretsNamespace(input.connectionId),
32
- key: ACCESS_TOKEN_KEY
51
+ originalAccessToken: accessToken,
52
+ forceRefresh: input.forceRefresh === true,
53
+ client,
54
+ secrets: params.secrets,
55
+ readAccessToken,
56
+ readSecretToken,
57
+ markConnectionError: params.markConnectionError
33
58
  });
34
- if (!token) throw new JiraAccessTokenMissingError(input.connectionId);
35
- return token;
59
+ tokenRefreshes.set(input.connectionId, refresh);
60
+ void refresh.then(()=>clearTokenRefresh(input.connectionId, refresh), ()=>clearTokenRefresh(input.connectionId, refresh));
61
+ return refresh;
36
62
  }
37
63
  };
38
64
  }
65
+ function shouldRefresh(expiresAt) {
66
+ return expiresAt !== null && expiresAt.getTime() <= Date.now() + TOKEN_REFRESH_MARGIN_MS;
67
+ }
68
+ function clearTokenRefresh(connectionId, refresh) {
69
+ if (tokenRefreshes.get(connectionId) === refresh) tokenRefreshes.delete(connectionId);
70
+ }
71
+ async function refreshAccessTokenWithLock(params) {
72
+ const lock = await withJiraRefreshLock(params.connectionId, ()=>refreshAccessTokenForConnection(params));
73
+ if (lock.acquired) return lock.value;
74
+ const reread = await params.readAccessToken(params.workspaceId, params.connectionId);
75
+ if (reread !== params.originalAccessToken) return reread;
76
+ throw new JiraIntegrationProviderError('provider-unavailable', 'Jira token refresh is already in progress');
77
+ }
78
+ async function refreshAccessTokenForConnection(params) {
79
+ const current = await params.readAccessToken(params.workspaceId, params.connectionId);
80
+ const installation = await getJiraInstallationByConnectionId(params.connectionId);
81
+ if (current !== params.originalAccessToken || !params.forceRefresh && !shouldRefresh(installation?.tokenExpiresAt ?? null)) return current;
82
+ const refreshToken = await params.readSecretToken(params.workspaceId, params.connectionId, REFRESH_TOKEN_KEY);
83
+ if (!refreshToken) {
84
+ if (params.forceRefresh || shouldRefresh(installation?.tokenExpiresAt ?? null)) throw new JiraTokenUnrefreshableError(params.connectionId);
85
+ return current;
86
+ }
87
+ try {
88
+ const refreshed = await params.client.refreshAccessToken({
89
+ refreshToken
90
+ });
91
+ if (!refreshed.refreshToken) throw new JiraTokenUnrefreshableError(params.connectionId);
92
+ await params.secrets.setSecrets({
93
+ workspaceId: params.workspaceId,
94
+ namespace: jiraSecretsNamespace(params.connectionId),
95
+ values: {
96
+ [ACCESS_TOKEN_KEY]: refreshed.accessToken,
97
+ [REFRESH_TOKEN_KEY]: refreshed.refreshToken
98
+ }
99
+ });
100
+ await updateJiraInstallationTokenExpiry({
101
+ connectionId: params.connectionId,
102
+ tokenExpiresAt: refreshed.expiresAt ?? null,
103
+ scopes: refreshed.scopes.length > 0 ? refreshed.scopes : undefined
104
+ });
105
+ return refreshed.accessToken;
106
+ } catch (error) {
107
+ if (error instanceof JiraIntegrationProviderError && error.reason === 'access-denied') {
108
+ await params.markConnectionError?.({
109
+ connectionId: params.connectionId
110
+ });
111
+ }
112
+ throw error;
113
+ }
114
+ }
39
115
 
40
116
  //# sourceMappingURL=tokens.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/core/tokens.ts"],"sourcesContent":["import {JiraAccessTokenMissingError, JiraConnectionNotFoundError} from '#core/errors.js';\n\nconst ACCESS_TOKEN_KEY = 'ACCESS_TOKEN';\nconst REFRESH_TOKEN_KEY = 'REFRESH_TOKEN';\n\nexport interface JiraConnectionResolverResult {\n workspaceId: string;\n}\n\nexport interface JiraSecretsStore {\n getSecret(params: {workspaceId: string; namespace: string; key: string}): Promise<string | null>;\n setSecrets(params: {\n workspaceId: string;\n namespace: string;\n values: Record<string, string>;\n editedBy?: string | null | undefined;\n }): Promise<void>;\n}\n\nexport interface CreateJiraTokenStoreParams {\n resolveConnection(connectionId: string): Promise<JiraConnectionResolverResult | undefined>;\n secrets: JiraSecretsStore;\n}\n\nexport interface StoreJiraTokensParams {\n connectionId: string;\n accessToken: string;\n refreshToken?: string | undefined;\n editedBy?: string | null | undefined;\n}\n\nexport interface GetJiraAccessTokenParams {\n connectionId: string;\n}\n\nexport interface JiraTokenStore {\n storeTokens(params: StoreJiraTokensParams): Promise<void>;\n getAccessToken(params: GetJiraAccessTokenParams): Promise<string>;\n}\n\nexport function jiraSecretsNamespace(connectionId: string): string {\n return `system/integrations/jira/${connectionId}`;\n}\n\nexport function createJiraTokenStore(params: CreateJiraTokenStoreParams): JiraTokenStore {\n async function resolveWorkspaceId(connectionId: string): Promise<string> {\n const connection = await params.resolveConnection(connectionId);\n if (!connection) throw new JiraConnectionNotFoundError(connectionId);\n return connection.workspaceId;\n }\n\n return {\n async storeTokens(input) {\n const workspaceId = await resolveWorkspaceId(input.connectionId);\n const values: Record<string, string> = {[ACCESS_TOKEN_KEY]: input.accessToken};\n if (input.refreshToken) values[REFRESH_TOKEN_KEY] = input.refreshToken;\n\n await params.secrets.setSecrets({\n workspaceId,\n namespace: jiraSecretsNamespace(input.connectionId),\n values,\n editedBy: input.editedBy,\n });\n },\n\n async getAccessToken(input) {\n const workspaceId = await resolveWorkspaceId(input.connectionId);\n const token = await params.secrets.getSecret({\n workspaceId,\n namespace: jiraSecretsNamespace(input.connectionId),\n key: ACCESS_TOKEN_KEY,\n });\n if (!token) throw new JiraAccessTokenMissingError(input.connectionId);\n return token;\n },\n };\n}\n"],"names":["JiraAccessTokenMissingError","JiraConnectionNotFoundError","ACCESS_TOKEN_KEY","REFRESH_TOKEN_KEY","jiraSecretsNamespace","connectionId","createJiraTokenStore","params","resolveWorkspaceId","connection","resolveConnection","workspaceId","storeTokens","input","values","accessToken","refreshToken","secrets","setSecrets","namespace","editedBy","getAccessToken","token","getSecret","key"],"mappings":"AAAA,SAAQA,2BAA2B,EAAEC,2BAA2B,QAAO,kBAAkB;AAEzF,MAAMC,mBAAmB;AACzB,MAAMC,oBAAoB;AAqC1B,OAAO,SAASC,qBAAqBC,YAAoB;IACvD,OAAO,CAAC,yBAAyB,EAAEA,cAAc;AACnD;AAEA,OAAO,SAASC,qBAAqBC,MAAkC;IACrE,eAAeC,mBAAmBH,YAAoB;QACpD,MAAMI,aAAa,MAAMF,OAAOG,iBAAiB,CAACL;QAClD,IAAI,CAACI,YAAY,MAAM,IAAIR,4BAA4BI;QACvD,OAAOI,WAAWE,WAAW;IAC/B;IAEA,OAAO;QACL,MAAMC,aAAYC,KAAK;YACrB,MAAMF,cAAc,MAAMH,mBAAmBK,MAAMR,YAAY;YAC/D,MAAMS,SAAiC;gBAAC,CAACZ,iBAAiB,EAAEW,MAAME,WAAW;YAAA;YAC7E,IAAIF,MAAMG,YAAY,EAAEF,MAAM,CAACX,kBAAkB,GAAGU,MAAMG,YAAY;YAEtE,MAAMT,OAAOU,OAAO,CAACC,UAAU,CAAC;gBAC9BP;gBACAQ,WAAWf,qBAAqBS,MAAMR,YAAY;gBAClDS;gBACAM,UAAUP,MAAMO,QAAQ;YAC1B;QACF;QAEA,MAAMC,gBAAeR,KAAK;YACxB,MAAMF,cAAc,MAAMH,mBAAmBK,MAAMR,YAAY;YAC/D,MAAMiB,QAAQ,MAAMf,OAAOU,OAAO,CAACM,SAAS,CAAC;gBAC3CZ;gBACAQ,WAAWf,qBAAqBS,MAAMR,YAAY;gBAClDmB,KAAKtB;YACP;YACA,IAAI,CAACoB,OAAO,MAAM,IAAItB,4BAA4Ba,MAAMR,YAAY;YACpE,OAAOiB;QACT;IACF;AACF"}
1
+ {"version":3,"sources":["../../src/core/tokens.ts"],"sourcesContent":["import {createJiraApiClient, type JiraApiClient} from '#api/client.js';\nimport {\n JiraAccessTokenMissingError,\n JiraConnectionNotFoundError,\n JiraIntegrationProviderError,\n JiraTokenUnrefreshableError,\n} from '#core/errors.js';\nimport {\n getJiraInstallationByConnectionId,\n updateJiraInstallationTokenExpiry,\n withJiraRefreshLock,\n} from '#db/installations.js';\n\nconst ACCESS_TOKEN_KEY = 'ACCESS_TOKEN';\nconst REFRESH_TOKEN_KEY = 'REFRESH_TOKEN';\nconst TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000;\nconst tokenRefreshes = new Map<string, Promise<string>>();\n\nexport interface JiraConnectionResolverResult {\n workspaceId: string;\n}\n\nexport interface JiraSecretsStore {\n getSecret(params: {workspaceId: string; namespace: string; key: string}): Promise<string | null>;\n setSecrets(params: {\n workspaceId: string;\n namespace: string;\n values: Record<string, string>;\n editedBy?: string | null | undefined;\n }): Promise<void>;\n}\n\nexport interface CreateJiraTokenStoreParams {\n resolveConnection(connectionId: string): Promise<JiraConnectionResolverResult | undefined>;\n secrets: JiraSecretsStore;\n client?: JiraApiClient | undefined;\n markConnectionError?: (params: {connectionId: string}) => Promise<void>;\n}\n\nexport interface StoreJiraTokensParams {\n connectionId: string;\n accessToken: string;\n refreshToken?: string | undefined;\n editedBy?: string | null | undefined;\n}\n\nexport interface GetJiraAccessTokenParams {\n connectionId: string;\n forceRefresh?: boolean | undefined;\n}\n\nexport interface JiraTokenStore {\n storeTokens(params: StoreJiraTokensParams): Promise<void>;\n getAccessToken(params: GetJiraAccessTokenParams): Promise<string>;\n}\n\nexport function jiraSecretsNamespace(connectionId: string): string {\n return `system/integrations/jira/${connectionId}`;\n}\n\nexport function createJiraTokenStore(params: CreateJiraTokenStoreParams): JiraTokenStore {\n const client = params.client ?? createJiraApiClient();\n async function resolveWorkspaceId(connectionId: string): Promise<string> {\n const connection = await params.resolveConnection(connectionId);\n if (!connection) throw new JiraConnectionNotFoundError(connectionId);\n return connection.workspaceId;\n }\n const readSecretToken = (\n workspaceId: string,\n connectionId: string,\n key: typeof ACCESS_TOKEN_KEY | typeof REFRESH_TOKEN_KEY,\n ) => params.secrets.getSecret({workspaceId, namespace: jiraSecretsNamespace(connectionId), key});\n async function readAccessToken(workspaceId: string, connectionId: string): Promise<string> {\n const token = await readSecretToken(workspaceId, connectionId, ACCESS_TOKEN_KEY);\n if (!token) throw new JiraAccessTokenMissingError(connectionId);\n return token;\n }\n\n return {\n async storeTokens(input) {\n const workspaceId = await resolveWorkspaceId(input.connectionId);\n const values: Record<string, string> = {[ACCESS_TOKEN_KEY]: input.accessToken};\n if (input.refreshToken) values[REFRESH_TOKEN_KEY] = input.refreshToken;\n await params.secrets.setSecrets({\n workspaceId,\n namespace: jiraSecretsNamespace(input.connectionId),\n values,\n editedBy: input.editedBy,\n });\n },\n async getAccessToken(input) {\n const workspaceId = await resolveWorkspaceId(input.connectionId);\n const accessToken = await readAccessToken(workspaceId, input.connectionId);\n if (\n !input.forceRefresh &&\n !shouldRefresh(\n (await getJiraInstallationByConnectionId(input.connectionId))?.tokenExpiresAt ?? null,\n )\n )\n return accessToken;\n const inFlight = tokenRefreshes.get(input.connectionId);\n if (inFlight) return inFlight;\n const refresh = refreshAccessTokenWithLock({\n connectionId: input.connectionId,\n workspaceId,\n originalAccessToken: accessToken,\n forceRefresh: input.forceRefresh === true,\n client,\n secrets: params.secrets,\n readAccessToken,\n readSecretToken,\n markConnectionError: params.markConnectionError,\n });\n tokenRefreshes.set(input.connectionId, refresh);\n void refresh.then(\n () => clearTokenRefresh(input.connectionId, refresh),\n () => clearTokenRefresh(input.connectionId, refresh),\n );\n return refresh;\n },\n };\n}\n\nfunction shouldRefresh(expiresAt: Date | null): boolean {\n return expiresAt !== null && expiresAt.getTime() <= Date.now() + TOKEN_REFRESH_MARGIN_MS;\n}\n\nfunction clearTokenRefresh(connectionId: string, refresh: Promise<string>): void {\n if (tokenRefreshes.get(connectionId) === refresh) tokenRefreshes.delete(connectionId);\n}\n\nasync function refreshAccessTokenWithLock(params: {\n connectionId: string;\n workspaceId: string;\n originalAccessToken: string;\n forceRefresh: boolean;\n client: JiraApiClient;\n secrets: JiraSecretsStore;\n readAccessToken(workspaceId: string, connectionId: string): Promise<string>;\n readSecretToken(\n workspaceId: string,\n connectionId: string,\n key: typeof ACCESS_TOKEN_KEY | typeof REFRESH_TOKEN_KEY,\n ): Promise<string | null>;\n markConnectionError?: ((params: {connectionId: string}) => Promise<void>) | undefined;\n}): Promise<string> {\n const lock = await withJiraRefreshLock(params.connectionId, () =>\n refreshAccessTokenForConnection(params),\n );\n if (lock.acquired) return lock.value;\n const reread = await params.readAccessToken(params.workspaceId, params.connectionId);\n if (reread !== params.originalAccessToken) return reread;\n throw new JiraIntegrationProviderError(\n 'provider-unavailable',\n 'Jira token refresh is already in progress',\n );\n}\n\nasync function refreshAccessTokenForConnection(params: {\n connectionId: string;\n workspaceId: string;\n originalAccessToken: string;\n forceRefresh: boolean;\n client: JiraApiClient;\n secrets: JiraSecretsStore;\n readAccessToken(workspaceId: string, connectionId: string): Promise<string>;\n readSecretToken(\n workspaceId: string,\n connectionId: string,\n key: typeof ACCESS_TOKEN_KEY | typeof REFRESH_TOKEN_KEY,\n ): Promise<string | null>;\n markConnectionError?: ((params: {connectionId: string}) => Promise<void>) | undefined;\n}): Promise<string> {\n const current = await params.readAccessToken(params.workspaceId, params.connectionId);\n const installation = await getJiraInstallationByConnectionId(params.connectionId);\n if (\n current !== params.originalAccessToken ||\n (!params.forceRefresh && !shouldRefresh(installation?.tokenExpiresAt ?? null))\n )\n return current;\n const refreshToken = await params.readSecretToken(\n params.workspaceId,\n params.connectionId,\n REFRESH_TOKEN_KEY,\n );\n if (!refreshToken) {\n if (params.forceRefresh || shouldRefresh(installation?.tokenExpiresAt ?? null))\n throw new JiraTokenUnrefreshableError(params.connectionId);\n return current;\n }\n try {\n const refreshed = await params.client.refreshAccessToken({refreshToken});\n if (!refreshed.refreshToken) throw new JiraTokenUnrefreshableError(params.connectionId);\n await params.secrets.setSecrets({\n workspaceId: params.workspaceId,\n namespace: jiraSecretsNamespace(params.connectionId),\n values: {\n [ACCESS_TOKEN_KEY]: refreshed.accessToken,\n [REFRESH_TOKEN_KEY]: refreshed.refreshToken,\n },\n });\n await updateJiraInstallationTokenExpiry({\n connectionId: params.connectionId,\n tokenExpiresAt: refreshed.expiresAt ?? null,\n scopes: refreshed.scopes.length > 0 ? refreshed.scopes : undefined,\n });\n return refreshed.accessToken;\n } catch (error) {\n if (error instanceof JiraIntegrationProviderError && error.reason === 'access-denied') {\n await params.markConnectionError?.({connectionId: params.connectionId});\n }\n throw error;\n }\n}\n"],"names":["createJiraApiClient","JiraAccessTokenMissingError","JiraConnectionNotFoundError","JiraIntegrationProviderError","JiraTokenUnrefreshableError","getJiraInstallationByConnectionId","updateJiraInstallationTokenExpiry","withJiraRefreshLock","ACCESS_TOKEN_KEY","REFRESH_TOKEN_KEY","TOKEN_REFRESH_MARGIN_MS","tokenRefreshes","Map","jiraSecretsNamespace","connectionId","createJiraTokenStore","params","client","resolveWorkspaceId","connection","resolveConnection","workspaceId","readSecretToken","key","secrets","getSecret","namespace","readAccessToken","token","storeTokens","input","values","accessToken","refreshToken","setSecrets","editedBy","getAccessToken","forceRefresh","shouldRefresh","tokenExpiresAt","inFlight","get","refresh","refreshAccessTokenWithLock","originalAccessToken","markConnectionError","set","then","clearTokenRefresh","expiresAt","getTime","Date","now","delete","lock","refreshAccessTokenForConnection","acquired","value","reread","current","installation","refreshed","refreshAccessToken","scopes","length","undefined","error","reason"],"mappings":"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"}