@gitkraken/core-gitlens 0.10.0 → 0.10.2

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 (35) hide show
  1. package/CHANGELOG.md +19 -1
  2. package/dist/plus/integrations/authentication/cloudIntegrationService.js +24 -7
  3. package/dist/plus/integrations/authentication/cloudIntegrationService.js.map +1 -1
  4. package/dist/plus/integrations/authentication/configuredIntegrationService.d.ts +40 -27
  5. package/dist/plus/integrations/authentication/configuredIntegrationService.js +92 -22
  6. package/dist/plus/integrations/authentication/configuredIntegrationService.js.map +1 -1
  7. package/dist/plus/integrations/authentication/integrationAuthenticationProvider.d.ts +7 -0
  8. package/dist/plus/integrations/authentication/integrationAuthenticationProvider.js +22 -8
  9. package/dist/plus/integrations/authentication/integrationAuthenticationProvider.js.map +1 -1
  10. package/dist/plus/integrations/integrationService.js +6 -3
  11. package/dist/plus/integrations/integrationService.js.map +1 -1
  12. package/dist/plus/integrations/models/gitHostIntegration.js +38 -36
  13. package/dist/plus/integrations/models/gitHostIntegration.js.map +1 -1
  14. package/dist/plus/integrations/models/integration.d.ts +71 -7
  15. package/dist/plus/integrations/models/integration.js +201 -55
  16. package/dist/plus/integrations/models/integration.js.map +1 -1
  17. package/dist/plus/integrations/models/issuesIntegration.js +9 -9
  18. package/dist/plus/integrations/models/issuesIntegration.js.map +1 -1
  19. package/dist/plus/integrations/providers/azureDevOps.js +1 -1
  20. package/dist/plus/integrations/providers/azureDevOps.js.map +1 -1
  21. package/dist/plus/integrations/providers/azureDevOpsServer.js +1 -1
  22. package/dist/plus/integrations/providers/azureDevOpsServer.js.map +1 -1
  23. package/dist/plus/integrations/providers/bitbucket-server.js +1 -1
  24. package/dist/plus/integrations/providers/bitbucket-server.js.map +1 -1
  25. package/dist/plus/integrations/providers/bitbucket.js +1 -1
  26. package/dist/plus/integrations/providers/bitbucket.js.map +1 -1
  27. package/dist/plus/integrations/providers/github.js +3 -6
  28. package/dist/plus/integrations/providers/github.js.map +1 -1
  29. package/dist/plus/integrations/providers/gitlab.js +1 -1
  30. package/dist/plus/integrations/providers/gitlab.js.map +1 -1
  31. package/dist/plus/integrations/providers/jira.js +1 -0
  32. package/dist/plus/integrations/providers/jira.js.map +1 -1
  33. package/dist/plus/integrations/providers/linear.js +1 -0
  34. package/dist/plus/integrations/providers/linear.js.map +1 -1
  35. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -6,6 +6,22 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.10.2] - 2026-10-05
10
+
11
+ ### Fixed
12
+
13
+ - Fixes reads that resolve the integration's own session rather than a pinned connection (a self-managed host read by `domain` alone, for one) reporting `no-connection` when the session had expired and the GitKraken API could not hand back its replacement (a `429` or `5xx` from `v1/provider-tokens`): the read now fails with the refresh's error, a `rate-limit` warning for a `429` and an `other` warning otherwise (a refresh cancelled by the host's timeout included). A forced re-sync no longer clears the cached session while it fetches the replacement, so a transient failure leaves it as it was and the integration stays connected; the next read refreshes it once the cloud answers again. The session-based lookups (`getAccountForEmail`, `getDefaultBranch`, `getPullRequestForBranch` and the like) answer nothing rather than reading with the expired token, and a pull request lookup asked to `throwOnError` throws the failure. Jira and Linear autolinks are not rebuilt with an expired session. `syncCloudConnection` now resolves its token fetch's failure instead of `undefined` (plus/integrations)
14
+ - Fixes the integration's session racing with itself: a resolution, a forced re-sync, a disconnect, a reauthentication, a connection switch and a refused token now change the session one at a time instead of each publishing what it read over what another settled, so a disconnect during a re-sync stands (it waits for the re-sync), a switch is not undone by the former primary, and a refused token stays refused. Reads keep using the cached session meanwhile. Storing and deleting a provider's tokens are serialized too, and a token read or fetched before a sign-out (a disconnect, a reauthentication, a removed connection) is no longer stored after it, so it does not reconnect on the next resolution or restart (#5948). `switchConnection` now returns a promise (plus/integrations)
15
+ - Fixes `getSession()` handing a direct consumer (code suggestions, cloud patches) an expired session's token while the GitKraken API could not refresh it: an expired session is now refreshed before it is returned, and nothing is returned when the refresh fails. `isConnected()` still reports the integration as connected without a request ([#5943](https://github.com/gitkraken/vscode-gitlens/issues/5943)) (plus/integrations)
16
+
17
+ ## [0.10.1] - 2026-10-05
18
+
19
+ ### Fixed
20
+
21
+ - Fixes cloud integration operations throwing a JSON parse error when the GitKraken API returns an empty or non-JSON error body, preserving failure notifications, token refresh fallback, and retryable status classification ([#5932](https://github.com/gitkraken/vscode-gitlens/issues/5932)) (plus/integrations)
22
+ - Fixes per-connection reads reporting a connection whose token the GitKraken API could not hand back (a `429` or `5xx` from `v1/provider-tokens`) as a `no-connection` warning, the signal a consumer treats as an expired credential: the read now fails with the token fetch's error, a `rate-limit` warning for a `429` and an `other` warning otherwise. A repository or current-account lookup rejects with that error instead of resolving `undefined`. A terminal answer (`400`, `404`, `410`, `422`) still reads as `no-connection` (plus/integrations)
23
+ - Fixes a forced re-sync (`forceSync` on a read, or a forced `syncCloudConnection`) deleting the stored token before fetching its replacement, which left a connection with no token at all when that fetch failed transiently, so every read until the next good sync reported it gone. The stored token is now replaced only once a new one arrives, and dropped only when the cloud answers definitively that there is none (plus/integrations)
24
+
9
25
  ## [0.10.0] - 2026-10-04
10
26
 
11
27
  ### Added
@@ -559,7 +575,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this p
559
575
 
560
576
  - Initial release. Bundles `@gitlens/utils`, `@gitlens/git`, `@gitlens/git-cli`, `@gitlens/ai`, and `@gitlens/git-github` into a single core npm package with subpath exports.
561
577
 
562
- [unreleased]: https://github.com/gitkraken/vscode-gitlens/compare/releases/core/v0.10.0...HEAD
578
+ [unreleased]: https://github.com/gitkraken/vscode-gitlens/compare/releases/core/v0.10.2...HEAD
579
+ [0.10.2]: https://github.com/gitkraken/vscode-gitlens/compare/releases/core/v0.10.1...gitkraken:releases/core/v0.10.2
580
+ [0.10.1]: https://github.com/gitkraken/vscode-gitlens/compare/releases/core/v0.10.0...gitkraken:releases/core/v0.10.1
563
581
  [0.10.0]: https://github.com/gitkraken/vscode-gitlens/compare/releases/core/v0.9.9...gitkraken:releases/core/v0.10.0
564
582
  [0.9.9]: https://github.com/gitkraken/vscode-gitlens/compare/releases/core/v0.9.8...gitkraken:releases/core/v0.9.9
565
583
  [0.9.8]: https://github.com/gitkraken/vscode-gitlens/compare/releases/core/v0.9.7...gitkraken:releases/core/v0.9.8
@@ -1,4 +1,5 @@
1
1
  import { getScopedLogger } from "../../../utils/logger.scoped.js";
2
+ import { RequestRateLimitError } from "../../../git/errors.js";
2
3
  import { toCloudIntegrationType } from "./models.js";
3
4
  //#region ../plus/integrations/src/authentication/cloudIntegrationService.ts
4
5
  /**
@@ -16,6 +17,15 @@ function isTerminalStatus(status) {
16
17
  default: return false;
17
18
  }
18
19
  }
20
+ /**
21
+ * The error for a token fetch the backend refused for a reason a retry can fix. A 429 is typed as a rate limit so
22
+ * a read that needed the token reports a throttle rather than a broken connection (gitkraken/kepler#3546); every
23
+ * other status stays a plain error.
24
+ */
25
+ function retryableTokenFetchError(status, message) {
26
+ const error = new Error(message);
27
+ return status === 429 ? new RequestRateLimitError(error, void 0, void 0) : error;
28
+ }
19
29
  function toSession(data) {
20
30
  return {
21
31
  type: data.type,
@@ -27,6 +37,13 @@ function toSession(data) {
27
37
  appKey: data.appKey
28
38
  };
29
39
  }
40
+ async function getResponseError(response) {
41
+ try {
42
+ return (await response.json())?.error;
43
+ } catch {
44
+ return;
45
+ }
46
+ }
30
47
  var CloudIntegrationService = class {
31
48
  ctx;
32
49
  constructor(ctx) {
@@ -36,7 +53,7 @@ var CloudIntegrationService = class {
36
53
  const scope = getScopedLogger();
37
54
  const providersRsp = await this.ctx.account.fetchGkApi("v1/provider-tokens", { method: "GET" });
38
55
  if (!providersRsp.ok) {
39
- const error = (await providersRsp.json())?.error;
56
+ const error = await getResponseError(providersRsp);
40
57
  const errorMessage = typeof error === "string" ? error : error?.message ?? providersRsp.statusText;
41
58
  if (error != null) scope?.error(void 0, `Failed to get connected providers from cloud: ${errorMessage}`);
42
59
  this.ctx.hooks?.connection?.onConnectionsFetchFailed?.({ code: providersRsp.status });
@@ -88,7 +105,7 @@ var CloudIntegrationService = class {
88
105
  const path = connectionId ? `v1/provider-tokens/tokens/${encodeURIComponent(connectionId)}${refresh ? "/refresh" : ""}` : `v1/provider-tokens/${cloudIntegrationType}${refresh ? "/refresh" : ""}`;
89
106
  const tokenRsp = await this.ctx.account.fetchGkApi(path, reqInitOptions);
90
107
  if (!tokenRsp.ok) {
91
- const error = (await tokenRsp.json())?.error;
108
+ const error = await getResponseError(tokenRsp);
92
109
  const errorMessage = typeof error === "string" ? error : error?.message ?? tokenRsp.statusText;
93
110
  if (error != null) scope?.error(void 0, `Failed to ${refresh ? "refresh" : "get"} ${id} token from cloud: ${errorMessage}`);
94
111
  this.ctx.hooks?.connection?.onConnectionFetchFailed?.({
@@ -109,10 +126,10 @@ var CloudIntegrationService = class {
109
126
  refreshing: false
110
127
  });
111
128
  if (isTerminalStatus(newTokenRsp.status)) return void 0;
112
- throw new Error(`Retryable failure (${newTokenRsp.status}) refreshing ${id} token from cloud`);
129
+ throw retryableTokenFetchError(newTokenRsp.status, `Retryable failure (${newTokenRsp.status}) refreshing ${id} token from cloud`);
113
130
  }
114
131
  if (isTerminalStatus(tokenRsp.status)) return void 0;
115
- throw new Error(`Retryable failure (${tokenRsp.status}) getting ${id} token from cloud`);
132
+ throw retryableTokenFetchError(tokenRsp.status, `Retryable failure (${tokenRsp.status}) getting ${id} token from cloud`);
116
133
  }
117
134
  const data = (await tokenRsp.json())?.data;
118
135
  return data != null ? toSession(data) : void 0;
@@ -126,7 +143,7 @@ var CloudIntegrationService = class {
126
143
  }
127
144
  const tokenRsp = await this.ctx.account.fetchGkApi(`v1/provider-tokens/${cloudIntegrationType}`, { method: "DELETE" });
128
145
  if (!tokenRsp.ok) {
129
- const error = (await tokenRsp.json())?.error;
146
+ const error = await getResponseError(tokenRsp);
130
147
  const errorMessage = typeof error === "string" ? error : error?.message ?? tokenRsp.statusText;
131
148
  if (error != null) scope?.error(void 0, `Failed to disconnect ${id} token from cloud: ${errorMessage}`);
132
149
  this.ctx.hooks?.connection?.onDisconnectFailed?.({
@@ -146,7 +163,7 @@ var CloudIntegrationService = class {
146
163
  const scope = getScopedLogger();
147
164
  const tokenRsp = await this.ctx.account.fetchGkApi(`v1/provider-tokens/tokens/${encodeURIComponent(connectionId)}`, { method: "DELETE" });
148
165
  if (!tokenRsp.ok) {
149
- const error = (await tokenRsp.json())?.error;
166
+ const error = await getResponseError(tokenRsp);
150
167
  const errorMessage = typeof error === "string" ? error : error?.message ?? tokenRsp.statusText;
151
168
  if (error != null) scope?.error(void 0, `Failed to disconnect connection '${connectionId}' for ${id}: ${errorMessage}`);
152
169
  this.ctx.hooks?.connection?.onDisconnectFailed?.({
@@ -165,7 +182,7 @@ var CloudIntegrationService = class {
165
182
  const scope = getScopedLogger();
166
183
  const tokenRsp = await this.ctx.account.fetchGkApi(`v1/provider-tokens/tokens/${encodeURIComponent(connectionId)}/primary`, { method: "POST" });
167
184
  if (!tokenRsp.ok) {
168
- const error = (await tokenRsp.json())?.error;
185
+ const error = await getResponseError(tokenRsp);
169
186
  const errorMessage = typeof error === "string" ? error : error?.message ?? tokenRsp.statusText;
170
187
  if (error != null) scope?.error(void 0, `Failed to set primary connection '${connectionId}' for ${id}: ${errorMessage}`);
171
188
  return false;
@@ -1 +1 @@
1
- {"version":3,"file":"cloudIntegrationService.js","names":[],"sources":["../../../../../plus/integrations/src/authentication/cloudIntegrationService.ts"],"sourcesContent":["import { getScopedLogger } from '@gitlens/utils/logger.scoped.js';\nimport type { IntegrationIds } from '../constants.js';\nimport type { IntegrationServiceContext } from '../context.js';\nimport type {\n\tCloudIntegrationAuthenticationSession,\n\tCloudIntegrationConnection,\n\tCloudIntegrationType,\n} from './models.js';\nimport { toCloudIntegrationType } from './models.js';\n\n/**\n * Wire shape of a `v1/provider-tokens` connection descriptor. The list endpoint returns one such\n * entry per provider as the primary, with additional accounts nested under `secondaries`. Field casing\n * is the backend's (camelCase, `tokenId`).\n */\ninterface GKProviderTokenData {\n\ttokenId: string;\n\tprovider: CloudIntegrationType;\n\ttype: CloudIntegrationAuthenticationSession['type'];\n\tdomain: string;\n\t/** Human-readable account handle; the backend includes it on the connection when available. */\n\taccountName?: string;\n}\n\n/** Wire shape of a single `v1/provider-tokens/...` token response (`data`). */\ninterface GKProviderToken {\n\ttokenId?: string;\n\tisPrimary?: boolean;\n\taccessToken: string;\n\tappKey?: string;\n\texpiresIn: number;\n\tscopes: string;\n\ttype: CloudIntegrationAuthenticationSession['type'];\n\tdomain?: string;\n}\n\n/**\n * Whether the backend definitively answered that this connection has no fetchable token, so it can be\n * cleanly disconnected (#5497). Everything else is retryable — notably 401/403, which reject our GK\n * *account* token rather than the connection. The asymmetry is deliberate: a wrongly-kept descriptor\n * self-corrects on the next sync, a wrongly-dropped one needs a manual reconnect (#5569).\n */\nfunction isTerminalStatus(status: number): boolean {\n\tswitch (status) {\n\t\tcase 400: // e.g. a non-uuid token id (#5497)\n\t\tcase 404:\n\t\tcase 410:\n\t\tcase 422:\n\t\t\treturn true;\n\t\tdefault:\n\t\t\treturn false;\n\t}\n}\n\nfunction toSession(data: GKProviderToken): CloudIntegrationAuthenticationSession {\n\t// Normalize the backend's `tokenId` onto our `id` so callers get a stable per-connection identity.\n\treturn {\n\t\ttype: data.type,\n\t\taccessToken: data.accessToken,\n\t\tdomain: data.domain ?? '',\n\t\texpiresIn: data.expiresIn,\n\t\tscopes: data.scopes,\n\t\tid: data.tokenId,\n\t\tappKey: data.appKey,\n\t};\n}\n\nexport class CloudIntegrationService {\n\tconstructor(private readonly ctx: IntegrationServiceContext) {}\n\n\tasync getConnections(): Promise<CloudIntegrationConnection[] | undefined> {\n\t\tconst scope = getScopedLogger();\n\n\t\tconst providersRsp = await this.ctx.account.fetchGkApi('v1/provider-tokens', { method: 'GET' });\n\t\tif (!providersRsp.ok) {\n\t\t\tconst error = ((await providersRsp.json()) as { error?: unknown })?.error;\n\t\t\tconst errorMessage =\n\t\t\t\ttypeof error === 'string'\n\t\t\t\t\t? error\n\t\t\t\t\t: ((error as { message?: string })?.message ?? providersRsp.statusText);\n\t\t\tif (error != null) {\n\t\t\t\tscope?.error(undefined, `Failed to get connected providers from cloud: ${errorMessage}`);\n\t\t\t}\n\t\t\tthis.ctx.hooks?.connection?.onConnectionsFetchFailed?.({ code: providersRsp.status });\n\t\t\treturn undefined;\n\t\t}\n\n\t\tconst data = ((await providersRsp.json()) as { data?: unknown })?.data as\n\t\t\t| (GKProviderTokenData & { secondaries?: GKProviderTokenData[] })[]\n\t\t\t| undefined;\n\t\t// A non-array `data` (unexpected backend shape) would throw on iteration below and abort the\n\t\t// whole cloud-sync path, so bail gracefully instead.\n\t\tif (!Array.isArray(data)) return undefined;\n\n\t\t// Flatten the primary + `secondaries[]` grouping into one connection per account. Primacy is\n\t\t// positional on the wire (top-level entry = primary), so we set the `primary` flag here.\n\t\tconst connections: CloudIntegrationConnection[] = [];\n\t\tfor (const item of data) {\n\t\t\tconnections.push({\n\t\t\t\tid: item.tokenId,\n\t\t\t\ttype: item.type,\n\t\t\t\tprovider: item.provider,\n\t\t\t\tdomain: item.domain,\n\t\t\t\tprimary: true,\n\t\t\t\taccountName: item.accountName,\n\t\t\t});\n\t\t\t// `secondaries` comes from the same unknown payload as `data`; guard against a non-array shape so\n\t\t\t// an unexpected value doesn't throw here and abort the whole cloud-sync path.\n\t\t\tfor (const secondary of Array.isArray(item.secondaries) ? item.secondaries : []) {\n\t\t\t\tconnections.push({\n\t\t\t\t\tid: secondary.tokenId,\n\t\t\t\t\ttype: secondary.type,\n\t\t\t\t\tprovider: secondary.provider ?? item.provider,\n\t\t\t\t\tdomain: secondary.domain || item.domain,\n\t\t\t\t\tprimary: false,\n\t\t\t\t\taccountName: secondary.accountName,\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\n\t\treturn connections;\n\t}\n\n\t/**\n\t * Fetches a connection's token, refreshing it first when `refreshToken` is given; `connectionId` targets\n\t * a specific connection (multi-account) rather than the provider's primary.\n\t *\n\t * Resolves `undefined` ONLY on a terminal failure (see {@link isTerminalStatus}, or an ok response with\n\t * no `data`), which callers may treat as \"the connection is gone\" (#5497). THROWS on every other\n\t * failure, which they must catch and treat as retryable instead of disconnecting (#5569).\n\t */\n\tasync getConnectionSession(\n\t\tid: IntegrationIds,\n\t\trefreshToken?: string,\n\t\tconnectionId?: string,\n\t): Promise<CloudIntegrationAuthenticationSession | undefined> {\n\t\tconst scope = getScopedLogger();\n\n\t\tconst refresh = Boolean(refreshToken);\n\t\tconst cloudIntegrationType = toCloudIntegrationType[id];\n\t\tif (cloudIntegrationType == null) {\n\t\t\tscope?.error(undefined, `Unsupported cloud integration type: ${id}`);\n\t\t\treturn undefined;\n\t\t}\n\n\t\tconst reqInitOptions = refreshToken\n\t\t\t? {\n\t\t\t\t\tmethod: 'POST',\n\t\t\t\t\tbody: JSON.stringify({\n\t\t\t\t\t\taccess_token: refreshToken,\n\t\t\t\t\t}),\n\t\t\t\t}\n\t\t\t: { method: 'GET' };\n\n\t\t// A specific connection (multi-account) is addressed by its token id under `/tokens/{tokenId}`,\n\t\t// including refresh (`/tokens/{tokenId}/refresh`). The provider-scoped endpoints only ever operate\n\t\t// on the provider's PRIMARY connection, so they must NOT be used to refresh a secondary.\n\t\tconst path = connectionId\n\t\t\t? `v1/provider-tokens/tokens/${encodeURIComponent(connectionId)}${refresh ? '/refresh' : ''}`\n\t\t\t: `v1/provider-tokens/${cloudIntegrationType}${refresh ? '/refresh' : ''}`;\n\t\tconst tokenRsp = await this.ctx.account.fetchGkApi(path, reqInitOptions);\n\t\tif (!tokenRsp.ok) {\n\t\t\tconst error = ((await tokenRsp.json()) as { error?: unknown })?.error;\n\t\t\tconst errorMessage =\n\t\t\t\ttypeof error === 'string' ? error : ((error as { message?: string })?.message ?? tokenRsp.statusText);\n\t\t\tif (error != null) {\n\t\t\t\tscope?.error(\n\t\t\t\t\tundefined,\n\t\t\t\t\t`Failed to ${refresh ? 'refresh' : 'get'} ${id} token from cloud: ${errorMessage}`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tthis.ctx.hooks?.connection?.onConnectionFetchFailed?.({\n\t\t\t\tid: id,\n\t\t\t\tcode: tokenRsp.status,\n\t\t\t\trefreshing: refresh,\n\t\t\t});\n\n\t\t\tif (refresh) {\n\t\t\t\t// try once to just get the latest token if the refresh fails, and give up if that fails too;\n\t\t\t\t// stay scoped to the same connection (by token id) so we don't fall back to the primary.\n\t\t\t\tconst latestPath = connectionId\n\t\t\t\t\t? `v1/provider-tokens/tokens/${encodeURIComponent(connectionId)}`\n\t\t\t\t\t: `v1/provider-tokens/${cloudIntegrationType}`;\n\t\t\t\tconst newTokenRsp = await this.ctx.account.fetchGkApi(latestPath, { method: 'GET' });\n\t\t\t\tif (newTokenRsp.ok) {\n\t\t\t\t\tconst data = ((await newTokenRsp.json()) as { data?: GKProviderToken })?.data;\n\t\t\t\t\treturn data != null ? toSession(data) : undefined;\n\t\t\t\t}\n\n\t\t\t\t// Report the fallback's own status (as a get, not a refresh) — it's what decides the outcome.\n\t\t\t\tthis.ctx.hooks?.connection?.onConnectionFetchFailed?.({\n\t\t\t\t\tid: id,\n\t\t\t\t\tcode: newTokenRsp.status,\n\t\t\t\t\trefreshing: false,\n\t\t\t\t});\n\n\t\t\t\tif (isTerminalStatus(newTokenRsp.status)) return undefined;\n\n\t\t\t\tthrow new Error(`Retryable failure (${newTokenRsp.status}) refreshing ${id} token from cloud`);\n\t\t\t}\n\n\t\t\tif (isTerminalStatus(tokenRsp.status)) return undefined;\n\n\t\t\tthrow new Error(`Retryable failure (${tokenRsp.status}) getting ${id} token from cloud`);\n\t\t}\n\n\t\tconst data = ((await tokenRsp.json()) as { data?: GKProviderToken })?.data;\n\t\treturn data != null ? toSession(data) : undefined;\n\t}\n\n\tasync disconnect(id: IntegrationIds): Promise<boolean> {\n\t\tconst scope = getScopedLogger();\n\n\t\tconst cloudIntegrationType = toCloudIntegrationType[id];\n\t\tif (cloudIntegrationType == null) {\n\t\t\tscope?.error(undefined, `Unsupported cloud integration type: ${id}`);\n\t\t\treturn false;\n\t\t}\n\n\t\tconst tokenRsp = await this.ctx.account.fetchGkApi(`v1/provider-tokens/${cloudIntegrationType}`, {\n\t\t\tmethod: 'DELETE',\n\t\t});\n\t\tif (!tokenRsp.ok) {\n\t\t\tconst error = ((await tokenRsp.json()) as { error?: unknown })?.error;\n\t\t\tconst errorMessage =\n\t\t\t\ttypeof error === 'string' ? error : ((error as { message?: string })?.message ?? tokenRsp.statusText);\n\t\t\tif (error != null) {\n\t\t\t\tscope?.error(undefined, `Failed to disconnect ${id} token from cloud: ${errorMessage}`);\n\t\t\t}\n\t\t\tthis.ctx.hooks?.connection?.onDisconnectFailed?.({\n\t\t\t\tid: id,\n\t\t\t\tcode: tokenRsp.status,\n\t\t\t});\n\t\t\treturn false;\n\t\t}\n\n\t\treturn true;\n\t}\n\n\t/**\n\t * Disconnects a single connection by its token id (multi-account). The backend promotes a secondary\n\t * to primary when the removed connection was the primary, so the provider stays connected if others\n\t * remain. Returns whether the delete succeeded.\n\t */\n\tasync disconnectConnection(id: IntegrationIds, connectionId: string): Promise<boolean> {\n\t\tconst scope = getScopedLogger();\n\n\t\tconst tokenRsp = await this.ctx.account.fetchGkApi(\n\t\t\t`v1/provider-tokens/tokens/${encodeURIComponent(connectionId)}`,\n\t\t\t{ method: 'DELETE' },\n\t\t);\n\t\tif (!tokenRsp.ok) {\n\t\t\tconst error = ((await tokenRsp.json()) as { error?: unknown })?.error;\n\t\t\tconst errorMessage =\n\t\t\t\ttypeof error === 'string' ? error : ((error as { message?: string })?.message ?? tokenRsp.statusText);\n\t\t\tif (error != null) {\n\t\t\t\tscope?.error(undefined, `Failed to disconnect connection '${connectionId}' for ${id}: ${errorMessage}`);\n\t\t\t}\n\t\t\tthis.ctx.hooks?.connection?.onDisconnectFailed?.({ id: id, code: tokenRsp.status });\n\t\t\treturn false;\n\t\t}\n\n\t\treturn true;\n\t}\n\n\t/**\n\t * Promotes a connection to primary by its token id (multi-account), clearing primacy on the provider's\n\t * other connections server-side. Returns whether the switch succeeded.\n\t */\n\tasync setPrimaryConnection(id: IntegrationIds, connectionId: string): Promise<boolean> {\n\t\tconst scope = getScopedLogger();\n\n\t\tconst tokenRsp = await this.ctx.account.fetchGkApi(\n\t\t\t`v1/provider-tokens/tokens/${encodeURIComponent(connectionId)}/primary`,\n\t\t\t{ method: 'POST' },\n\t\t);\n\t\tif (!tokenRsp.ok) {\n\t\t\tconst error = ((await tokenRsp.json()) as { error?: unknown })?.error;\n\t\t\tconst errorMessage =\n\t\t\t\ttypeof error === 'string' ? error : ((error as { message?: string })?.message ?? tokenRsp.statusText);\n\t\t\tif (error != null) {\n\t\t\t\tscope?.error(\n\t\t\t\t\tundefined,\n\t\t\t\t\t`Failed to set primary connection '${connectionId}' for ${id}: ${errorMessage}`,\n\t\t\t\t);\n\t\t\t}\n\t\t\treturn false;\n\t\t}\n\n\t\treturn true;\n\t}\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"cloudIntegrationService.js","names":[],"sources":["../../../../../plus/integrations/src/authentication/cloudIntegrationService.ts"],"sourcesContent":["import { RequestRateLimitError } from '@gitlens/git/errors.js';\nimport { getScopedLogger } from '@gitlens/utils/logger.scoped.js';\nimport type { IntegrationIds } from '../constants.js';\nimport type { IntegrationServiceContext } from '../context.js';\nimport type {\n\tCloudIntegrationAuthenticationSession,\n\tCloudIntegrationConnection,\n\tCloudIntegrationType,\n} from './models.js';\nimport { toCloudIntegrationType } from './models.js';\n\n/**\n * Wire shape of a `v1/provider-tokens` connection descriptor. The list endpoint returns one such\n * entry per provider as the primary, with additional accounts nested under `secondaries`. Field casing\n * is the backend's (camelCase, `tokenId`).\n */\ninterface GKProviderTokenData {\n\ttokenId: string;\n\tprovider: CloudIntegrationType;\n\ttype: CloudIntegrationAuthenticationSession['type'];\n\tdomain: string;\n\t/** Human-readable account handle; the backend includes it on the connection when available. */\n\taccountName?: string;\n}\n\n/** Wire shape of a single `v1/provider-tokens/...` token response (`data`). */\ninterface GKProviderToken {\n\ttokenId?: string;\n\tisPrimary?: boolean;\n\taccessToken: string;\n\tappKey?: string;\n\texpiresIn: number;\n\tscopes: string;\n\ttype: CloudIntegrationAuthenticationSession['type'];\n\tdomain?: string;\n}\n\n/**\n * Whether the backend definitively answered that this connection has no fetchable token, so it can be\n * cleanly disconnected (#5497). Everything else is retryable — notably 401/403, which reject our GK\n * *account* token rather than the connection. The asymmetry is deliberate: a wrongly-kept descriptor\n * self-corrects on the next sync, a wrongly-dropped one needs a manual reconnect (#5569).\n */\nfunction isTerminalStatus(status: number): boolean {\n\tswitch (status) {\n\t\tcase 400: // e.g. a non-uuid token id (#5497)\n\t\tcase 404:\n\t\tcase 410:\n\t\tcase 422:\n\t\t\treturn true;\n\t\tdefault:\n\t\t\treturn false;\n\t}\n}\n\n/**\n * The error for a token fetch the backend refused for a reason a retry can fix. A 429 is typed as a rate limit so\n * a read that needed the token reports a throttle rather than a broken connection (gitkraken/kepler#3546); every\n * other status stays a plain error.\n */\nfunction retryableTokenFetchError(status: number, message: string): Error {\n\tconst error = new Error(message);\n\treturn status === 429 ? new RequestRateLimitError(error, undefined, undefined) : error;\n}\n\nfunction toSession(data: GKProviderToken): CloudIntegrationAuthenticationSession {\n\t// Normalize the backend's `tokenId` onto our `id` so callers get a stable per-connection identity.\n\treturn {\n\t\ttype: data.type,\n\t\taccessToken: data.accessToken,\n\t\tdomain: data.domain ?? '',\n\t\texpiresIn: data.expiresIn,\n\t\tscopes: data.scopes,\n\t\tid: data.tokenId,\n\t\tappKey: data.appKey,\n\t};\n}\n\nasync function getResponseError(response: Response): Promise<unknown> {\n\ttry {\n\t\treturn ((await response.json()) as { error?: unknown })?.error;\n\t} catch {\n\t\treturn undefined;\n\t}\n}\n\nexport class CloudIntegrationService {\n\tconstructor(private readonly ctx: IntegrationServiceContext) {}\n\n\tasync getConnections(): Promise<CloudIntegrationConnection[] | undefined> {\n\t\tconst scope = getScopedLogger();\n\n\t\tconst providersRsp = await this.ctx.account.fetchGkApi('v1/provider-tokens', { method: 'GET' });\n\t\tif (!providersRsp.ok) {\n\t\t\tconst error = await getResponseError(providersRsp);\n\t\t\tconst errorMessage =\n\t\t\t\ttypeof error === 'string'\n\t\t\t\t\t? error\n\t\t\t\t\t: ((error as { message?: string })?.message ?? providersRsp.statusText);\n\t\t\tif (error != null) {\n\t\t\t\tscope?.error(undefined, `Failed to get connected providers from cloud: ${errorMessage}`);\n\t\t\t}\n\t\t\tthis.ctx.hooks?.connection?.onConnectionsFetchFailed?.({ code: providersRsp.status });\n\t\t\treturn undefined;\n\t\t}\n\n\t\tconst data = ((await providersRsp.json()) as { data?: unknown })?.data as\n\t\t\t| (GKProviderTokenData & { secondaries?: GKProviderTokenData[] })[]\n\t\t\t| undefined;\n\t\t// A non-array `data` (unexpected backend shape) would throw on iteration below and abort the\n\t\t// whole cloud-sync path, so bail gracefully instead.\n\t\tif (!Array.isArray(data)) return undefined;\n\n\t\t// Flatten the primary + `secondaries[]` grouping into one connection per account. Primacy is\n\t\t// positional on the wire (top-level entry = primary), so we set the `primary` flag here.\n\t\tconst connections: CloudIntegrationConnection[] = [];\n\t\tfor (const item of data) {\n\t\t\tconnections.push({\n\t\t\t\tid: item.tokenId,\n\t\t\t\ttype: item.type,\n\t\t\t\tprovider: item.provider,\n\t\t\t\tdomain: item.domain,\n\t\t\t\tprimary: true,\n\t\t\t\taccountName: item.accountName,\n\t\t\t});\n\t\t\t// `secondaries` comes from the same unknown payload as `data`; guard against a non-array shape so\n\t\t\t// an unexpected value doesn't throw here and abort the whole cloud-sync path.\n\t\t\tfor (const secondary of Array.isArray(item.secondaries) ? item.secondaries : []) {\n\t\t\t\tconnections.push({\n\t\t\t\t\tid: secondary.tokenId,\n\t\t\t\t\ttype: secondary.type,\n\t\t\t\t\tprovider: secondary.provider ?? item.provider,\n\t\t\t\t\tdomain: secondary.domain || item.domain,\n\t\t\t\t\tprimary: false,\n\t\t\t\t\taccountName: secondary.accountName,\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\n\t\treturn connections;\n\t}\n\n\t/**\n\t * Fetches a connection's token, refreshing it first when `refreshToken` is given; `connectionId` targets\n\t * a specific connection (multi-account) rather than the provider's primary.\n\t *\n\t * Resolves `undefined` ONLY on a terminal failure (see {@link isTerminalStatus}, or an ok response with\n\t * no `data`), which callers may treat as \"the connection is gone\" (#5497). THROWS on every other\n\t * failure, which they must catch and treat as retryable instead of disconnecting (#5569).\n\t */\n\tasync getConnectionSession(\n\t\tid: IntegrationIds,\n\t\trefreshToken?: string,\n\t\tconnectionId?: string,\n\t): Promise<CloudIntegrationAuthenticationSession | undefined> {\n\t\tconst scope = getScopedLogger();\n\n\t\tconst refresh = Boolean(refreshToken);\n\t\tconst cloudIntegrationType = toCloudIntegrationType[id];\n\t\tif (cloudIntegrationType == null) {\n\t\t\tscope?.error(undefined, `Unsupported cloud integration type: ${id}`);\n\t\t\treturn undefined;\n\t\t}\n\n\t\tconst reqInitOptions = refreshToken\n\t\t\t? {\n\t\t\t\t\tmethod: 'POST',\n\t\t\t\t\tbody: JSON.stringify({\n\t\t\t\t\t\taccess_token: refreshToken,\n\t\t\t\t\t}),\n\t\t\t\t}\n\t\t\t: { method: 'GET' };\n\n\t\t// A specific connection (multi-account) is addressed by its token id under `/tokens/{tokenId}`,\n\t\t// including refresh (`/tokens/{tokenId}/refresh`). The provider-scoped endpoints only ever operate\n\t\t// on the provider's PRIMARY connection, so they must NOT be used to refresh a secondary.\n\t\tconst path = connectionId\n\t\t\t? `v1/provider-tokens/tokens/${encodeURIComponent(connectionId)}${refresh ? '/refresh' : ''}`\n\t\t\t: `v1/provider-tokens/${cloudIntegrationType}${refresh ? '/refresh' : ''}`;\n\t\tconst tokenRsp = await this.ctx.account.fetchGkApi(path, reqInitOptions);\n\t\tif (!tokenRsp.ok) {\n\t\t\tconst error = await getResponseError(tokenRsp);\n\t\t\tconst errorMessage =\n\t\t\t\ttypeof error === 'string' ? error : ((error as { message?: string })?.message ?? tokenRsp.statusText);\n\t\t\tif (error != null) {\n\t\t\t\tscope?.error(\n\t\t\t\t\tundefined,\n\t\t\t\t\t`Failed to ${refresh ? 'refresh' : 'get'} ${id} token from cloud: ${errorMessage}`,\n\t\t\t\t);\n\t\t\t}\n\t\t\tthis.ctx.hooks?.connection?.onConnectionFetchFailed?.({\n\t\t\t\tid: id,\n\t\t\t\tcode: tokenRsp.status,\n\t\t\t\trefreshing: refresh,\n\t\t\t});\n\n\t\t\tif (refresh) {\n\t\t\t\t// try once to just get the latest token if the refresh fails, and give up if that fails too;\n\t\t\t\t// stay scoped to the same connection (by token id) so we don't fall back to the primary.\n\t\t\t\tconst latestPath = connectionId\n\t\t\t\t\t? `v1/provider-tokens/tokens/${encodeURIComponent(connectionId)}`\n\t\t\t\t\t: `v1/provider-tokens/${cloudIntegrationType}`;\n\t\t\t\tconst newTokenRsp = await this.ctx.account.fetchGkApi(latestPath, { method: 'GET' });\n\t\t\t\tif (newTokenRsp.ok) {\n\t\t\t\t\tconst data = ((await newTokenRsp.json()) as { data?: GKProviderToken })?.data;\n\t\t\t\t\treturn data != null ? toSession(data) : undefined;\n\t\t\t\t}\n\n\t\t\t\t// Report the fallback's own status (as a get, not a refresh) — it's what decides the outcome.\n\t\t\t\tthis.ctx.hooks?.connection?.onConnectionFetchFailed?.({\n\t\t\t\t\tid: id,\n\t\t\t\t\tcode: newTokenRsp.status,\n\t\t\t\t\trefreshing: false,\n\t\t\t\t});\n\n\t\t\t\tif (isTerminalStatus(newTokenRsp.status)) return undefined;\n\n\t\t\t\tthrow retryableTokenFetchError(\n\t\t\t\t\tnewTokenRsp.status,\n\t\t\t\t\t`Retryable failure (${newTokenRsp.status}) refreshing ${id} token from cloud`,\n\t\t\t\t);\n\t\t\t}\n\n\t\t\tif (isTerminalStatus(tokenRsp.status)) return undefined;\n\n\t\t\tthrow retryableTokenFetchError(\n\t\t\t\ttokenRsp.status,\n\t\t\t\t`Retryable failure (${tokenRsp.status}) getting ${id} token from cloud`,\n\t\t\t);\n\t\t}\n\n\t\tconst data = ((await tokenRsp.json()) as { data?: GKProviderToken })?.data;\n\t\treturn data != null ? toSession(data) : undefined;\n\t}\n\n\tasync disconnect(id: IntegrationIds): Promise<boolean> {\n\t\tconst scope = getScopedLogger();\n\n\t\tconst cloudIntegrationType = toCloudIntegrationType[id];\n\t\tif (cloudIntegrationType == null) {\n\t\t\tscope?.error(undefined, `Unsupported cloud integration type: ${id}`);\n\t\t\treturn false;\n\t\t}\n\n\t\tconst tokenRsp = await this.ctx.account.fetchGkApi(`v1/provider-tokens/${cloudIntegrationType}`, {\n\t\t\tmethod: 'DELETE',\n\t\t});\n\t\tif (!tokenRsp.ok) {\n\t\t\tconst error = await getResponseError(tokenRsp);\n\t\t\tconst errorMessage =\n\t\t\t\ttypeof error === 'string' ? error : ((error as { message?: string })?.message ?? tokenRsp.statusText);\n\t\t\tif (error != null) {\n\t\t\t\tscope?.error(undefined, `Failed to disconnect ${id} token from cloud: ${errorMessage}`);\n\t\t\t}\n\t\t\tthis.ctx.hooks?.connection?.onDisconnectFailed?.({\n\t\t\t\tid: id,\n\t\t\t\tcode: tokenRsp.status,\n\t\t\t});\n\t\t\treturn false;\n\t\t}\n\n\t\treturn true;\n\t}\n\n\t/**\n\t * Disconnects a single connection by its token id (multi-account). The backend promotes a secondary\n\t * to primary when the removed connection was the primary, so the provider stays connected if others\n\t * remain. Returns whether the delete succeeded.\n\t */\n\tasync disconnectConnection(id: IntegrationIds, connectionId: string): Promise<boolean> {\n\t\tconst scope = getScopedLogger();\n\n\t\tconst tokenRsp = await this.ctx.account.fetchGkApi(\n\t\t\t`v1/provider-tokens/tokens/${encodeURIComponent(connectionId)}`,\n\t\t\t{ method: 'DELETE' },\n\t\t);\n\t\tif (!tokenRsp.ok) {\n\t\t\tconst error = await getResponseError(tokenRsp);\n\t\t\tconst errorMessage =\n\t\t\t\ttypeof error === 'string' ? error : ((error as { message?: string })?.message ?? tokenRsp.statusText);\n\t\t\tif (error != null) {\n\t\t\t\tscope?.error(undefined, `Failed to disconnect connection '${connectionId}' for ${id}: ${errorMessage}`);\n\t\t\t}\n\t\t\tthis.ctx.hooks?.connection?.onDisconnectFailed?.({ id: id, code: tokenRsp.status });\n\t\t\treturn false;\n\t\t}\n\n\t\treturn true;\n\t}\n\n\t/**\n\t * Promotes a connection to primary by its token id (multi-account), clearing primacy on the provider's\n\t * other connections server-side. Returns whether the switch succeeded.\n\t */\n\tasync setPrimaryConnection(id: IntegrationIds, connectionId: string): Promise<boolean> {\n\t\tconst scope = getScopedLogger();\n\n\t\tconst tokenRsp = await this.ctx.account.fetchGkApi(\n\t\t\t`v1/provider-tokens/tokens/${encodeURIComponent(connectionId)}/primary`,\n\t\t\t{ method: 'POST' },\n\t\t);\n\t\tif (!tokenRsp.ok) {\n\t\t\tconst error = await getResponseError(tokenRsp);\n\t\t\tconst errorMessage =\n\t\t\t\ttypeof error === 'string' ? error : ((error as { message?: string })?.message ?? tokenRsp.statusText);\n\t\t\tif (error != null) {\n\t\t\t\tscope?.error(\n\t\t\t\t\tundefined,\n\t\t\t\t\t`Failed to set primary connection '${connectionId}' for ${id}: ${errorMessage}`,\n\t\t\t\t);\n\t\t\t}\n\t\t\treturn false;\n\t\t}\n\n\t\treturn true;\n\t}\n}\n"],"mappings":"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"}
@@ -1,29 +1,10 @@
1
1
  import { Disposable } from "../../../utils/disposable.js";
2
2
  import { Event } from "../../../utils/event.js";
3
- import { CloudIntegrationAuthType, ConfiguredIntegrationDescriptor, ProviderAuthenticationSession } from "./models.js";
3
+ import { ConfiguredIntegrationDescriptor, ProviderAuthenticationSession } from "./models.js";
4
4
  import { IntegrationIds } from "../constants.js";
5
5
  import { IntegrationAuthenticationSessionDescriptor } from "./integrationAuthenticationProvider.js";
6
6
  import { IntegrationServiceContext } from "../context.js";
7
7
  //#region ../plus/integrations/src/authentication/configuredIntegrationService.d.ts
8
- interface StoredSession {
9
- id: string;
10
- accessToken: string;
11
- account?: {
12
- label?: string;
13
- displayName?: string;
14
- id: string;
15
- };
16
- scopes: string[];
17
- cloud?: boolean;
18
- type: CloudIntegrationAuthType | undefined;
19
- expiresAt?: string;
20
- domain?: string;
21
- /** See {@link ProviderAuthenticationSession.baseUrl}: the configured address, path included. */
22
- baseUrl?: string;
23
- protocol?: string;
24
- /** The provider app key paired with the token for providers whose client needs one (e.g. Trello). */
25
- appKey?: string;
26
- }
27
8
  export interface ConfiguredIntegrationsChangeEvent {
28
9
  readonly added: readonly IntegrationIds[];
29
10
  readonly removed: readonly IntegrationIds[];
@@ -32,6 +13,16 @@ export declare class ConfiguredIntegrationService implements Disposable {
32
13
  private readonly ctx;
33
14
  private readonly _onDidChange;
34
15
  private storeConfiguredQueue;
16
+ /**
17
+ * One provider's secret and descriptor mutations run one at a time, so a sign-out never interleaves with a
18
+ * token being stored: storing writes the secret and then registers its descriptor, and a delete in between would
19
+ * miss a connection it cannot see yet, or be undone by the pending write (#5948).
20
+ */
21
+ private readonly _locks;
22
+ /** Monotonic across providers; see {@link getSignOutMark}. */
23
+ private _signOutEpoch;
24
+ /** The epoch of the latest sign-out per scope: a provider, a self-managed host, or a single connection. */
25
+ private readonly _signOuts;
35
26
  get onDidChange(): Event<ConfiguredIntegrationsChangeEvent>;
36
27
  constructor(ctx: IntegrationServiceContext);
37
28
  dispose(): void;
@@ -44,11 +35,34 @@ export declare class ConfiguredIntegrationService implements Disposable {
44
35
  private storeConfigured;
45
36
  private addOrUpdateConfigured;
46
37
  private removeConfigured;
47
- storeSession(id: IntegrationIds, session: ProviderAuthenticationSession): Promise<void>;
38
+ private withLock;
39
+ /**
40
+ * Taken before a token is read or fetched, and handed to {@link storeSession}: a sign-out that lands after it
41
+ * (a disconnect, a reauthentication, the connection being removed) refuses the store, so a token fetched across
42
+ * it is not written back and reconnected on the next resolution (gitkraken/kepler#3546).
43
+ */
44
+ getSignOutMark(): number;
45
+ private markSignOut;
46
+ private getHostScope;
47
+ private isSignedOutSince;
48
+ /**
49
+ * Stores the session's secret and descriptor together. With a `signOutMark`, nothing is stored, and `false` is
50
+ * returned, when a sign-out covering the session landed after the mark was taken.
51
+ */
52
+ storeSession(id: IntegrationIds, session: ProviderAuthenticationSession, options?: {
53
+ signOutMark?: number;
54
+ }): Promise<boolean>;
48
55
  getStoredSession(id: IntegrationIds, descriptor: IntegrationAuthenticationSessionDescriptor): Promise<ProviderAuthenticationSession | undefined>;
56
+ /**
57
+ * Deletes one connection's stored session. `signOut` is for a delete that signs the integration out (a forced new
58
+ * session): it also refuses the store of any token of the provider, or of the descriptor's self-managed host,
59
+ * fetched before it (see {@link getSignOutMark}). A cleanup of a replaced token leaves it unset.
60
+ */
49
61
  deleteStoredSessions(id: IntegrationIds, descriptor: IntegrationAuthenticationSessionDescriptor, cloud?: boolean, options?: {
50
62
  preserveConfigured?: boolean;
63
+ signOut?: boolean;
51
64
  }): Promise<void>;
65
+ /** Signs out of every stored session of the provider, or of one self-managed host when `domain` is given. */
52
66
  deleteAllStoredSessions(id: IntegrationIds, cloud?: boolean, domain?: string): Promise<void>;
53
67
  /**
54
68
  * Reclaims all stored configuration + secrets for the given (typically retired) integration ids.
@@ -58,12 +72,10 @@ export declare class ConfiguredIntegrationService implements Disposable {
58
72
  * are a no-op.
59
73
  */
60
74
  purgeStoredConfiguration(ids: readonly string[]): Promise<void>;
61
- deleteSecrets(id: IntegrationIds, connectionId: string, cloud?: boolean, options?: {
62
- preserveConfigured?: boolean;
63
- }): Promise<void>;
64
- deleteAllSecrets(id: IntegrationIds, cloud?: boolean, domain?: string): Promise<void>;
65
- writeSecret(id: IntegrationIds, session: ProviderAuthenticationSession | StoredSession): Promise<void>;
66
- readSecret(id: IntegrationIds, sessionId: string, cloud?: boolean): Promise<StoredSession | undefined>;
75
+ private deleteSecrets;
76
+ private deleteAllSecrets;
77
+ private writeSecret;
78
+ private readSecret;
67
79
  private getSecretKey;
68
80
  private getLocalSecretKey;
69
81
  private getCloudSecretKey;
@@ -116,6 +128,7 @@ export declare class ConfiguredIntegrationService implements Disposable {
116
128
  * siblings. Persists immediately instead of relying on a session re-store to carry the primary flag.
117
129
  */
118
130
  setPrimaryConnection(id: IntegrationIds, connectionId: string): Promise<void>;
131
+ private setPrimaryConnectionCore;
119
132
  /**
120
133
  * Removes a single connection (its secret + configured descriptor). If it was the primary and other
121
134
  * connections remain, a secondary is promoted (see {@link removeConfigured}). Unlike
@@ -9,6 +9,16 @@ var ConfiguredIntegrationService = class {
9
9
  ctx;
10
10
  _onDidChange = new Emitter();
11
11
  storeConfiguredQueue = Promise.resolve();
12
+ /**
13
+ * One provider's secret and descriptor mutations run one at a time, so a sign-out never interleaves with a
14
+ * token being stored: storing writes the secret and then registers its descriptor, and a delete in between would
15
+ * miss a connection it cannot see yet, or be undone by the pending write (#5948).
16
+ */
17
+ _locks = /* @__PURE__ */ new Map();
18
+ /** Monotonic across providers; see {@link getSignOutMark}. */
19
+ _signOutEpoch = 0;
20
+ /** The epoch of the latest sign-out per scope: a provider, a self-managed host, or a single connection. */
21
+ _signOuts = /* @__PURE__ */ new Map();
12
22
  get onDidChange() {
13
23
  return this._onDidChange.event;
14
24
  }
@@ -112,30 +122,84 @@ var ConfiguredIntegrationService = class {
112
122
  this.fireChange(void 0, id);
113
123
  await this.storeConfigured();
114
124
  }
115
- async storeSession(id, session) {
116
- await this.writeSecret(id, session);
125
+ withLock(id, fn) {
126
+ const run = (this._locks.get(id) ?? Promise.resolve()).then(fn);
127
+ const settled = run.then(() => void 0, () => void 0);
128
+ this._locks.set(id, settled);
129
+ settled.then(() => {
130
+ if (this._locks.get(id) === settled) this._locks.delete(id);
131
+ });
132
+ return run;
133
+ }
134
+ /**
135
+ * Taken before a token is read or fetched, and handed to {@link storeSession}: a sign-out that lands after it
136
+ * (a disconnect, a reauthentication, the connection being removed) refuses the store, so a token fetched across
137
+ * it is not written back and reconnected on the next resolution (gitkraken/kepler#3546).
138
+ */
139
+ getSignOutMark() {
140
+ return this._signOutEpoch;
141
+ }
142
+ markSignOut(scope) {
143
+ this._signOuts.set(scope, ++this._signOutEpoch);
144
+ }
145
+ getHostScope(id, domain) {
146
+ return `${id}|host:${hostFromDomain(domain) ?? domain}`;
147
+ }
148
+ isSignedOutSince(id, session, mark) {
149
+ const since = (scope) => (this._signOuts.get(scope) ?? 0) > mark;
150
+ if (since(id)) return true;
151
+ if (isSelfManagedHostIntegrationId(id) && session.domain && since(this.getHostScope(id, session.domain))) return true;
152
+ return since(`${id}|connection:${session.id}`);
153
+ }
154
+ /**
155
+ * Stores the session's secret and descriptor together. With a `signOutMark`, nothing is stored, and `false` is
156
+ * returned, when a sign-out covering the session landed after the mark was taken.
157
+ */
158
+ storeSession(id, session, options) {
159
+ return this.withLock(id, async () => {
160
+ if (options?.signOutMark != null && this.isSignedOutSince(id, session, options.signOutMark)) return false;
161
+ await this.writeSecret(id, session);
162
+ return true;
163
+ });
117
164
  }
118
- async getStoredSession(id, descriptor) {
119
- const sessionId = this.resolveConnectionId(id, descriptor);
120
- let session = descriptor.cloud === true ? void 0 : await this.readSecret(id, sessionId, false);
121
- let cloudIfMissing = false;
122
- if (session != null) {
123
- if (session.expiresAt != null) {
165
+ getStoredSession(id, descriptor) {
166
+ return this.withLock(id, async () => {
167
+ const sessionId = this.resolveConnectionId(id, descriptor);
168
+ let session = descriptor.cloud === true ? void 0 : await this.readSecret(id, sessionId, false);
169
+ let cloudIfMissing = false;
170
+ if (session != null) {
171
+ if (session.expiresAt != null) {
172
+ cloudIfMissing = true;
173
+ try {
174
+ await this.deleteSecrets(id, session.id);
175
+ await this.writeSecret(id, session);
176
+ } catch {}
177
+ }
178
+ }
179
+ if (session == null && descriptor.cloud !== false) {
124
180
  cloudIfMissing = true;
125
- await Promise.allSettled([this.deleteSecrets(id, session.id), this.writeSecret(id, session)]);
181
+ session = await this.readSecret(id, sessionId, true);
126
182
  }
127
- }
128
- if (session == null && descriptor.cloud !== false) {
129
- cloudIfMissing = true;
130
- session = await this.readSecret(id, sessionId, true);
131
- }
132
- return convertStoredSessionToSession(session, descriptor, cloudIfMissing);
183
+ return convertStoredSessionToSession(session, descriptor, cloudIfMissing);
184
+ });
133
185
  }
134
- async deleteStoredSessions(id, descriptor, cloud, options) {
135
- await this.deleteSecrets(id, this.resolveConnectionId(id, descriptor), cloud, options);
186
+ /**
187
+ * Deletes one connection's stored session. `signOut` is for a delete that signs the integration out (a forced new
188
+ * session): it also refuses the store of any token of the provider, or of the descriptor's self-managed host,
189
+ * fetched before it (see {@link getSignOutMark}). A cleanup of a replaced token leaves it unset.
190
+ */
191
+ deleteStoredSessions(id, descriptor, cloud, options) {
192
+ return this.withLock(id, async () => {
193
+ if (options?.signOut) this.markSignOut(isSelfManagedHostIntegrationId(id) && descriptor.domain ? this.getHostScope(id, descriptor.domain) : id);
194
+ await this.deleteSecrets(id, this.resolveConnectionId(id, descriptor), cloud, options);
195
+ });
136
196
  }
137
- async deleteAllStoredSessions(id, cloud, domain) {
138
- await this.deleteAllSecrets(id, cloud, domain);
197
+ /** Signs out of every stored session of the provider, or of one self-managed host when `domain` is given. */
198
+ deleteAllStoredSessions(id, cloud, domain) {
199
+ return this.withLock(id, async () => {
200
+ this.markSignOut(isSelfManagedHostIntegrationId(id) && domain != null ? this.getHostScope(id, domain) : id);
201
+ await this.deleteAllSecrets(id, cloud, domain);
202
+ });
139
203
  }
140
204
  /**
141
205
  * Reclaims all stored configuration + secrets for the given (typically retired) integration ids.
@@ -317,7 +381,10 @@ var ConfiguredIntegrationService = class {
317
381
  * Marks the given connection as the primary/default for the provider and clears the flag on its
318
382
  * siblings. Persists immediately instead of relying on a session re-store to carry the primary flag.
319
383
  */
320
- async setPrimaryConnection(id, connectionId) {
384
+ setPrimaryConnection(id, connectionId) {
385
+ return this.withLock(id, () => this.setPrimaryConnectionCore(id, connectionId));
386
+ }
387
+ async setPrimaryConnectionCore(id, connectionId) {
321
388
  const descriptors = this.configured.get(id);
322
389
  if (descriptors == null || descriptors.length === 0) return;
323
390
  if (!descriptors.some((d) => d.id === connectionId)) return;
@@ -347,8 +414,11 @@ var ConfiguredIntegrationService = class {
347
414
  * {@link deleteAllStoredSessions}, this only affects the targeted connection. Pass `cloud` to scope
348
415
  * the removal to the cloud/local variant when a local PAT and a cloud session share a connection id.
349
416
  */
350
- async deleteConnection(id, connectionId, cloud) {
351
- await this.deleteSecrets(id, connectionId, cloud);
417
+ deleteConnection(id, connectionId, cloud) {
418
+ return this.withLock(id, async () => {
419
+ this.markSignOut(`${id}|connection:${connectionId}`);
420
+ await this.deleteSecrets(id, connectionId, cloud);
421
+ });
352
422
  }
353
423
  _addedIds = /* @__PURE__ */ new Set();
354
424
  _removedIds = /* @__PURE__ */ new Set();