mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Merge pull request #795 from igormorais123/feat/provider-expiration-tracking
feat(domain): add provider expiration tracking with proactive alerts
This commit is contained in:
259
src/domain/providerExpiration.ts
Normal file
259
src/domain/providerExpiration.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Provider Expiration Tracking
|
||||
*
|
||||
* Tracks OAuth token, subscription, and API credit expiration dates
|
||||
* per provider connection. Provides proactive alerts before expiration
|
||||
* so operators can re-authenticate or renew before failures occur.
|
||||
*
|
||||
* Prevents the common failure mode where an OAuth token or subscription
|
||||
* expires silently and requests start failing with 401/402 errors.
|
||||
*/
|
||||
|
||||
/** Types of expiration events */
|
||||
export type ExpiryType = "oauth_token" | "subscription" | "api_credits" | "free_tier_reset";
|
||||
|
||||
/** Status derived from expiration date */
|
||||
export type ExpiryStatus = "active" | "expiring_soon" | "expired" | "unknown";
|
||||
|
||||
/** Tracked expiration for a provider connection */
|
||||
export interface ProviderExpiration {
|
||||
/** Connection ID (matches ProviderConnection.id) */
|
||||
connectionId: string;
|
||||
/** Provider name (e.g., "claude", "codex", "gemini-cli") */
|
||||
provider: string;
|
||||
/** Human-readable connection name */
|
||||
connectionName: string;
|
||||
/** ISO date when the credential expires (null = unknown) */
|
||||
expiresAt: string | null;
|
||||
/** Type of expiration */
|
||||
expiryType: ExpiryType;
|
||||
/** Days before expiry to trigger alert (default: 7) */
|
||||
alertDays: number;
|
||||
/** ISO date of last verification */
|
||||
lastChecked: string;
|
||||
/** Current status */
|
||||
status: ExpiryStatus;
|
||||
/** Optional note (e.g., "Membership expired, needs re-auth on dashboard") */
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
/** Summary of provider expiration health */
|
||||
export interface ExpirationSummary {
|
||||
total: number;
|
||||
active: number;
|
||||
expiringSoon: number;
|
||||
expired: number;
|
||||
unknown: number;
|
||||
nextExpiration: ProviderExpiration | null;
|
||||
}
|
||||
|
||||
// ── In-memory store ──────────────────────────────────────────────────────────
|
||||
// In production, this would be persisted to SQLite alongside other domain state.
|
||||
// Using in-memory Map for the initial implementation.
|
||||
|
||||
const expirations = new Map<string, ProviderExpiration>();
|
||||
|
||||
/**
|
||||
* Calculate expiry status from an expiration date.
|
||||
*/
|
||||
function calculateStatus(expiresAt: string | null, alertDays: number): ExpiryStatus {
|
||||
if (!expiresAt) return "unknown";
|
||||
|
||||
const now = new Date();
|
||||
const expiry = new Date(expiresAt);
|
||||
|
||||
if (isNaN(expiry.getTime())) return "unknown";
|
||||
if (expiry <= now) return "expired";
|
||||
|
||||
const daysUntilExpiry = (expiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
|
||||
if (daysUntilExpiry <= alertDays) return "expiring_soon";
|
||||
|
||||
return "active";
|
||||
}
|
||||
|
||||
/**
|
||||
* Register or update an expiration tracking entry.
|
||||
*/
|
||||
export function setExpiration(
|
||||
connectionId: string,
|
||||
provider: string,
|
||||
connectionName: string,
|
||||
expiresAt: string | null,
|
||||
expiryType: ExpiryType,
|
||||
options?: { alertDays?: number; note?: string | null }
|
||||
): ProviderExpiration {
|
||||
const alertDays = options?.alertDays ?? 7;
|
||||
const status = calculateStatus(expiresAt, alertDays);
|
||||
|
||||
const entry: ProviderExpiration = {
|
||||
connectionId,
|
||||
provider,
|
||||
connectionName,
|
||||
expiresAt,
|
||||
expiryType,
|
||||
alertDays,
|
||||
lastChecked: new Date().toISOString(),
|
||||
status,
|
||||
note: options?.note ?? null,
|
||||
};
|
||||
|
||||
expirations.set(connectionId, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get expiration info for a specific connection.
|
||||
*/
|
||||
export function getExpiration(connectionId: string): ProviderExpiration | null {
|
||||
const entry = expirations.get(connectionId);
|
||||
if (!entry) return null;
|
||||
|
||||
// Recalculate status (may have changed since last check)
|
||||
entry.status = calculateStatus(entry.expiresAt, entry.alertDays);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tracked expirations, with status recalculated.
|
||||
*/
|
||||
export function getAllExpirations(): ProviderExpiration[] {
|
||||
const result: ProviderExpiration[] = [];
|
||||
for (const entry of expirations.values()) {
|
||||
entry.status = calculateStatus(entry.expiresAt, entry.alertDays);
|
||||
result.push(entry);
|
||||
}
|
||||
return result.sort((a, b) => {
|
||||
// Sort: expired first, then expiring_soon, then active, then unknown
|
||||
const order: Record<ExpiryStatus, number> = {
|
||||
expired: 0,
|
||||
expiring_soon: 1,
|
||||
active: 2,
|
||||
unknown: 3,
|
||||
};
|
||||
return (order[a.status] ?? 4) - (order[b.status] ?? 4);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connections that are expired or expiring soon.
|
||||
*/
|
||||
export function getExpiringSoon(): ProviderExpiration[] {
|
||||
return getAllExpirations().filter(
|
||||
(e) => e.status === "expired" || e.status === "expiring_soon"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a summary of expiration health across all tracked connections.
|
||||
*/
|
||||
export function getExpirationSummary(): ExpirationSummary {
|
||||
const all = getAllExpirations();
|
||||
|
||||
const summary: ExpirationSummary = {
|
||||
total: all.length,
|
||||
active: 0,
|
||||
expiringSoon: 0,
|
||||
expired: 0,
|
||||
unknown: 0,
|
||||
nextExpiration: null,
|
||||
};
|
||||
|
||||
let nearestMs = Infinity;
|
||||
|
||||
for (const entry of all) {
|
||||
switch (entry.status) {
|
||||
case "active":
|
||||
summary.active++;
|
||||
break;
|
||||
case "expiring_soon":
|
||||
summary.expiringSoon++;
|
||||
break;
|
||||
case "expired":
|
||||
summary.expired++;
|
||||
break;
|
||||
case "unknown":
|
||||
summary.unknown++;
|
||||
break;
|
||||
}
|
||||
|
||||
// Track nearest expiration
|
||||
if (entry.expiresAt) {
|
||||
const ms = new Date(entry.expiresAt).getTime() - Date.now();
|
||||
if (ms > 0 && ms < nearestMs) {
|
||||
nearestMs = ms;
|
||||
summary.nextExpiration = entry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove expiration tracking for a connection.
|
||||
*/
|
||||
export function removeExpiration(connectionId: string): boolean {
|
||||
return expirations.delete(connectionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to detect expiration hints from HTTP response headers.
|
||||
* Many providers include rate limit reset times that can indicate
|
||||
* quota or token expiration.
|
||||
*
|
||||
* @param provider - Provider ID
|
||||
* @param status - HTTP status code
|
||||
* @param headers - Response headers (as plain object)
|
||||
* @returns Detected expiration date or null
|
||||
*/
|
||||
export function detectExpirationFromResponse(
|
||||
provider: string,
|
||||
status: number,
|
||||
headers: Record<string, string>
|
||||
): { expiresAt: string; expiryType: ExpiryType } | null {
|
||||
// 401 with specific patterns → token expired
|
||||
if (status === 401) {
|
||||
return {
|
||||
expiresAt: new Date().toISOString(), // Already expired
|
||||
expiryType: "oauth_token",
|
||||
};
|
||||
}
|
||||
|
||||
// 402 → payment/subscription expired
|
||||
if (status === 402) {
|
||||
return {
|
||||
expiresAt: new Date().toISOString(),
|
||||
expiryType: "subscription",
|
||||
};
|
||||
}
|
||||
|
||||
// Rate limit headers may indicate reset times
|
||||
const resetHeader =
|
||||
headers["x-ratelimit-reset"] ||
|
||||
headers["x-ratelimit-reset-tokens"] ||
|
||||
headers["retry-after"];
|
||||
|
||||
if (resetHeader && status === 429) {
|
||||
const resetTime = parseInt(resetHeader, 10);
|
||||
if (!isNaN(resetTime)) {
|
||||
// Could be epoch seconds or seconds-from-now
|
||||
const date =
|
||||
resetTime > 1_000_000_000
|
||||
? new Date(resetTime * 1000)
|
||||
: new Date(Date.now() + resetTime * 1000);
|
||||
return {
|
||||
expiresAt: date.toISOString(),
|
||||
expiryType: "free_tier_reset",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all tracked expirations. Useful for testing.
|
||||
*/
|
||||
export function resetExpirations(): void {
|
||||
expirations.clear();
|
||||
}
|
||||
Reference in New Issue
Block a user