feat(FN-4783): complete Step 2 — add OAuth expiry monitor

Fusion-Task-Id: FN-4783
Fusion-Task-Lineage: 7a02897b-e303-4947-86b2-3dd7a343f653
This commit is contained in:
Fusion (runfusion.ai)
2026-05-16 13:12:51 -07:00
committed by gsxdsm
parent f8c050741c
commit b48f8f1fac
3 changed files with 269 additions and 0 deletions

View File

@@ -0,0 +1,149 @@
import { describe, expect, it, vi } from "vitest";
import { OAuthExpiryMonitor, type AuthStorageLike } from "../oauth-expiry-monitor.js";
function createAuthStorage(initialCredential?: { type?: string; expires?: number }): AuthStorageLike & {
credential: { type?: string; expires?: number } | undefined;
} {
return {
credential: initialCredential,
reload: vi.fn(),
getOAuthProviders: () => [{ id: "openai-codex", name: "OpenAI Codex" }],
get(providerId: string) {
if (providerId !== "openai-codex") {
return undefined;
}
return this.credential;
},
};
}
describe("OAuthExpiryMonitor", () => {
it("fires once when an OAuth credential is expired", async () => {
vi.useFakeTimers();
const authStorage = createAuthStorage({ type: "oauth", expires: Date.now() - 1_000 });
const dispatch = vi.fn(async () => undefined);
const monitor = new OAuthExpiryMonitor({
authStorage,
notificationService: { dispatch } as any,
intervalMs: 100,
clock: () => Date.now(),
});
await monitor.start();
await vi.runOnlyPendingTimersAsync();
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith(
"oauth-token-expired",
expect.objectContaining({
event: "oauth-token-expired",
metadata: expect.objectContaining({
providerId: "openai-codex",
providerName: "OpenAI Codex",
}),
}),
);
monitor.stop();
vi.useRealTimers();
});
it("does not fire for non-expired/non-oauth credentials", async () => {
vi.useFakeTimers();
const dispatch = vi.fn(async () => undefined);
const now = Date.now();
const cases: Array<{ type?: string; expires?: number } | undefined> = [
{ type: "api_key" },
{ type: "oauth" },
{ type: "oauth", expires: now + 60_000 },
undefined,
];
for (const credential of cases) {
const authStorage = createAuthStorage(credential);
const monitor = new OAuthExpiryMonitor({
authStorage,
notificationService: { dispatch } as any,
intervalMs: 100,
clock: () => now,
});
await monitor.start();
await vi.runOnlyPendingTimersAsync();
monitor.stop();
}
expect(dispatch).not.toHaveBeenCalled();
vi.useRealTimers();
});
it("deduplicates dispatches for same provider and expiry", async () => {
vi.useFakeTimers();
const now = Date.now();
const authStorage = createAuthStorage({ type: "oauth", expires: now - 1 });
const dispatch = vi.fn(async () => undefined);
const monitor = new OAuthExpiryMonitor({
authStorage,
notificationService: { dispatch } as any,
intervalMs: 100,
clock: () => now,
});
await monitor.start();
await vi.advanceTimersByTimeAsync(200);
expect(dispatch).toHaveBeenCalledTimes(1);
monitor.stop();
vi.useRealTimers();
});
it("re-fires after credential is replaced with a new expiry that later expires", async () => {
vi.useFakeTimers();
let now = Date.now();
const authStorage = createAuthStorage({ type: "oauth", expires: now - 1 });
const dispatch = vi.fn(async () => undefined);
const monitor = new OAuthExpiryMonitor({
authStorage,
notificationService: { dispatch } as any,
intervalMs: 100,
clock: () => now,
});
await monitor.start();
expect(dispatch).toHaveBeenCalledTimes(1);
authStorage.credential = { type: "oauth", expires: now + 1_000 };
await vi.advanceTimersByTimeAsync(100);
now += 2_000;
await vi.advanceTimersByTimeAsync(100);
expect(dispatch).toHaveBeenCalledTimes(2);
monitor.stop();
vi.useRealTimers();
});
it("stop cancels the interval", async () => {
vi.useFakeTimers();
const now = Date.now();
const authStorage = createAuthStorage({ type: "oauth", expires: now - 1 });
const dispatch = vi.fn(async () => undefined);
const monitor = new OAuthExpiryMonitor({
authStorage,
notificationService: { dispatch } as any,
intervalMs: 100,
clock: () => now,
});
await monitor.start();
monitor.stop();
await vi.advanceTimersByTimeAsync(500);
expect(dispatch).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
});

View File

@@ -6,3 +6,6 @@ export type { WebhookProviderConfig } from "./webhook-provider.js";
export { NotificationService } from "./notification-service.js"; export { NotificationService } from "./notification-service.js";
export type { NotificationServiceOptions } from "./notification-service.js"; 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";

View File

@@ -0,0 +1,117 @@
import type { NotificationPayload } from "@fusion/core";
import { schedulerLog } from "../logger.js";
import type { NotificationService } from "./notification-service.js";
const DEFAULT_INTERVAL_MS = 5 * 60_000;
interface OAuthProviderInfo {
id: string;
name: string;
}
interface OAuthCredential {
type?: string;
expires?: number;
}
export interface AuthStorageLike {
reload?(): void;
getOAuthProviders?(): OAuthProviderInfo[];
get?(providerId: string): OAuthCredential | undefined;
}
export interface OAuthExpiryMonitorOptions {
authStorage: AuthStorageLike;
notificationService: NotificationService;
intervalMs?: number;
clock?: () => number;
warnBeforeMs?: number;
}
export class OAuthExpiryMonitor {
private readonly intervalMs: number;
private readonly clock: () => number;
private readonly warnBeforeMs: number;
private timer: NodeJS.Timeout | null = null;
private readonly dispatchedExpiryKeys = new Set<string>();
constructor(private readonly opts: OAuthExpiryMonitorOptions) {
this.intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
this.clock = opts.clock ?? Date.now;
this.warnBeforeMs = opts.warnBeforeMs ?? 0;
}
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;
}
private async check(): Promise<void> {
this.opts.authStorage.reload?.();
const providers = this.opts.authStorage.getOAuthProviders?.();
if (!providers?.length) {
this.dispatchedExpiryKeys.clear();
return;
}
const now = this.clock();
const activeExpiryKeys = new Set<string>();
for (const provider of providers) {
const credential = this.opts.authStorage.get?.(provider.id);
if (credential?.type !== "oauth" || typeof credential.expires !== "number") {
continue;
}
const expiryKey = `${provider.id}:${credential.expires}`;
activeExpiryKeys.add(expiryKey);
if (now + this.warnBeforeMs < credential.expires) {
continue;
}
if (this.dispatchedExpiryKeys.has(expiryKey)) {
continue;
}
const payload: NotificationPayload = {
event: "oauth-token-expired",
metadata: {
providerId: provider.id,
providerName: provider.name,
expiresAt: new Date(credential.expires).toISOString(),
},
};
try {
await this.opts.notificationService.dispatch("oauth-token-expired", payload);
this.dispatchedExpiryKeys.add(expiryKey);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
schedulerLog.warn(`OAuth expiry notification dispatch failed provider=${provider.id}: ${message}`);
}
}
for (const key of this.dispatchedExpiryKeys) {
if (!activeExpiryKeys.has(key)) {
this.dispatchedExpiryKeys.delete(key);
}
}
}
}