@gitkraken/core-gitlens 0.4.101 → 0.5.100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -6
- package/dist/git/models/graph.d.ts +63 -33
- package/dist/git/models/graph.d.ts.map +1 -1
- package/dist/git/models/graph.js.map +1 -1
- package/dist/git/models/graphSession.d.ts +3 -84
- package/dist/git/models/graphSession.d.ts.map +1 -1
- package/dist/git/models/graphSession.js +1 -6
- package/dist/git/models/graphSession.js.map +1 -1
- package/dist/git/providers/graph.d.ts +1 -15
- package/dist/git/providers/graph.d.ts.map +1 -1
- package/dist/git/providers/pausedOperations.d.ts +2 -0
- package/dist/git/providers/pausedOperations.d.ts.map +1 -1
- package/dist/git/utils/reachability.utils.d.ts +1 -1
- package/dist/git/utils/reachability.utils.js +1 -1
- package/dist/git-cli/providers/graph.d.ts +1 -3
- package/dist/git-cli/providers/graph.d.ts.map +1 -1
- package/dist/git-cli/providers/graph.js +37 -214
- package/dist/git-cli/providers/graph.js.map +1 -1
- package/dist/git-cli/providers/pausedOperations.d.ts +1 -0
- package/dist/git-cli/providers/pausedOperations.d.ts.map +1 -1
- package/dist/git-cli/providers/pausedOperations.js +21 -1
- package/dist/git-cli/providers/pausedOperations.js.map +1 -1
- package/dist/git-cli/providers/staging.d.ts.map +1 -1
- package/dist/git-cli/providers/staging.js +10 -1
- package/dist/git-cli/providers/staging.js.map +1 -1
- package/dist/plus/git-github/providers/github/graph.d.ts +1 -3
- package/dist/plus/git-github/providers/github/graph.d.ts.map +1 -1
- package/dist/plus/git-github/providers/github/graph.js +8 -10
- package/dist/plus/git-github/providers/github/graph.js.map +1 -1
- package/dist/plus/integrations/authentication/integrationAuthenticationProvider.d.ts +38 -48
- package/dist/plus/integrations/authentication/integrationAuthenticationProvider.d.ts.map +1 -1
- package/dist/plus/integrations/authentication/integrationAuthenticationProvider.js +22 -5
- package/dist/plus/integrations/authentication/integrationAuthenticationProvider.js.map +1 -1
- package/dist/plus/integrations/authentication/rejectedTokenTracker.d.ts +39 -0
- package/dist/plus/integrations/authentication/rejectedTokenTracker.d.ts.map +1 -0
- package/dist/plus/integrations/authentication/rejectedTokenTracker.js +53 -0
- package/dist/plus/integrations/authentication/rejectedTokenTracker.js.map +1 -0
- package/dist/plus/integrations/models/gitHostIntegration.d.ts.map +1 -1
- package/dist/plus/integrations/models/gitHostIntegration.js +19 -6
- package/dist/plus/integrations/models/gitHostIntegration.js.map +1 -1
- package/dist/plus/integrations/models/integration.d.ts +20 -0
- package/dist/plus/integrations/models/integration.d.ts.map +1 -1
- package/dist/plus/integrations/models/integration.js +61 -4
- package/dist/plus/integrations/models/integration.js.map +1 -1
- package/dist/plus/integrations/models/issuesIntegration.js +5 -5
- package/dist/plus/integrations/models/issuesIntegration.js.map +1 -1
- package/dist/utils/date.d.ts +1 -1
- package/dist/utils/date.d.ts.map +1 -1
- package/dist/utils/date.js +5 -3
- package/dist/utils/date.js.map +1 -1
- package/package.json +1 -1
- package/src/git/models/graph.ts +59 -41
- package/src/git/models/graphSession.ts +3 -106
- package/src/git/providers/graph.ts +1 -15
- package/src/git/providers/pausedOperations.ts +8 -1
- package/src/git/utils/reachability.utils.ts +1 -1
- package/src/git-cli/providers/graph.ts +37 -250
- package/src/git-cli/providers/pausedOperations.ts +29 -2
- package/src/git-cli/providers/staging.ts +8 -1
- package/src/plus/git-github/providers/github/graph.ts +8 -15
- package/src/plus/integrations/authentication/integrationAuthenticationProvider.ts +62 -40
- package/src/plus/integrations/authentication/rejectedTokenTracker.ts +56 -0
- package/src/plus/integrations/models/gitHostIntegration.ts +19 -6
- package/src/plus/integrations/models/integration.ts +68 -5
- package/src/plus/integrations/models/issuesIntegration.ts +5 -5
- package/src/utils/date.ts +6 -3
|
@@ -32,6 +32,38 @@ export interface IntegrationAuthenticationSessionDescriptor {
|
|
|
32
32
|
[key: string]: unknown;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Options for {@link IntegrationAuthenticationProvider.getSession}. The two variants keep `sync` exclusive
|
|
37
|
+
* with the interactive flags: a sync resolves whatever the cloud already has, so it must never open a connect
|
|
38
|
+
* window.
|
|
39
|
+
*
|
|
40
|
+
* `refreshRejectedToken` marks the stored token as known-refused by the provider (an `AuthenticationError` on
|
|
41
|
+
* a previous read), which forces a cloud `/refresh` even when the token's expiry still claims it is valid —
|
|
42
|
+
* the case a purely time-based refresh cannot see. The refresh token itself never reaches the client: the GK
|
|
43
|
+
* cloud performs the exchange server-side.
|
|
44
|
+
*/
|
|
45
|
+
export type GetSessionOptions =
|
|
46
|
+
| {
|
|
47
|
+
createIfNeeded?: boolean;
|
|
48
|
+
forceNewSession?: boolean;
|
|
49
|
+
sync?: never;
|
|
50
|
+
refreshRejectedToken?: boolean;
|
|
51
|
+
source?: Sources;
|
|
52
|
+
}
|
|
53
|
+
| {
|
|
54
|
+
createIfNeeded?: never;
|
|
55
|
+
forceNewSession?: never;
|
|
56
|
+
sync: boolean;
|
|
57
|
+
refreshRejectedToken?: boolean;
|
|
58
|
+
source?: Sources;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* What {@link IntegrationAuthenticationProviderBase.getSession} passes down once it has decided the stored
|
|
63
|
+
* session can't be served as-is. `refreshIfExpired` is its own conclusion, not a caller's option.
|
|
64
|
+
*/
|
|
65
|
+
type GetNewSessionOptions = GetSessionOptions & { refreshIfExpired?: boolean };
|
|
66
|
+
|
|
35
67
|
export interface IntegrationAuthenticationProvider extends Disposable {
|
|
36
68
|
/**
|
|
37
69
|
* Clears the stored secret for one connection only (never the whole provider). Unlike
|
|
@@ -46,11 +78,13 @@ export interface IntegrationAuthenticationProvider extends Disposable {
|
|
|
46
78
|
options?: { preserveConfigured?: boolean },
|
|
47
79
|
): Promise<void>;
|
|
48
80
|
deleteAllSessions(descriptor?: IntegrationAuthenticationSessionDescriptor): Promise<void>;
|
|
81
|
+
/**
|
|
82
|
+
* Resolves the session for `descriptor`, from storage when it is still usable and from the cloud
|
|
83
|
+
* otherwise. See {@link GetSessionOptions}.
|
|
84
|
+
*/
|
|
49
85
|
getSession(
|
|
50
86
|
descriptor: IntegrationAuthenticationSessionDescriptor,
|
|
51
|
-
options?:
|
|
52
|
-
| { createIfNeeded?: boolean; forceNewSession?: boolean; sync?: never; source?: Sources }
|
|
53
|
-
| { createIfNeeded?: never; forceNewSession?: never; sync: boolean; source?: Sources },
|
|
87
|
+
options?: GetSessionOptions,
|
|
54
88
|
): Promise<ProviderAuthenticationSession | undefined>;
|
|
55
89
|
get onDidChange(): Event<void>;
|
|
56
90
|
}
|
|
@@ -120,9 +154,7 @@ abstract class IntegrationAuthenticationProviderBase<
|
|
|
120
154
|
@trace()
|
|
121
155
|
async getSession(
|
|
122
156
|
descriptor: IntegrationAuthenticationSessionDescriptor,
|
|
123
|
-
options?:
|
|
124
|
-
| { createIfNeeded?: boolean; forceNewSession?: boolean; sync?: never; source?: Sources }
|
|
125
|
-
| { createIfNeeded?: never; forceNewSession?: never; sync: boolean; source?: Sources },
|
|
157
|
+
options?: GetSessionOptions,
|
|
126
158
|
): Promise<ProviderAuthenticationSession | undefined> {
|
|
127
159
|
let session;
|
|
128
160
|
let previousToken;
|
|
@@ -143,10 +175,15 @@ abstract class IntegrationAuthenticationProviderBase<
|
|
|
143
175
|
}
|
|
144
176
|
|
|
145
177
|
const isExpiredSession = session?.expiresAt != null && new Date(session.expiresAt).getTime() < Date.now();
|
|
146
|
-
|
|
178
|
+
// A rejected token has to re-resolve even when the stored session looks perfectly healthy: that is
|
|
179
|
+
// the whole point — the provider refused it while its expiry still says valid. Without this the
|
|
180
|
+
// stored session short-circuits here and the refresh below is never reached.
|
|
181
|
+
const refreshRejectedToken = options?.refreshRejectedToken === true && session != null;
|
|
182
|
+
if (session == null || isExpiredSession || refreshRejectedToken) {
|
|
147
183
|
session = await this.getNewSession(descriptor, {
|
|
148
184
|
...options,
|
|
149
185
|
refreshIfExpired: isExpiredSession,
|
|
186
|
+
refreshRejectedToken: refreshRejectedToken,
|
|
150
187
|
});
|
|
151
188
|
|
|
152
189
|
if (session != null) {
|
|
@@ -163,21 +200,7 @@ abstract class IntegrationAuthenticationProviderBase<
|
|
|
163
200
|
|
|
164
201
|
protected abstract getNewSession(
|
|
165
202
|
descriptor: IntegrationAuthenticationSessionDescriptor,
|
|
166
|
-
options?:
|
|
167
|
-
| {
|
|
168
|
-
createIfNeeded?: boolean;
|
|
169
|
-
forceNewSession?: boolean;
|
|
170
|
-
sync?: never;
|
|
171
|
-
refreshIfExpired?: boolean;
|
|
172
|
-
source?: Sources;
|
|
173
|
-
}
|
|
174
|
-
| {
|
|
175
|
-
createIfNeeded?: never;
|
|
176
|
-
forceNewSession?: never;
|
|
177
|
-
sync: boolean;
|
|
178
|
-
refreshIfExpired?: boolean;
|
|
179
|
-
source?: Sources;
|
|
180
|
-
},
|
|
203
|
+
options?: GetNewSessionOptions,
|
|
181
204
|
): Promise<ProviderAuthenticationSession | undefined>;
|
|
182
205
|
|
|
183
206
|
protected fireChange(): void {
|
|
@@ -202,21 +225,7 @@ export class CloudIntegrationAuthenticationProvider<
|
|
|
202
225
|
|
|
203
226
|
protected override async getNewSession(
|
|
204
227
|
descriptor: IntegrationAuthenticationSessionDescriptor,
|
|
205
|
-
options?:
|
|
206
|
-
| {
|
|
207
|
-
createIfNeeded?: boolean;
|
|
208
|
-
forceNewSession?: boolean;
|
|
209
|
-
sync?: never;
|
|
210
|
-
refreshIfExpired?: boolean;
|
|
211
|
-
source?: Sources;
|
|
212
|
-
}
|
|
213
|
-
| {
|
|
214
|
-
createIfNeeded?: never;
|
|
215
|
-
forceNewSession?: never;
|
|
216
|
-
sync: boolean;
|
|
217
|
-
refreshIfExpired?: boolean;
|
|
218
|
-
source?: Sources;
|
|
219
|
-
},
|
|
228
|
+
options?: GetNewSessionOptions,
|
|
220
229
|
): Promise<ProviderAuthenticationSession | undefined> {
|
|
221
230
|
if (options?.forceNewSession) {
|
|
222
231
|
if ((await this.disconnectCloudSession()) === 'failure') {
|
|
@@ -230,9 +239,15 @@ export class CloudIntegrationAuthenticationProvider<
|
|
|
230
239
|
// TODO: This is a stopgap to make sure we're not hammering the api on automatic calls to get the session.
|
|
231
240
|
// Ultimately we want to timestamp calls to syncCloudIntegrations and use that to determine whether we should
|
|
232
241
|
// make the call or not.
|
|
242
|
+
// `refreshRejectedToken` joins the list for the same reason `refreshIfExpired` is on it: both mean the
|
|
243
|
+
// session in hand is known-unusable, so skipping the cloud round trip would just re-serve it.
|
|
233
244
|
let session =
|
|
234
|
-
options?.refreshIfExpired ||
|
|
235
|
-
|
|
245
|
+
options?.refreshIfExpired ||
|
|
246
|
+
options?.refreshRejectedToken ||
|
|
247
|
+
options?.createIfNeeded ||
|
|
248
|
+
options?.forceNewSession ||
|
|
249
|
+
options?.sync
|
|
250
|
+
? await this.getCloudSession(descriptor, { refreshRejectedToken: options?.refreshRejectedToken })
|
|
236
251
|
: undefined;
|
|
237
252
|
|
|
238
253
|
const shouldCreateSession = options?.createIfNeeded && session == null;
|
|
@@ -269,6 +284,7 @@ export class CloudIntegrationAuthenticationProvider<
|
|
|
269
284
|
|
|
270
285
|
private async getCloudSession(
|
|
271
286
|
descriptor: IntegrationAuthenticationSessionDescriptor,
|
|
287
|
+
options?: { refreshRejectedToken?: boolean },
|
|
272
288
|
): Promise<ProviderAuthenticationSession | undefined> {
|
|
273
289
|
const loggedIn = (await this.authenticationService.ctx.account.getAccount()) != null;
|
|
274
290
|
if (!loggedIn) return undefined;
|
|
@@ -304,7 +320,13 @@ export class CloudIntegrationAuthenticationProvider<
|
|
|
304
320
|
session.expiresIn = maxSmallIntegerV8; // maximum expiration length
|
|
305
321
|
}
|
|
306
322
|
|
|
307
|
-
|
|
323
|
+
// `expiresIn < 60` is a PREDICTION that the token is about to lapse, and it is the only trigger the
|
|
324
|
+
// refresh used to have. It cannot see a token the provider has already refused while the backend
|
|
325
|
+
// still believes it is valid — a revoked grant, retired scopes, an app removed from the org — where
|
|
326
|
+
// `expiresIn` stays high and the doomed token is re-sent on every read. `refreshRejectedToken` is
|
|
327
|
+
// the observed counterpart: the caller saw the provider reject this exact token, so refresh it
|
|
328
|
+
// regardless of what its expiry claims.
|
|
329
|
+
if (session != null && (session.expiresIn < 60 || options?.refreshRejectedToken)) {
|
|
308
330
|
session = await cloudIntegrations.getConnectionSession(
|
|
309
331
|
this.authProviderId,
|
|
310
332
|
session.accessToken,
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-connection recovery state for a cloud token the provider REFUSED — a revoked grant, retired scopes, an
|
|
3
|
+
* app removed from the org. A purely predictive refresh (`expiresIn < 60`) cannot see that case, because the
|
|
4
|
+
* backend still believes the token is valid, so without this the refused token is re-sent on every read and
|
|
5
|
+
* the failure only clears when the user reconnects by hand.
|
|
6
|
+
*
|
|
7
|
+
* One recovery refresh per connection, then the connection is the failure budget's problem:
|
|
8
|
+
* - {@link recordRejection} on an `AuthenticationError` — arms the refresh, or reports that the rejection is
|
|
9
|
+
* not recoverable so the caller counts it as a real failure instead.
|
|
10
|
+
* - {@link claimRefresh} before the next read resolves its session — spends the rejection, so the refresh
|
|
11
|
+
* fires once and no further read of that connection asks the cloud again.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately NOT re-armed when the refresh hands back a different token. The GK cloud mints a new access
|
|
14
|
+
* token on every `/refresh`, so a credential the provider refuses for a reason a refresh cannot fix (retired
|
|
15
|
+
* scopes being the plain case) would look "rotated" on each attempt: re-arming on that would refresh on every
|
|
16
|
+
* read forever and the failure budget — the thing that disconnects and prompts a reconnect — would never
|
|
17
|
+
* advance. What resets this instead is {@link clear}, called by every path that replaces or removes the stored
|
|
18
|
+
* tokens; a connection that is simply working never reaches here at all.
|
|
19
|
+
*/
|
|
20
|
+
export class RejectedTokenTracker {
|
|
21
|
+
private readonly _rejections = new Map<string, 'armed' | 'spent'>();
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Whether a read of `connectionId` must force the cloud refresh of a token known to be refused, spending
|
|
25
|
+
* the rejection so the refresh runs at most once. Spent here rather than after the read because a resolve
|
|
26
|
+
* that throws must not leave the refresh armed and ask the cloud again on the next read.
|
|
27
|
+
*/
|
|
28
|
+
claimRefresh(connectionId: string): boolean {
|
|
29
|
+
if (this._rejections.get(connectionId) !== 'armed') return false;
|
|
30
|
+
|
|
31
|
+
this._rejections.set(connectionId, 'spent');
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Arms the recovery refresh for `connectionId`. Returns `false` once this connection has had its refresh
|
|
37
|
+
* (or already has one pending), so the caller falls back to its own failure handling: a token still
|
|
38
|
+
* refused after the refresh is not self-healing, and only the failure budget ends in a reconnect prompt.
|
|
39
|
+
*/
|
|
40
|
+
recordRejection(connectionId: string): boolean {
|
|
41
|
+
if (this._rejections.has(connectionId)) return false;
|
|
42
|
+
|
|
43
|
+
this._rejections.set(connectionId, 'armed');
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Drops all state, for when the stored tokens a rejection could name are replaced or gone. */
|
|
48
|
+
clear(): void {
|
|
49
|
+
this._rejections.clear();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Whether any connection is holding a rejection. For assertions; the read path uses {@link claimRefresh}. */
|
|
53
|
+
get empty(): boolean {
|
|
54
|
+
return this._rejections.size === 0;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -373,7 +373,10 @@ export abstract class GitHostIntegration<
|
|
|
373
373
|
this.resetRequestExceptionCount('getOrganizationsForUser');
|
|
374
374
|
return { value: result, duration: performance.now() - start };
|
|
375
375
|
} catch (ex) {
|
|
376
|
-
this.handleProviderException('getOrganizationsForUser', ex, {
|
|
376
|
+
this.handleProviderException('getOrganizationsForUser', ex, {
|
|
377
|
+
scope: scope,
|
|
378
|
+
connectionId: connectionId,
|
|
379
|
+
});
|
|
377
380
|
return { error: toError(ex), duration: performance.now() - start };
|
|
378
381
|
}
|
|
379
382
|
}
|
|
@@ -409,7 +412,7 @@ export abstract class GitHostIntegration<
|
|
|
409
412
|
this.resetRequestExceptionCount('getProjectsForOrg');
|
|
410
413
|
return { value: result, duration: performance.now() - start };
|
|
411
414
|
} catch (ex) {
|
|
412
|
-
this.handleProviderException('getProjectsForOrg', ex, { scope: scope });
|
|
415
|
+
this.handleProviderException('getProjectsForOrg', ex, { scope: scope, connectionId: connectionId });
|
|
413
416
|
return { error: toError(ex), duration: performance.now() - start };
|
|
414
417
|
}
|
|
415
418
|
}
|
|
@@ -446,7 +449,10 @@ export abstract class GitHostIntegration<
|
|
|
446
449
|
this.resetRequestExceptionCount('getRepositoriesForOrg');
|
|
447
450
|
return { value: result, duration: performance.now() - start };
|
|
448
451
|
} catch (ex) {
|
|
449
|
-
this.handleProviderException('getRepositoriesForOrg', ex, {
|
|
452
|
+
this.handleProviderException('getRepositoriesForOrg', ex, {
|
|
453
|
+
scope: scope,
|
|
454
|
+
connectionId: options?.connectionId,
|
|
455
|
+
});
|
|
450
456
|
return { error: toError(ex), duration: performance.now() - start };
|
|
451
457
|
}
|
|
452
458
|
}
|
|
@@ -484,7 +490,10 @@ export abstract class GitHostIntegration<
|
|
|
484
490
|
this.resetRequestExceptionCount('getRepositoriesForUser');
|
|
485
491
|
return { value: result, duration: performance.now() - start };
|
|
486
492
|
} catch (ex) {
|
|
487
|
-
this.handleProviderException('getRepositoriesForUser', ex, {
|
|
493
|
+
this.handleProviderException('getRepositoriesForUser', ex, {
|
|
494
|
+
scope: scope,
|
|
495
|
+
connectionId: options?.connectionId,
|
|
496
|
+
});
|
|
488
497
|
return { error: toError(ex), duration: performance.now() - start };
|
|
489
498
|
}
|
|
490
499
|
}
|
|
@@ -1414,6 +1423,7 @@ export abstract class GitHostIntegration<
|
|
|
1414
1423
|
this.handleProviderException('searchMyPullRequests', ex, {
|
|
1415
1424
|
scope: scope,
|
|
1416
1425
|
silent: true,
|
|
1426
|
+
connectionId: connectionId,
|
|
1417
1427
|
});
|
|
1418
1428
|
return {
|
|
1419
1429
|
error: toError(ex),
|
|
@@ -1456,7 +1466,10 @@ export abstract class GitHostIntegration<
|
|
|
1456
1466
|
this.resetRequestExceptionCount('getMyPullRequestsForUser');
|
|
1457
1467
|
return { value: result, duration: performance.now() - start };
|
|
1458
1468
|
} catch (ex) {
|
|
1459
|
-
this.handleProviderException('getMyPullRequestsForUser', ex, {
|
|
1469
|
+
this.handleProviderException('getMyPullRequestsForUser', ex, {
|
|
1470
|
+
scope: scope,
|
|
1471
|
+
connectionId: connectionId,
|
|
1472
|
+
});
|
|
1460
1473
|
return { error: toError(ex), duration: performance.now() - start };
|
|
1461
1474
|
}
|
|
1462
1475
|
}
|
|
@@ -1588,7 +1601,7 @@ export abstract class GitHostIntegration<
|
|
|
1588
1601
|
this.resetRequestExceptionCount('searchPullRequests');
|
|
1589
1602
|
return prs;
|
|
1590
1603
|
} catch (ex) {
|
|
1591
|
-
this.handleProviderException('searchPullRequests', ex, { scope: scope });
|
|
1604
|
+
this.handleProviderException('searchPullRequests', ex, { scope: scope, connectionId: connectionId });
|
|
1592
1605
|
return undefined;
|
|
1593
1606
|
}
|
|
1594
1607
|
}
|
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
} from '../authentication/integrationAuthenticationProvider.js';
|
|
21
21
|
import type { IntegrationAuthenticationService } from '../authentication/integrationAuthenticationService.js';
|
|
22
22
|
import type { ProviderAuthenticationSession } from '../authentication/models.js';
|
|
23
|
+
import { RejectedTokenTracker } from '../authentication/rejectedTokenTracker.js';
|
|
23
24
|
import type { IntegrationIds, IssuesCloudHostIntegrationId } from '../constants.js';
|
|
24
25
|
import { GitCloudHostIntegrationId } from '../constants.js';
|
|
25
26
|
import type { IntegrationServiceContext } from '../context.js';
|
|
@@ -240,13 +241,18 @@ export abstract class IntegrationBase<
|
|
|
240
241
|
// A truthy connectionId targets a specific account; an empty string is not a real target, so it falls
|
|
241
242
|
// through to the primary path below.
|
|
242
243
|
if (connectionId) {
|
|
244
|
+
// A read of this connection previously failed with an AuthenticationError, so the token in
|
|
245
|
+
// storage is known-refused. Ask for a refresh through the GK cloud before reading again, which
|
|
246
|
+
// is this branch's equivalent of the primary path's `refreshSessionIfExpired`. Claimed here so
|
|
247
|
+
// the forced refresh runs at most once per rejection.
|
|
248
|
+
const refreshRejectedToken = this._rejectedTokens.claimRefresh(connectionId);
|
|
243
249
|
// Degrade to "no results" on failure, matching the primary path (whose ensureSession/
|
|
244
250
|
// refreshSessionIfExpired swallow errors) so read methods keep their never-throws contract.
|
|
245
251
|
try {
|
|
246
252
|
const authProvider = await this.authenticationService.get(this.authProvider.id);
|
|
247
253
|
const session = await authProvider.getSession(
|
|
248
254
|
{ ...this.authProviderDescriptor, connectionId: connectionId, cloud: true },
|
|
249
|
-
{ source: source },
|
|
255
|
+
{ source: source, refreshRejectedToken: refreshRejectedToken },
|
|
250
256
|
);
|
|
251
257
|
return session != null && this.isSessionForIntegrationHost(session) ? session : undefined;
|
|
252
258
|
} catch (ex) {
|
|
@@ -326,6 +332,10 @@ export abstract class IntegrationBase<
|
|
|
326
332
|
|
|
327
333
|
@debug()
|
|
328
334
|
async reauthenticate(): Promise<void> {
|
|
335
|
+
// `forceNewSession` below deletes the stored secrets to reconnect. Ahead of the guard — see
|
|
336
|
+
// `onStoredTokensReplaced`.
|
|
337
|
+
this.onStoredTokensReplaced();
|
|
338
|
+
|
|
329
339
|
if (this._session === undefined) return;
|
|
330
340
|
|
|
331
341
|
this._session = undefined;
|
|
@@ -343,6 +353,30 @@ export abstract class IntegrationBase<
|
|
|
343
353
|
requestSessionSyncForUsecase(syncReqUsecase: SyncReqUsecase): void {
|
|
344
354
|
this._syncRequestsPerFailedUsecase.add(syncReqUsecase);
|
|
345
355
|
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* The per-connection counterpart of the primary session's expire-and-resync recovery: a token the
|
|
359
|
+
* provider refused is refreshed once through the GK cloud, which exchanges the refresh token server-side
|
|
360
|
+
* (the client never holds one). Armed by {@link handleProviderException} and consumed by
|
|
361
|
+
* {@link resolveReadSession}; a rejection it declines belongs to {@link trackRequestException} instead.
|
|
362
|
+
*/
|
|
363
|
+
private readonly _rejectedTokens = new RejectedTokenTracker();
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Called by every path that replaces or removes the stored tokens — a disconnect, a forced re-sync, a
|
|
367
|
+
* reauthentication, or the connection set changing. A rejection names a specific credential, so once that
|
|
368
|
+
* credential is gone the rejection describes nothing and must not force a refresh (or, for a deleted
|
|
369
|
+
* connection, outlive it).
|
|
370
|
+
*
|
|
371
|
+
* Deliberately NOT conditioned on `_session`. A per-connection read resolves through the auth provider and
|
|
372
|
+
* never populates the cached primary session, so the connections this recovery exists for are exactly the
|
|
373
|
+
* ones with no `_session` to inspect — the same blind spot the recovery itself was added to fix. Callers
|
|
374
|
+
* that guard on `_session` therefore invoke this ahead of that guard.
|
|
375
|
+
*/
|
|
376
|
+
protected onStoredTokensReplaced(): void {
|
|
377
|
+
this._rejectedTokens.clear();
|
|
378
|
+
}
|
|
379
|
+
|
|
346
380
|
private static readonly requestExceptionLimit = 5;
|
|
347
381
|
private requestExceptionCount = 0;
|
|
348
382
|
|
|
@@ -350,6 +384,8 @@ export abstract class IntegrationBase<
|
|
|
350
384
|
this.requestExceptionCount = 0;
|
|
351
385
|
if (syncReqUsecase === 'all') {
|
|
352
386
|
this._syncRequestsPerFailedUsecase.clear();
|
|
387
|
+
// 'all' is the whole-integration reset: a disconnect, or a re-sync that produced a new access token.
|
|
388
|
+
this.onStoredTokensReplaced();
|
|
353
389
|
} else {
|
|
354
390
|
this._syncRequestsPerFailedUsecase.delete(syncReqUsecase);
|
|
355
391
|
}
|
|
@@ -374,6 +410,10 @@ export abstract class IntegrationBase<
|
|
|
374
410
|
* `deleteConnection`). Unlike {@link reset}/{@link disconnect}, it deletes nothing from storage.
|
|
375
411
|
*/
|
|
376
412
|
switchConnection(): void {
|
|
413
|
+
// A connection was deleted, or a different one became primary. Ahead of the guard — see
|
|
414
|
+
// `onStoredTokensReplaced`.
|
|
415
|
+
this.onStoredTokensReplaced();
|
|
416
|
+
|
|
377
417
|
if (this._session === undefined) return;
|
|
378
418
|
|
|
379
419
|
const wasConnected = this._session != null;
|
|
@@ -419,6 +459,9 @@ export abstract class IntegrationBase<
|
|
|
419
459
|
// Reset our stored session so that we get a new one from the cloud
|
|
420
460
|
const authProvider = await this.authenticationService.get(this.authProvider.id);
|
|
421
461
|
await authProvider.deleteSession(this.authProviderDescriptor);
|
|
462
|
+
// The stored token was just deleted. Not left to the token-changed check below, which needs
|
|
463
|
+
// an `oldSession` — see `onStoredTokensReplaced`.
|
|
464
|
+
this.onStoredTokensReplaced();
|
|
422
465
|
// Reset the session and clear our "stay disconnected" flag
|
|
423
466
|
this._session = undefined;
|
|
424
467
|
await this.ctx.storage.deleteWorkspace(this.connectedKey);
|
|
@@ -474,12 +517,29 @@ export abstract class IntegrationBase<
|
|
|
474
517
|
protected handleProviderException(
|
|
475
518
|
syncReqUsecase: SyncReqUsecase,
|
|
476
519
|
ex: Error,
|
|
477
|
-
options?: { scope?: ScopedLogger | undefined; silent?: boolean },
|
|
520
|
+
options?: { scope?: ScopedLogger | undefined; silent?: boolean; connectionId?: string },
|
|
478
521
|
): void {
|
|
479
522
|
if (isCancellationError(ex)) return;
|
|
480
523
|
|
|
481
524
|
options?.scope?.error(ex);
|
|
482
525
|
|
|
526
|
+
// A per-connection (multi-account) read resolved its session through `resolveReadSession`'s
|
|
527
|
+
// `connectionId` branch, which deliberately never touches the cached primary `_session`. So the
|
|
528
|
+
// primary-session recovery below cannot apply to it: expiring `_session` would mark a session this
|
|
529
|
+
// read never used, while the rejected connection kept its stored token and re-sent it on every
|
|
530
|
+
// later read — a token the provider has already refused (expired scopes, a revoked grant, an
|
|
531
|
+
// uninstalled app) is not self-healing, so the read failed identically until the user reconnected
|
|
532
|
+
// by hand. Record the rejection against the connection instead; `resolveReadSession` consumes it
|
|
533
|
+
// and forces the cloud `/refresh` on the next read of that connection. When there is nothing to
|
|
534
|
+
// recover, fall through to the shared failure budget, which disconnects after
|
|
535
|
+
// `requestExceptionLimit` and surfaces the reconnect prompt.
|
|
536
|
+
if (ex instanceof AuthenticationError && options?.connectionId) {
|
|
537
|
+
if (!this._rejectedTokens.recordRejection(options.connectionId)) {
|
|
538
|
+
this.trackRequestException(options);
|
|
539
|
+
}
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
483
543
|
if (ex instanceof AuthenticationError && this._session?.cloud) {
|
|
484
544
|
if (!this.hasSessionSyncRequests()) {
|
|
485
545
|
this.requestSessionSyncForUsecase(syncReqUsecase);
|
|
@@ -686,7 +746,7 @@ export abstract class IntegrationBase<
|
|
|
686
746
|
this.resetRequestExceptionCount('searchMyIssues');
|
|
687
747
|
return { value: issues };
|
|
688
748
|
} catch (ex) {
|
|
689
|
-
this.handleProviderException('searchMyIssues', ex, { scope: scope });
|
|
749
|
+
this.handleProviderException('searchMyIssues', ex, { scope: scope, connectionId: connectionId });
|
|
690
750
|
return { error: toError(ex) };
|
|
691
751
|
}
|
|
692
752
|
}
|
|
@@ -738,7 +798,7 @@ export abstract class IntegrationBase<
|
|
|
738
798
|
this.resetRequestExceptionCount('searchMyIssues');
|
|
739
799
|
return { value: result };
|
|
740
800
|
} catch (ex) {
|
|
741
|
-
this.handleProviderException('searchMyIssues', ex, { scope: scope });
|
|
801
|
+
this.handleProviderException('searchMyIssues', ex, { scope: scope, connectionId: connectionId });
|
|
742
802
|
return { error: toError(ex) };
|
|
743
803
|
}
|
|
744
804
|
}
|
|
@@ -858,7 +918,10 @@ export abstract class IntegrationBase<
|
|
|
858
918
|
return undefined;
|
|
859
919
|
}
|
|
860
920
|
|
|
861
|
-
this.handleProviderException('getCurrentAccount', ex, {
|
|
921
|
+
this.handleProviderException('getCurrentAccount', ex, {
|
|
922
|
+
scope: scope,
|
|
923
|
+
connectionId: connectionId,
|
|
924
|
+
});
|
|
862
925
|
|
|
863
926
|
// Invalidate the cache on error, except for auth errors
|
|
864
927
|
if (!(ex instanceof AuthenticationError)) {
|
|
@@ -48,7 +48,7 @@ export abstract class IssuesIntegration<
|
|
|
48
48
|
this.resetRequestExceptionCount('getAccountForResource');
|
|
49
49
|
return { value: account };
|
|
50
50
|
} catch (ex) {
|
|
51
|
-
this.handleProviderException('getAccountForResource', ex);
|
|
51
|
+
this.handleProviderException('getAccountForResource', ex, { connectionId: connectionId });
|
|
52
52
|
return { error: toError(ex) };
|
|
53
53
|
}
|
|
54
54
|
}
|
|
@@ -80,7 +80,7 @@ export abstract class IssuesIntegration<
|
|
|
80
80
|
this.resetRequestExceptionCount('getResourcesForUser');
|
|
81
81
|
return { value: resources };
|
|
82
82
|
} catch (ex) {
|
|
83
|
-
this.handleProviderException('getResourcesForUser', ex);
|
|
83
|
+
this.handleProviderException('getResourcesForUser', ex, { connectionId: connectionId });
|
|
84
84
|
return { error: toError(ex) };
|
|
85
85
|
}
|
|
86
86
|
}
|
|
@@ -108,7 +108,7 @@ export abstract class IssuesIntegration<
|
|
|
108
108
|
this.resetRequestExceptionCount('getProjectsForResources');
|
|
109
109
|
return { value: projects };
|
|
110
110
|
} catch (ex) {
|
|
111
|
-
this.handleProviderException('getProjectsForResources', ex);
|
|
111
|
+
this.handleProviderException('getProjectsForResources', ex, { connectionId: connectionId });
|
|
112
112
|
return { error: toError(ex) };
|
|
113
113
|
}
|
|
114
114
|
}
|
|
@@ -161,7 +161,7 @@ export abstract class IssuesIntegration<
|
|
|
161
161
|
this.resetRequestExceptionCount('getIssuesForProject');
|
|
162
162
|
return { value: issues };
|
|
163
163
|
} catch (ex) {
|
|
164
|
-
this.handleProviderException('getIssuesForProject', ex);
|
|
164
|
+
this.handleProviderException('getIssuesForProject', ex, { connectionId: connectionId });
|
|
165
165
|
return { error: toError(ex) };
|
|
166
166
|
}
|
|
167
167
|
}
|
|
@@ -188,7 +188,7 @@ export abstract class IssuesIntegration<
|
|
|
188
188
|
this.resetRequestExceptionCount('getIssuesForProject');
|
|
189
189
|
return { value: result };
|
|
190
190
|
} catch (ex) {
|
|
191
|
-
this.handleProviderException('getIssuesForProject', ex);
|
|
191
|
+
this.handleProviderException('getIssuesForProject', ex, { connectionId: connectionId });
|
|
192
192
|
return { error: toError(ex) };
|
|
193
193
|
}
|
|
194
194
|
}
|
package/src/utils/date.ts
CHANGED
|
@@ -149,9 +149,12 @@ export function unitThresholdMs(unit: Intl.RelativeTimeFormatUnit): number {
|
|
|
149
149
|
|
|
150
150
|
// fromNow keeps its OWN single-pass loop (not routed through fromNowUnit) — one iteration, no
|
|
151
151
|
// intermediate {unit,value} object allocation on this hot formatting path; fromNowUnit below iterates
|
|
152
|
-
// the SAME `relativeUnitThresholds` array, so the two classifications can't drift apart.
|
|
153
|
-
|
|
154
|
-
|
|
152
|
+
// the SAME `relativeUnitThresholds` array, so the two classifications can't drift apart. `now` defaults
|
|
153
|
+
// to `Date.now()`, like fromNowUnit's — pass it to pin the instant (a caller comparing against its own
|
|
154
|
+
// clock read otherwise sits one unpredictable tick away from the bucket it expects).
|
|
155
|
+
export function fromNow(date: Date | number, short?: boolean, now: Date | number = Date.now()): string {
|
|
156
|
+
const elapsed =
|
|
157
|
+
(typeof date === 'number' ? date : date.getTime()) - (typeof now === 'number' ? now : now.getTime());
|
|
155
158
|
// Guard against invalid dates (e.g. `new Date(<unparseable>)`), since a non-finite value would throw in `Intl.RelativeTimeFormat.format`
|
|
156
159
|
if (!Number.isFinite(elapsed)) return '';
|
|
157
160
|
|