feat(FN-5595): add oauth relogin banner with validity logger

This merge implements an OAuth relogin banner feature (FN-5595) that displays in the dashboard when OAuth tokens expire. The feature includes a new `OAuthReloginBanner` component with styling and tests, an OAuth validity logger in the engine for tracking token state, and corresponding API route inte

Fusion-Task-Id: FN-5595

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Fusion-Task-Id: FN-5595
This commit is contained in:
gsxdsm
2026-05-27 00:39:20 -07:00
parent 0d0d07ded4
commit da34bd06e3
13 changed files with 623 additions and 22 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Dashboard now shows a top-level "Re-login required" banner when a stored OAuth provider credential (Codex, Claude, etc.) has expired, and the engine logs the expired set on startup and once every 24 hours.

View File

@@ -25,6 +25,7 @@ import { CliBinaryInstallBanner } from "./components/CliBinaryInstallBanner";
import { SetupWarningBanner } from "./components/SetupWarningBanner";
import { CapacityRiskBanner } from "./components/CapacityRiskBanner";
import { TestModeBanner } from "./components/TestModeBanner";
import { OAuthReloginBanner } from "./components/OAuthReloginBanner";
import { TaskIdIntegrityBanner } from "./components/TaskIdIntegrityBanner";
import { DbCorruptionBanner } from "./components/DbCorruptionBanner";
import { UpdateAvailableBanner } from "./components/UpdateAvailableBanner";
@@ -1794,7 +1795,12 @@ function AppInner() {
}
/>
{viewMode === "project" && currentProject && (
<TestModeBanner isActive={isTestMode} />
<>
<TestModeBanner isActive={isTestMode} />
<OAuthReloginBanner
onReLogin={(_providerId) => modalManager.openSettings("authentication" as SectionId)}
/>
</>
)}
{viewMode === "project" && currentProject && !nodesOpen && taskView !== "missions" && !modalManager.isPlanningOpen && !sessionBannersHidden && (
<SessionNotificationBanner

View File

@@ -1441,6 +1441,8 @@ export interface AuthProvider {
authenticated: boolean;
/** True when the server currently has an active OAuth login flow for this provider. */
loginInProgress?: boolean;
/** True when an OAuth credential is stored locally but its expires timestamp is in the past — prompt the user to re-login. */
expired?: boolean;
/** True when the redirect cannot reach this dashboard host and the user must paste the URL/code back manually. */
requiresManualCode?: boolean;
/**

View File

@@ -0,0 +1,50 @@
.oauth-relogin-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
margin-bottom: var(--space-md);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-md);
border-inline-start: var(--space-xs) solid var(--color-warning);
background: color-mix(in srgb, var(--color-warning) 18%, transparent);
color: var(--text);
}
.oauth-relogin-banner__content {
display: flex;
align-items: center;
gap: var(--space-sm);
min-width: 0;
}
.oauth-relogin-banner__message {
margin: 0;
line-height: 1.4;
}
.oauth-relogin-banner__actions {
display: flex;
align-items: center;
gap: var(--space-xs);
flex-shrink: 0;
}
.oauth-relogin-banner__dismiss {
color: var(--text-muted);
}
.oauth-relogin-banner__dismiss:hover {
color: var(--text);
}
@media (max-width: 768px) {
.oauth-relogin-banner {
flex-direction: column;
align-items: stretch;
}
.oauth-relogin-banner__actions {
justify-content: flex-end;
}
}

View File

@@ -0,0 +1,129 @@
import { useEffect, useMemo, useState } from "react";
import { AlertTriangle, X } from "lucide-react";
import { fetchAuthStatus } from "../api";
import "./OAuthReloginBanner.css";
const DISMISS_STORAGE_KEY = "fusion:oauth-relogin-dismissed";
function loadDismissedProviderIds(): Set<string> {
if (typeof window === "undefined") {
return new Set();
}
try {
const parsed = JSON.parse(window.localStorage.getItem(DISMISS_STORAGE_KEY) ?? "[]");
if (!Array.isArray(parsed)) {
return new Set();
}
return new Set(parsed.filter((value): value is string => typeof value === "string"));
} catch {
return new Set();
}
}
function persistDismissedProviderIds(providerIds: Set<string>): void {
if (typeof window === "undefined") {
return;
}
window.localStorage.setItem(DISMISS_STORAGE_KEY, JSON.stringify(Array.from(providerIds)));
}
export function OAuthReloginBanner({
onReLogin,
pollIntervalMs,
}: {
onReLogin: (providerId?: string) => void;
pollIntervalMs?: number;
}): JSX.Element | null {
const [expiredProviders, setExpiredProviders] = useState<Array<{ id: string; name: string }>>([]);
const [dismissedProviderIds, setDismissedProviderIds] = useState<Set<string>>(() => loadDismissedProviderIds());
useEffect(() => {
let active = true;
const refreshAuthStatus = async () => {
try {
const { providers } = await fetchAuthStatus();
if (!active) {
return;
}
const nextExpiredProviders = providers
.filter((provider) => provider.type === "oauth" && provider.expired === true)
.map((provider) => ({ id: provider.id, name: provider.name }));
setExpiredProviders(nextExpiredProviders);
setDismissedProviderIds((currentDismissed) => {
const expiredProviderIds = new Set(nextExpiredProviders.map((provider) => provider.id));
const filteredDismissed = new Set(
Array.from(currentDismissed).filter((providerId) => expiredProviderIds.has(providerId)),
);
if (filteredDismissed.size === currentDismissed.size) {
return currentDismissed;
}
persistDismissedProviderIds(filteredDismissed);
return filteredDismissed;
});
} catch {
// Non-blocking banner; ignore transient status fetch failures.
}
};
void refreshAuthStatus();
const interval = window.setInterval(refreshAuthStatus, pollIntervalMs ?? 60 * 60 * 1000);
return () => {
active = false;
window.clearInterval(interval);
};
}, [pollIntervalMs]);
const visibleExpiredProviders = useMemo(
() => expiredProviders.filter((provider) => !dismissedProviderIds.has(provider.id)),
[dismissedProviderIds, expiredProviders],
);
if (visibleExpiredProviders.length === 0) {
return null;
}
const isSingleProvider = visibleExpiredProviders.length === 1;
const providerList = visibleExpiredProviders.map((provider) => provider.name).join(", ");
const handleDismiss = () => {
const nextDismissed = new Set(dismissedProviderIds);
for (const provider of visibleExpiredProviders) {
nextDismissed.add(provider.id);
}
setDismissedProviderIds(nextDismissed);
persistDismissedProviderIds(nextDismissed);
};
return (
<section className="oauth-relogin-banner" role="status" aria-live="polite">
<div className="oauth-relogin-banner__content">
<AlertTriangle aria-hidden="true" />
<p className="oauth-relogin-banner__message">
{isSingleProvider
? `Re-login required: ${providerList}. Your ${providerList} session expired — sign in again to keep agents running.`
: `Re-login required: ${providerList}`}
</p>
</div>
<div className="oauth-relogin-banner__actions">
<button
type="button"
className="btn btn-sm"
onClick={() => onReLogin(isSingleProvider ? visibleExpiredProviders[0]?.id : undefined)}
>
Re-login
</button>
<button
type="button"
className="btn-icon oauth-relogin-banner__dismiss"
aria-label="Dismiss OAuth re-login banner"
onClick={handleDismiss}
>
<X aria-hidden="true" />
</button>
</div>
</section>
);
}

View File

@@ -0,0 +1,147 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { OAuthReloginBanner } from "../OAuthReloginBanner";
import * as api from "../../api";
vi.mock("../../api", () => ({
fetchAuthStatus: vi.fn(),
}));
const mockFetchAuthStatus = vi.mocked(api.fetchAuthStatus);
describe("OAuthReloginBanner", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
window.localStorage.clear();
});
it("renders nothing when no providers are expired", async () => {
mockFetchAuthStatus.mockResolvedValue({
providers: [{ id: "github-copilot", name: "GitHub Copilot", authenticated: true, type: "oauth", expired: false }],
});
const { container } = render(<OAuthReloginBanner onReLogin={vi.fn()} />);
await waitFor(() => {
expect(mockFetchAuthStatus).toHaveBeenCalledTimes(1);
});
expect(container.firstChild).toBeNull();
});
it("renders a banner for one expired oauth provider", async () => {
mockFetchAuthStatus.mockResolvedValue({
providers: [{ id: "claude", name: "Claude", authenticated: false, type: "oauth", expired: true }],
});
render(<OAuthReloginBanner onReLogin={vi.fn()} />);
expect(await screen.findByText(/Re-login required: Claude/i)).toBeInTheDocument();
expect(screen.getByText(/Your Claude session expired/i)).toBeInTheDocument();
});
it("renders a comma-joined list when multiple providers are expired", async () => {
mockFetchAuthStatus.mockResolvedValue({
providers: [
{ id: "claude", name: "Claude", authenticated: false, type: "oauth", expired: true },
{ id: "github-copilot", name: "GitHub Copilot", authenticated: false, type: "oauth", expired: true },
],
});
render(<OAuthReloginBanner onReLogin={vi.fn()} />);
expect(await screen.findByText("Re-login required: Claude, GitHub Copilot")).toBeInTheDocument();
});
it("calls onReLogin with providerId for single and undefined for multi", async () => {
const onReLogin = vi.fn();
mockFetchAuthStatus
.mockResolvedValueOnce({
providers: [{ id: "claude", name: "Claude", authenticated: false, type: "oauth", expired: true }],
})
.mockResolvedValueOnce({
providers: [
{ id: "claude", name: "Claude", authenticated: false, type: "oauth", expired: true },
{ id: "github-copilot", name: "GitHub Copilot", authenticated: false, type: "oauth", expired: true },
],
});
render(<OAuthReloginBanner onReLogin={onReLogin} pollIntervalMs={1_000} />);
fireEvent.click(await screen.findByRole("button", { name: "Re-login" }));
expect(onReLogin).toHaveBeenCalledWith("claude");
await act(async () => {
vi.advanceTimersByTime(1_000);
});
fireEvent.click(await screen.findByRole("button", { name: "Re-login" }));
expect(onReLogin).toHaveBeenLastCalledWith(undefined);
});
it("dismisses banner and stores provider ids in localStorage", async () => {
mockFetchAuthStatus.mockResolvedValue({
providers: [{ id: "claude", name: "Claude", authenticated: false, type: "oauth", expired: true }],
});
const { container } = render(<OAuthReloginBanner onReLogin={vi.fn()} />);
fireEvent.click(await screen.findByRole("button", { name: /dismiss oauth re-login banner/i }));
expect(container.firstChild).toBeNull();
expect(window.localStorage.getItem("fusion:oauth-relogin-dismissed")).toBe(JSON.stringify(["claude"]));
});
it("keeps banner dismissed until provider recovers then expires again", async () => {
mockFetchAuthStatus
.mockResolvedValueOnce({
providers: [{ id: "claude", name: "Claude", authenticated: false, type: "oauth", expired: true }],
})
.mockResolvedValueOnce({
providers: [{ id: "claude", name: "Claude", authenticated: false, type: "oauth", expired: true }],
})
.mockResolvedValueOnce({
providers: [{ id: "claude", name: "Claude", authenticated: true, type: "oauth", expired: false }],
})
.mockResolvedValueOnce({
providers: [{ id: "claude", name: "Claude", authenticated: false, type: "oauth", expired: true }],
});
const { container } = render(<OAuthReloginBanner onReLogin={vi.fn()} pollIntervalMs={1_000} />);
fireEvent.click(await screen.findByRole("button", { name: /dismiss oauth re-login banner/i }));
expect(container.firstChild).toBeNull();
await act(async () => {
vi.advanceTimersByTime(1_000);
});
expect(container.firstChild).toBeNull();
await act(async () => {
vi.advanceTimersByTime(1_000);
});
expect(container.firstChild).toBeNull();
expect(window.localStorage.getItem("fusion:oauth-relogin-dismissed")).toBe(JSON.stringify([]));
await act(async () => {
vi.advanceTimersByTime(1_000);
});
expect(await screen.findByText(/Re-login required: Claude/i)).toBeInTheDocument();
});
it("ignores expired flags on api_key and cli providers", async () => {
mockFetchAuthStatus.mockResolvedValue({
providers: [
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key", expired: true },
{ id: "claude-cli", name: "Anthropic — via Claude CLI", authenticated: false, type: "cli", expired: true },
],
});
const { container } = render(<OAuthReloginBanner onReLogin={vi.fn()} />);
await waitFor(() => {
expect(mockFetchAuthStatus).toHaveBeenCalledTimes(1);
});
expect(container.firstChild).toBeNull();
});
});

View File

@@ -635,7 +635,7 @@ describe("GET /auth/status", () => {
// Structural assertions here are about OAuth + API-key paths only.
const providers = res.body.providers.filter((p: any) => p.id !== "claude-cli" && p.id !== "droid-cli" && p.id !== "cursor-cli" && p.id !== "llama-cpp");
expect(providers).toEqual([
{ id: "github-copilot", name: "GitHub Copilot", authenticated: true, type: "oauth", loginInProgress: false },
{ id: "github-copilot", name: "GitHub Copilot", authenticated: true, type: "oauth", expired: false, loginInProgress: false },
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" },
{ id: "kimi-coding", name: "Kimi", authenticated: false, type: "api_key" },
]);
@@ -657,6 +657,7 @@ describe("GET /auth/status", () => {
name: "GitHub Copilot",
authenticated: true,
type: "oauth",
expired: false,
loginInProgress: false,
});
});
@@ -679,8 +680,8 @@ describe("GET /auth/status", () => {
expect(res.status).toBe(200);
const providers = res.body.providers.filter((p: any) => p.id !== "claude-cli" && p.id !== "droid-cli" && p.id !== "cursor-cli" && p.id !== "llama-cpp");
expect(providers).toEqual([
{ id: "github-copilot", name: "GitHub Copilot", authenticated: true, type: "oauth", loginInProgress: false },
{ id: "openai-codex", name: "OpenAI Codex", authenticated: false, type: "oauth", loginInProgress: false, requiresManualCode: true },
{ id: "github-copilot", name: "GitHub Copilot", authenticated: true, type: "oauth", expired: false, loginInProgress: false },
{ id: "openai-codex", name: "OpenAI Codex", authenticated: false, type: "oauth", expired: false, loginInProgress: false, requiresManualCode: true },
{ id: "openrouter", name: "OpenRouter", authenticated: false, type: "api_key" },
{ id: "kimi-coding", name: "Kimi", authenticated: false, type: "api_key" },
{ id: "acme-extension", name: "Acme Extension", authenticated: true, type: "api_key" },
@@ -727,19 +728,36 @@ describe("GET /auth/status", () => {
expect(res.body.providers[0].authenticated).toBe(false);
});
it("treats expired oauth credentials as unauthenticated", async () => {
(authStorage.hasAuth as ReturnType<typeof vi.fn>).mockReturnValue(true);
(authStorage.get as ReturnType<typeof vi.fn>).mockImplementation((provider: string) =>
provider === "github-copilot"
? { type: "oauth", access: "token", refresh: "refresh", expires: Date.now() - 1_000 }
: undefined,
it("reports oauth expired flag for valid, expired, and missing credentials", async () => {
const now = Date.now();
(authStorage.getOAuthProviders as ReturnType<typeof vi.fn>).mockReturnValue([
{ id: "github-copilot", name: "GitHub Copilot" },
{ id: "claude", name: "Claude" },
{ id: "gemini-oauth", name: "Gemini OAuth" },
]);
(authStorage.hasAuth as ReturnType<typeof vi.fn>).mockImplementation(
(provider: string) => provider !== "gemini-oauth",
);
(authStorage.get as ReturnType<typeof vi.fn>).mockImplementation((provider: string) => {
if (provider === "github-copilot") {
return { type: "oauth", access: "token", refresh: "refresh", expires: now + 60_000 };
}
if (provider === "claude") {
return { type: "oauth", access: "token", refresh: "refresh", expires: now - 1_000 };
}
return undefined;
});
const res = await GET(app, "/api/auth/status");
expect(res.status).toBe(200);
const githubCopilot = res.body.providers.find((p: any) => p.id === "github-copilot");
expect(githubCopilot.authenticated).toBe(false);
const claude = res.body.providers.find((p: any) => p.id === "claude");
const geminiOauth = res.body.providers.find((p: any) => p.id === "gemini-oauth");
expect(githubCopilot).toMatchObject({ authenticated: true, expired: false });
expect(claude).toMatchObject({ authenticated: false, expired: true });
expect(geminiOauth).toMatchObject({ authenticated: false, expired: false });
});
it("reports loginInProgress for oauth providers with active logins", async () => {

View File

@@ -248,17 +248,23 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
name: string;
authenticated: boolean;
type: "oauth" | "api_key" | "cli";
expired?: boolean;
keyHint?: string;
loginInProgress?: boolean;
requiresManualCode?: boolean;
}[] = oauthProviders.map((p) => ({
id: p.id,
name: p.name,
authenticated: storage.hasAuth(p.id) && !isExpiredOauthCredential(p.id, storage),
type: "oauth" as const,
loginInProgress: loginInProgress.has(p.id),
requiresManualCode: getManualCodeConfig(p.id, origin) !== undefined || undefined,
}));
}[] = oauthProviders.map((p) => {
const hasAuth = storage.hasAuth(p.id);
const expired = hasAuth && isExpiredOauthCredential(p.id, storage);
return {
id: p.id,
name: p.name,
authenticated: hasAuth && !expired,
type: "oauth" as const,
expired,
loginInProgress: loginInProgress.has(p.id),
requiresManualCode: getManualCodeConfig(p.id, origin) !== undefined || undefined,
};
});
// Include API-key-backed providers if supported
if (storage.getApiKeyProviders) {

View File

@@ -3,7 +3,7 @@ import { ProjectEngine } from "../project-engine.js";
import { runtimeLog } from "../logger.js";
import { TunnelProcessManager } from "../remote-access/tunnel-process-manager.js";
import { NtfyNotifier } from "../notifier.js";
import { NotificationService, OAuthExpiryMonitor } from "../notification/index.js";
import { NotificationService, OAuthExpiryMonitor, OAuthValidityLogger } from "../notification/index.js";
const mocks = vi.hoisted(() => ({
syncInsightExtractionAutomation: vi.fn(),
@@ -27,6 +27,8 @@ const mocks = vi.hoisted(() => ({
notificationServiceStop: vi.fn(),
oauthExpiryMonitorStart: vi.fn(async () => undefined),
oauthExpiryMonitorStop: vi.fn(),
oauthValidityLoggerStart: vi.fn(async () => undefined),
oauthValidityLoggerStop: vi.fn(),
runtimeConfigurePrMonitoring: vi.fn(),
prHandlerCreateFollowUpTask: vi.fn(async () => undefined),
}));
@@ -98,6 +100,10 @@ vi.mock("../notification/index.js", () => ({
start: mocks.oauthExpiryMonitorStart,
stop: mocks.oauthExpiryMonitorStop,
})),
OAuthValidityLogger: vi.fn().mockImplementation(() => ({
start: mocks.oauthValidityLoggerStart,
stop: mocks.oauthValidityLoggerStop,
})),
}));
vi.mock("../auth-storage.js", () => ({
@@ -261,6 +267,8 @@ beforeEach(() => {
mocks.notificationServiceStop.mockClear();
mocks.oauthExpiryMonitorStart.mockClear();
mocks.oauthExpiryMonitorStop.mockClear();
mocks.oauthValidityLoggerStart.mockClear();
mocks.oauthValidityLoggerStop.mockClear();
mocks.execFile.mockImplementation((
_file: string,

View File

@@ -0,0 +1,152 @@
import { describe, expect, it, vi } from "vitest";
import { OAuthValidityLogger } from "../oauth-validity-logger.js";
import type { AuthStorageLike } from "../oauth-expiry-monitor.js";
function createAuthStorage(providers: Array<{ id: string; name: string }>, credentials: Record<string, any>): AuthStorageLike {
return {
reload: vi.fn(),
getOAuthProviders: () => providers,
get: (providerId: string) => credentials[providerId],
};
}
describe("OAuthValidityLogger", () => {
it("logs one line per expired oauth credential on start", async () => {
vi.useFakeTimers();
const now = Date.now();
const logger = vi.fn();
const authStorage = createAuthStorage(
[
{ id: "openai-codex", name: "OpenAI Codex" },
{ id: "claude", name: "Claude" },
],
{
"openai-codex": { type: "oauth", expires: now - 1_000 },
claude: { type: "oauth", expires: now - 500 },
},
);
const validityLogger = new OAuthValidityLogger({ authStorage, logger, intervalMs: 1_000, clock: () => now });
await validityLogger.start();
expect(logger).toHaveBeenCalledTimes(2);
validityLogger.stop();
vi.useRealTimers();
});
it("logs again on interval without dedupe", async () => {
vi.useFakeTimers();
const now = Date.now();
const logger = vi.fn();
const authStorage = createAuthStorage(
[{ id: "openai-codex", name: "OpenAI Codex" }],
{ "openai-codex": { type: "oauth", expires: now - 1_000 } },
);
const validityLogger = new OAuthValidityLogger({ authStorage, logger, intervalMs: 1_000, clock: () => now });
await validityLogger.start();
await vi.advanceTimersByTimeAsync(1_000);
expect(logger).toHaveBeenCalledTimes(2);
validityLogger.stop();
vi.useRealTimers();
});
it("does not log for valid oauth, api key, or missing expires", async () => {
vi.useFakeTimers();
const now = Date.now();
const logger = vi.fn();
const authStorage = createAuthStorage(
[
{ id: "valid-oauth", name: "Valid OAuth" },
{ id: "api-key-provider", name: "API Key" },
{ id: "missing-expiry", name: "Missing Expiry" },
],
{
"valid-oauth": { type: "oauth", expires: now + 10_000 },
"api-key-provider": { type: "api_key" },
"missing-expiry": { type: "oauth" },
},
);
const validityLogger = new OAuthValidityLogger({ authStorage, logger, intervalMs: 1_000, clock: () => now });
await validityLogger.start();
expect(logger).not.toHaveBeenCalled();
validityLogger.stop();
vi.useRealTimers();
});
it("stop cancels the interval", async () => {
vi.useFakeTimers();
const now = Date.now();
const logger = vi.fn();
const authStorage = createAuthStorage(
[{ id: "openai-codex", name: "OpenAI Codex" }],
{ "openai-codex": { type: "oauth", expires: now - 1_000 } },
);
const validityLogger = new OAuthValidityLogger({ authStorage, logger, intervalMs: 1_000, clock: () => now });
await validityLogger.start();
validityLogger.stop();
await vi.advanceTimersByTimeAsync(5_000);
expect(logger).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
it("continues iterating when one provider throws", async () => {
vi.useFakeTimers();
const now = Date.now();
const logger = vi.fn();
const authStorage: AuthStorageLike = {
reload: vi.fn(),
getOAuthProviders: () => [
{ id: "broken", name: "Broken" },
{ id: "claude", name: "Claude" },
],
get: (providerId: string) => {
if (providerId === "broken") {
throw new Error("boom");
}
return { type: "oauth", expires: now - 100 };
},
};
const validityLogger = new OAuthValidityLogger({ authStorage, logger, intervalMs: 1_000, clock: () => now });
await validityLogger.start();
expect(logger).toHaveBeenCalledTimes(1);
expect(logger).toHaveBeenCalledWith(
"oauth credential expired — provider re-login required",
expect.objectContaining({ providerId: "claude" }),
);
validityLogger.stop();
vi.useRealTimers();
});
it("never includes token material in log metadata", async () => {
vi.useFakeTimers();
const now = Date.now();
const logger = vi.fn();
const authStorage = createAuthStorage(
[{ id: "openai-codex", name: "OpenAI Codex" }],
{
"openai-codex": {
type: "oauth",
expires: now - 1_000,
accessToken: "secret-access",
refreshToken: "secret-refresh",
},
},
);
const validityLogger = new OAuthValidityLogger({ authStorage, logger, intervalMs: 1_000, clock: () => now });
await validityLogger.start();
const [, meta] = logger.mock.calls[0] ?? [];
expect(Object.keys(meta ?? {}).sort()).toEqual(["expiresAt", "providerId", "providerName"]);
validityLogger.stop();
vi.useRealTimers();
});
});

View File

@@ -9,3 +9,5 @@ export type { NotificationServiceOptions } from "./notification-service.js";
export { OAuthExpiryMonitor } from "./oauth-expiry-monitor.js";
export type { AuthStorageLike as OAuthExpiryAuthStorageLike, OAuthExpiryMonitorOptions } from "./oauth-expiry-monitor.js";
export { OAuthValidityLogger } from "./oauth-validity-logger.js";

View File

@@ -0,0 +1,71 @@
import { schedulerLog } from "../logger.js";
import type { AuthStorageLike } from "./oauth-expiry-monitor.js";
const DEFAULT_INTERVAL_MS = 24 * 60 * 60 * 1000;
interface OAuthValidityLoggerOptions {
authStorage: AuthStorageLike;
intervalMs?: number;
clock?: () => number;
logger?: (msg: string, meta?: Record<string, unknown>) => void;
}
export class OAuthValidityLogger {
private readonly intervalMs: number;
private readonly clock: () => number;
private readonly logger: (msg: string, meta?: Record<string, unknown>) => void;
private timer: NodeJS.Timeout | null = null;
constructor(private readonly opts: OAuthValidityLoggerOptions) {
this.intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
this.clock = opts.clock ?? Date.now;
this.logger = opts.logger ?? ((message, meta) => schedulerLog.warn(message, meta));
}
async start(): Promise<void> {
if (this.timer) {
return;
}
await this.check();
this.timer = setInterval(() => {
void this.check();
}, this.intervalMs);
this.timer.unref?.();
}
stop(): void {
if (!this.timer) {
return;
}
clearInterval(this.timer);
this.timer = null;
}
async check(): Promise<void> {
this.opts.authStorage.reload?.();
const providers = this.opts.authStorage.getOAuthProviders?.() ?? [];
const now = this.clock();
for (const provider of providers) {
try {
const credential = this.opts.authStorage.get?.(provider.id);
if (credential?.type !== "oauth" || typeof credential.expires !== "number") {
continue;
}
if (credential.expires > now) {
continue;
}
this.logger("oauth credential expired — provider re-login required", {
providerId: provider.id,
providerName: provider.name,
expiresAt: new Date(credential.expires).toISOString(),
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
schedulerLog.warn(`OAuth validity logger failed for provider=${provider.id}: ${message}`);
}
}
}
}

View File

@@ -19,7 +19,7 @@ import type { ProjectRuntimeConfig } from "./project-runtime.js";
import { PrMonitor } from "./pr-monitor.js";
import { PrCommentHandler } from "./pr-comment-handler.js";
import { NtfyNotifier } from "./notifier.js";
import { NotificationService, OAuthExpiryMonitor } from "./notification/index.js";
import { NotificationService, OAuthExpiryMonitor, OAuthValidityLogger } from "./notification/index.js";
import type { NotificationChatStore } from "./notification/notification-service.js";
import { GridlockDetector } from "./gridlock-detector.js";
import { createFusionAuthStorage } from "./auth-storage.js";
@@ -177,6 +177,7 @@ export class ProjectEngine {
private notifier?: NtfyNotifier;
private notificationService?: NotificationService;
private oauthExpiryMonitor?: OAuthExpiryMonitor;
private oauthValidityLogger?: OAuthValidityLogger;
private gridlockDetector?: GridlockDetector;
private cronRunner?: CronRunner;
private automationStore?: AutomationStoreType;
@@ -353,11 +354,14 @@ export class ProjectEngine {
agentNameResolver,
});
await this.notificationService.start();
const authStorage = createFusionAuthStorage();
this.oauthExpiryMonitor = new OAuthExpiryMonitor({
authStorage: createFusionAuthStorage(),
authStorage,
notificationService: this.notificationService,
});
await this.oauthExpiryMonitor.start();
this.oauthValidityLogger = new OAuthValidityLogger({ authStorage });
await this.oauthValidityLogger.start();
// Backward-compatibility shim for gridlock notifications.
this.notifier = new NtfyNotifier(
@@ -581,6 +585,7 @@ export class ProjectEngine {
// Stop auxiliary subsystems
this.oauthExpiryMonitor?.stop();
this.oauthValidityLogger?.stop();
this.notificationService?.stop();
this.notifier?.stop();
this.gridlockDetector?.stop();