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:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
71
packages/engine/src/notification/oauth-validity-logger.ts
Normal file
71
packages/engine/src/notification/oauth-validity-logger.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user