FN-7821: refresh-then-recheck OAuth credentials before dispatching expiry notifications
Aligns OAuthExpiryMonitor's ntfy push notifications with the /api/auth/status refresh-then-recheck logic that drives the in-app OAuthReloginBanner, so providers that silently auto-refresh (e.g. GitHub Copilot's ephemeral token) no longer trigger false "OAuth token expired" pushes with no matching banner. - OAuthExpiryMonitor.check() now performs a best-effort authStorage.getApiKey() refresh and reloads/re-resolves the credential before dispatching oauth-token-expired, instead of relying solely on the stored expiry timestamp - resolveEffectiveOAuthCredential() now also guards against non-finite expires values in addition to non-numeric ones - Updated docs/dashboard-guide.md and docs/settings-reference.md to describe the refresh-then-recheck behavior generically (not just Claude/Anthropic) and documented the FN-7821 fix in FNXC provenance comments - Added regression tests covering the refresh-then-recheck flow in oauth-expiry-monitor.test.ts - Added a patch changeset describing the fix for release notes Files changed: .changeset/fn-7821-oauth-expiry-notification-banner-consistency.md | 7 + docs/dashboard-guide.md | 6 +- docs/settings-reference.md | 6 +- packages/engine/src/notification/__tests__/oauth-expiry-monitor.test.ts | 146 ++++++++++++++++++++- packages/engine/src/notification/oauth-expiry-monitor.ts | 48 ++++++- 5 files changed, 199 insertions(+), 14 deletions(-) Fusion-Task-Id: FN-7821 Fusion-Task-Lineage: 5954592c-adda-4fd4-b205-265860eddf3d Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Stop false "OAuth token expired" push notifications for providers that silently refresh (e.g. GitHub Copilot).
|
||||
category: fix
|
||||
dev: OAuthExpiryMonitor.check() now attempts a best-effort getApiKey refresh and re-checks the credential before dispatching oauth-token-expired, mirroring /api/auth/status's refresh-then-recheck that drives OAuthReloginBanner. The FN-7574 start-refresher-first ordering only covered the startup check; short-lived auto-refreshing tokens still fired on interval ticks with no matching banner.
|
||||
@@ -766,11 +766,11 @@ Branch names are dynamic from merge/audit payloads; the banner is not hardcoded
|
||||
|
||||
The global OAuth re-login banner clears a provider row immediately after that provider successfully re-authenticates (from Settings → Authentication or Model Onboarding), instead of waiting for the next `GET /auth/status` poll interval.
|
||||
|
||||
For Claude/Anthropic OAuth credentials, the same `/auth/status` poll also attempts an automatic refresh when the stored OAuth credential has a refresh token and the access token is expired or within the refresh buffer. Anthropic banner state is keyed to `anthropic-subscription` (including legacy Anthropic OAuth rows), not Claude CLI state. When that refresh succeeds, the banner clears for the subscription provider without manual re-login and without waiting for a separate model request.
|
||||
For OAuth credentials, the same `/auth/status` poll also attempts an automatic refresh when the stored credential has a refresh token and the access token is expired or within the refresh buffer. Anthropic banner state is keyed to `anthropic-subscription` (including legacy Anthropic OAuth rows), not Claude CLI state. When that refresh succeeds, the banner clears for the provider without manual re-login and without waiting for a separate model request.
|
||||
|
||||
If the OAuth credential has no refresh token, the refresh request fails, or the provider is not Anthropic, the provider stays expired and the banner remains visible. Re-authenticate with manual re-login from **Settings → Authentication** or Model Onboarding. On Fusion desktop, OAuth login URLs open in the operating system browser rather than an in-app Electron child window; the Settings/Onboarding UI keeps polling until the provider authenticates or the login truly stops.
|
||||
If the OAuth credential has no refresh token or the refresh request fails/leaves the credential expired, the provider stays expired and the banner remains visible. Re-authenticate with manual re-login from **Settings → Authentication** or Model Onboarding. On Fusion desktop, OAuth login URLs open in the operating system browser rather than an in-app Electron child window; the Settings/Onboarding UI keeps polling until the provider authenticates or the login truly stops.
|
||||
|
||||
<!-- FNXC:ProviderAuth 2026-07-05-00:00: FN-7574 — the status route and the engine's OAuthExpiryMonitor previously diverged: a subscription OAuth credential with a past or missing/non-numeric `expires` could still read authenticated:true from /api/auth/status even though the monitor had already fired an oauth-token-expired notification for it. `/api/auth/status` now treats any OAuth credential lacking a usable numeric `expires` — and any credential whose numeric `expires` is in the past and cannot be refreshed — as expired:true/authenticated:false, for both the legacy anthropic-row and separated anthropic-subscription-row storage permutations. Settings → Authentication and the global re-login banner both read this corrected status, so an expired-and-unrefreshable subscription now consistently shows as not connected everywhere. -->
|
||||
<!-- FNXC:ProviderAuth 2026-07-05-00:00: FN-7574 — the status route and the engine's OAuthExpiryMonitor previously diverged: a subscription OAuth credential with a past or missing/non-numeric `expires` could still read authenticated:true from /api/auth/status even though the monitor had already fired an oauth-token-expired notification for it. `/api/auth/status` now treats any OAuth credential lacking a usable numeric `expires` — and any credential whose numeric `expires` is in the past and cannot be refreshed — as expired:true/authenticated:false, for both the legacy anthropic-row and separated anthropic-subscription-row storage permutations. Settings → Authentication and the global re-login banner both read this corrected status, so an expired-and-unrefreshable subscription now consistently shows as not connected everywhere. FNXC:ProviderAuth 2026-07-11-18:00: FN-7821 — OAuthExpiryMonitor now mirrors this refresh-then-recheck before dispatching oauth-token-expired, preventing false provider pushes (notably github-copilot ephemeral tokens) when the banner-driving status route would refresh and report healthy. -->
|
||||
|
||||
<!-- FNXC:ClaudeOAuth 2026-07-05-00:00: FN-7574 — beyond the reactive best-effort refresh on the /auth/status poll, the engine now runs an independent background OAuthRefreshScheduler (packages/engine/src/notification/oauth-refresh-scheduler.ts) on a 5-minute interval, guarded by the same `skipNotifier` option as OAuthExpiryMonitor. It proactively calls the existing refresh-if-due logic in auth-storage.ts for every known OAuth provider (plus the anthropic-subscription alias) so a healthy subscription's access token is renewed well ahead of expiry via the stored refresh token, instead of only refreshing reactively when something happens to request a runtime API key. Only providerId/providerName/expiresAt are ever logged — never token material. -->
|
||||
|
||||
|
||||
@@ -777,13 +777,13 @@ Recovery entrypoints in the dashboard:
|
||||
|
||||
### OAuth credential refresh
|
||||
|
||||
Fusion automatically refreshes Claude/Anthropic OAuth credentials before reporting auth status when the stored OAuth credential includes a refresh token and the access token is expired or within the refresh buffer. For Anthropic, this status path is the `anthropic-subscription` surface (including legacy `anthropic` OAuth rows), not Claude CLI state. A successful refresh updates auth storage and prevents `oauth-token-expired` notifications or startup warnings for that provider, so users usually do not need manual re-login after the initial Claude OAuth login.
|
||||
Fusion automatically refreshes OAuth credentials before reporting auth status when the stored credential includes a refresh token and the access token is expired or within the refresh buffer. For Anthropic, this status path is the `anthropic-subscription` surface (including legacy `anthropic` OAuth rows), not Claude CLI state. A successful refresh updates auth storage and prevents `oauth-token-expired` notifications or startup warnings for that provider, so users usually do not need manual re-login after the initial OAuth login.
|
||||
|
||||
<!-- FNXC:ClaudeOAuth 2026-07-05-00:00: FN-7574 — the proactive-refresh buffer was widened from 60s to 5 minutes (`OAUTH_REFRESH_BUFFER_MS` in packages/engine/src/auth-storage.ts) so a credential nearing expiry is treated as due-for-refresh earlier, without needlessly refreshing well-valid tokens. An engine-side background `OAuthRefreshScheduler` also polls every 5 minutes and reuses this same refresh-if-due logic against every known OAuth provider (plus the anthropic-subscription alias), so healthy subscriptions renew ahead of expiry even if nothing else happens to request a runtime API key in that window. In-flight refresh dedupe and the 30s post-failure cooldown (`OAUTH_REFRESH_FAILURE_COOLDOWN_MS`) still apply. -->
|
||||
|
||||
Manual re-login is still required when no refresh token is stored, the refresh request fails, or the expired OAuth credential belongs to a non-Anthropic provider. In those cases the credential remains expired, `oauth-token-expired` notifications/startup warnings may fire subject to their 12-hour provider throttle, and users should re-authenticate from **Settings → Authentication** or Model Onboarding. The top-level dashboard re-login banner suppresses only the urgent **Anthropic Subscription** entry when **Anthropic API Key** or **Anthropic — via Claude CLI** is already authenticated, so the banner does not imply all Anthropic agent execution is blocked; Settings still shows the subscription OAuth state as expired/not connected until it is refreshed or re-logged-in.
|
||||
Manual re-login is still required when no refresh token is stored or the refresh request fails/leaves the credential expired. In those cases the credential remains expired, `oauth-token-expired` notifications/startup warnings may fire subject to their 12-hour provider throttle, and users should re-authenticate from **Settings → Authentication** or Model Onboarding. The top-level dashboard re-login banner suppresses only the urgent **Anthropic Subscription** entry when **Anthropic API Key** or **Anthropic — via Claude CLI** is already authenticated, so the banner does not imply all Anthropic agent execution is blocked; Settings still shows the subscription OAuth state as expired/not connected until it is refreshed or re-logged-in.
|
||||
|
||||
<!-- FNXC:ProviderAuth 2026-07-05-00:00: FN-7574 — `/api/auth/status` and the engine's OAuthExpiryMonitor previously diverged on what counted as "expired": an oauth-typed credential with a past or missing/non-numeric `expires` could still read authenticated:true from the status route, even after the monitor's oauth-token-expired notification fired. The status route now fails safe: any OAuth credential lacking a usable numeric `expires`, or whose numeric `expires` is in the past and cannot be refreshed, reports expired:true/authenticated:false for both the legacy anthropic-row and separated anthropic-subscription-row storage permutations. -->
|
||||
<!-- FNXC:ProviderAuth 2026-07-05-00:00: FN-7574 — `/api/auth/status` and the engine's OAuthExpiryMonitor previously diverged on what counted as "expired": an oauth-typed credential with a past or missing/non-numeric `expires` could still read authenticated:true from the status route, even after the monitor's oauth-token-expired notification fired. The status route now fails safe: any OAuth credential lacking a usable numeric `expires`, or whose numeric `expires` is in the past and cannot be refreshed, reports expired:true/authenticated:false for both the legacy anthropic-row and separated anthropic-subscription-row storage permutations. FNXC:ProviderAuth 2026-07-11-18:00: FN-7821 — OAuthExpiryMonitor also refreshes-then-rechecks before dispatching oauth-token-expired, so a silently refreshed provider such as github-copilot does not produce a push without a matching in-app re-login banner. -->
|
||||
|
||||
### Anthropic API-key authentication
|
||||
|
||||
|
||||
@@ -13,8 +13,10 @@ function createStatePath(): string {
|
||||
return join(dir, "oauth-alert-state.json");
|
||||
}
|
||||
|
||||
function createAuthStorage(initialCredential?: { type?: string; expires?: number }): AuthStorageLike & {
|
||||
credential: { type?: string; expires?: number } | undefined;
|
||||
type TestCredential = { type?: string; expires?: number };
|
||||
|
||||
function createAuthStorage(initialCredential?: TestCredential): AuthStorageLike & {
|
||||
credential: TestCredential | undefined;
|
||||
} {
|
||||
return {
|
||||
credential: initialCredential,
|
||||
@@ -68,6 +70,110 @@ describe("OAuthExpiryMonitor", () => {
|
||||
monitor.stop();
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:ProviderAuth 2026-07-11-18:00:
|
||||
Regression coverage for FN-7821: GitHub Copilot's stored OAuth access token is intentionally short-lived and can look expired on an OAuthExpiryMonitor interval tick even though getApiKey() can silently refresh it. The monitor must attempt that refresh and re-check before dispatching so ntfy and OAuthReloginBanner do not disagree.
|
||||
*/
|
||||
it("does not fire for github-copilot when getApiKey refreshes the expired credential", async () => {
|
||||
vi.useFakeTimers();
|
||||
const now = Date.now();
|
||||
let credential: TestCredential | undefined = { type: "oauth", expires: now - 1_000 };
|
||||
const getApiKey = vi.fn(async () => {
|
||||
credential = { type: "oauth", expires: now + 60_000 };
|
||||
return "opaque-github-copilot-access-token";
|
||||
});
|
||||
const dispatch = vi.fn(async () => undefined);
|
||||
const authStorage: AuthStorageLike = {
|
||||
reload: vi.fn(),
|
||||
getOAuthProviders: () => [{ id: "github-copilot", name: "GitHub Copilot" }],
|
||||
get: (providerId: string) => providerId === "github-copilot" ? credential : undefined,
|
||||
getApiKey,
|
||||
};
|
||||
|
||||
const monitor = new OAuthExpiryMonitor({
|
||||
authStorage,
|
||||
notificationService: { dispatch } as any,
|
||||
intervalMs: 100,
|
||||
clock: () => now,
|
||||
alertState: new OAuthAlertStateStore({ statePath: createStatePath(), clock: () => now }),
|
||||
});
|
||||
|
||||
await monitor.start();
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(getApiKey).toHaveBeenCalledWith("github-copilot");
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
monitor.stop();
|
||||
});
|
||||
|
||||
it("still fires exactly once for github-copilot when refresh throws and never logs token material in metadata", async () => {
|
||||
vi.useFakeTimers();
|
||||
const now = Date.now();
|
||||
const getApiKey = vi.fn(async () => {
|
||||
throw new Error("refresh failed");
|
||||
});
|
||||
const dispatch = vi.fn(async () => undefined);
|
||||
const authStorage: AuthStorageLike = {
|
||||
reload: vi.fn(),
|
||||
getOAuthProviders: () => [{ id: "github-copilot", name: "GitHub Copilot" }],
|
||||
get: (providerId: string) => providerId === "github-copilot"
|
||||
? { type: "oauth", expires: now - 1_000 }
|
||||
: undefined,
|
||||
getApiKey,
|
||||
};
|
||||
|
||||
const monitor = new OAuthExpiryMonitor({
|
||||
authStorage,
|
||||
notificationService: { dispatch } as any,
|
||||
intervalMs: 100,
|
||||
clock: () => now,
|
||||
alertState: new OAuthAlertStateStore({ statePath: createStatePath(), clock: () => now }),
|
||||
});
|
||||
|
||||
await monitor.start();
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(getApiKey).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
"oauth-token-expired",
|
||||
expect.objectContaining({
|
||||
metadata: {
|
||||
providerId: "github-copilot",
|
||||
providerName: "GitHub Copilot",
|
||||
expiresAt: new Date(now - 1_000).toISOString(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(JSON.stringify(dispatch.mock.calls)).not.toContain("opaque-github-copilot-access-token");
|
||||
monitor.stop();
|
||||
});
|
||||
|
||||
it("still fires for openai-codex when refresh leaves the credential expired", async () => {
|
||||
vi.useFakeTimers();
|
||||
const now = Date.now();
|
||||
const authStorage = createAuthStorage({ type: "oauth", expires: now - 1_000 });
|
||||
const getApiKey = vi.fn(async () => "opaque-openai-codex-access-token");
|
||||
authStorage.getApiKey = getApiKey;
|
||||
const dispatch = vi.fn(async () => undefined);
|
||||
|
||||
const monitor = new OAuthExpiryMonitor({
|
||||
authStorage,
|
||||
notificationService: { dispatch } as any,
|
||||
intervalMs: 100,
|
||||
clock: () => now,
|
||||
alertState: new OAuthAlertStateStore({ statePath: createStatePath(), clock: () => now }),
|
||||
});
|
||||
|
||||
await monitor.start();
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(getApiKey).toHaveBeenCalledWith("openai-codex");
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(JSON.stringify(dispatch.mock.calls)).not.toContain("opaque-openai-codex-access-token");
|
||||
monitor.stop();
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:ClaudeOAuth 2026-07-08-20:55:
|
||||
Regression: getOAuthProviders() only yields base `anthropic`, and get("anthropic") can
|
||||
@@ -109,6 +215,41 @@ describe("OAuthExpiryMonitor", () => {
|
||||
monitor.stop();
|
||||
});
|
||||
|
||||
it("does not fire for anthropic when getApiKey refreshes the effective subscription alias", async () => {
|
||||
vi.useFakeTimers();
|
||||
const now = Date.now();
|
||||
const dispatch = vi.fn(async () => undefined);
|
||||
const credentials: Record<string, TestCredential | undefined> = {
|
||||
anthropic: { type: "oauth", expires: now - 60 * 24 * 60 * 60 * 1000 },
|
||||
"anthropic-subscription": { type: "oauth", expires: now - 1_000 },
|
||||
};
|
||||
const getApiKey = vi.fn(async () => {
|
||||
credentials["anthropic-subscription"] = { type: "oauth", expires: now + 5 * 60 * 60 * 1000 };
|
||||
return "opaque-anthropic-subscription-token";
|
||||
});
|
||||
const authStorage: AuthStorageLike = {
|
||||
reload: vi.fn(),
|
||||
getOAuthProviders: () => [{ id: "anthropic", name: "Anthropic" }],
|
||||
get: (providerId: string) => credentials[providerId],
|
||||
getApiKey,
|
||||
};
|
||||
|
||||
const monitor = new OAuthExpiryMonitor({
|
||||
authStorage,
|
||||
notificationService: { dispatch } as any,
|
||||
intervalMs: 100,
|
||||
clock: () => now,
|
||||
alertState: new OAuthAlertStateStore({ statePath: createStatePath(), clock: () => now }),
|
||||
});
|
||||
|
||||
await monitor.start();
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
|
||||
expect(getApiKey).toHaveBeenCalledWith("anthropic");
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
monitor.stop();
|
||||
});
|
||||
|
||||
it("still fires for anthropic when both the legacy row and the subscription alias are expired", async () => {
|
||||
vi.useFakeTimers();
|
||||
const now = Date.now();
|
||||
@@ -154,6 +295,7 @@ describe("OAuthExpiryMonitor", () => {
|
||||
const cases: Array<{ type?: string; expires?: number } | undefined> = [
|
||||
{ type: "api_key" },
|
||||
{ type: "oauth" },
|
||||
{ type: "oauth", expires: Number.NaN },
|
||||
{ type: "oauth", expires: now + 60_000 },
|
||||
undefined,
|
||||
];
|
||||
|
||||
@@ -23,11 +23,15 @@ export interface AuthStorageLike {
|
||||
reload?(): void;
|
||||
getOAuthProviders?(): OAuthProviderInfo[];
|
||||
get?(providerId: string): OAuthCredential | undefined;
|
||||
getApiKey?(providerId: string): Promise<string | null | undefined> | string | null | undefined;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ClaudeOAuth 2026-07-08-20:55:
|
||||
`getOAuthProviders()` is NOT aliased by the engine auth-storage proxy, so it only yields the base id `anthropic`. But the Anthropic subscription token the runtime actually uses/refreshes lives under `anthropic-subscription`, while `get("anthropic")` can still return a STALE legacy row (e.g. a months-old credential in ~/.pi/agent/auth.json). Evaluating `get("anthropic")` alone made the expiry monitor and validity logger fire a false "Anthropic OAuth expired" alert even though the subscription token had refreshed successfully. Resolve the FRESHEST of the two aliased ids so a live subscription token suppresses the false alert — mirroring the refresh scheduler's alias handling (getRefreshCandidateIds in oauth-refresh-scheduler.ts).
|
||||
|
||||
FNXC:ClaudeOAuth 2026-07-11-18:00:
|
||||
OAuthExpiryMonitor must refresh-then-recheck before firing `oauth-token-expired` so ntfy observes the same manual re-login truth as `/api/auth/status`, which already calls `getApiKey()` and recomputes expiry for OAuthReloginBanner. The FN-7574 start-refresher-before-monitor ordering only protected the startup check; short-lived auto-refreshing credentials such as GitHub Copilot's ephemeral token can still expire between interval ticks and silently refresh moments later, so dispatching from the stored timestamp alone creates false pushes with no matching banner.
|
||||
*/
|
||||
export function resolveEffectiveOAuthCredential(
|
||||
authStorage: AuthStorageLike,
|
||||
@@ -39,7 +43,7 @@ export function resolveEffectiveOAuthCredential(
|
||||
}
|
||||
const subscription = authStorage.get?.(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID);
|
||||
const candidates = [direct, subscription].filter(
|
||||
(c): c is OAuthCredential => c?.type === "oauth" && typeof c.expires === "number",
|
||||
(c): c is OAuthCredential => c?.type === "oauth" && typeof c.expires === "number" && Number.isFinite(c.expires),
|
||||
);
|
||||
if (candidates.length === 0) {
|
||||
return direct;
|
||||
@@ -109,22 +113,54 @@ export class OAuthExpiryMonitor {
|
||||
const activeExpiryKeys = new Set<string>();
|
||||
|
||||
for (const provider of providers) {
|
||||
const credential = resolveEffectiveOAuthCredential(this.opts.authStorage, provider.id);
|
||||
if (credential?.type !== "oauth" || typeof credential.expires !== "number") {
|
||||
let credential = resolveEffectiveOAuthCredential(this.opts.authStorage, provider.id);
|
||||
if (credential?.type !== "oauth" || typeof credential.expires !== "number" || !Number.isFinite(credential.expires)) {
|
||||
this.alertState.clear([provider.id]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const expiryKey = `${provider.id}:${credential.expires}`;
|
||||
activeExpiryKeys.add(expiryKey);
|
||||
|
||||
let expiryKey = `${provider.id}:${credential.expires}`;
|
||||
if (now + this.warnBeforeMs < credential.expires) {
|
||||
activeExpiryKeys.add(expiryKey);
|
||||
continue;
|
||||
}
|
||||
if (this.dispatchedExpiryKeys.has(expiryKey)) {
|
||||
activeExpiryKeys.add(expiryKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.opts.authStorage.getApiKey) {
|
||||
try {
|
||||
/*
|
||||
FNXC:ClaudeOAuth 2026-07-11-18:00:
|
||||
The expiry monitor must reuse authStorage.getApiKey() as the single refresh side effect and then reload/re-resolve the effective credential before notifying. This keeps `oauth-token-expired` aligned with the banner-driving `/api/auth/status` route without duplicating provider-specific token refresh code or logging token material.
|
||||
*/
|
||||
await this.opts.authStorage.getApiKey(provider.id);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
schedulerLog.warn(`OAuth expiry refresh-before-notify failed provider=${provider.id}: ${message}`);
|
||||
}
|
||||
|
||||
this.opts.authStorage.reload?.();
|
||||
credential = resolveEffectiveOAuthCredential(this.opts.authStorage, provider.id);
|
||||
if (credential?.type !== "oauth" || typeof credential.expires !== "number" || !Number.isFinite(credential.expires)) {
|
||||
this.alertState.clear([provider.id]);
|
||||
continue;
|
||||
}
|
||||
|
||||
expiryKey = `${provider.id}:${credential.expires}`;
|
||||
if (now + this.warnBeforeMs < credential.expires) {
|
||||
activeExpiryKeys.add(expiryKey);
|
||||
continue;
|
||||
}
|
||||
if (this.dispatchedExpiryKeys.has(expiryKey)) {
|
||||
activeExpiryKeys.add(expiryKey);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
activeExpiryKeys.add(expiryKey);
|
||||
|
||||
const previousNotificationAt = this.alertState.getLastAlertAt(provider.id);
|
||||
if (
|
||||
typeof previousNotificationAt === "number" &&
|
||||
|
||||
Reference in New Issue
Block a user