mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 00:02:20 +03:00
refactor(phase5-6): domain persistence, policy engine, OAuth extraction, proxy decoupling
Phase 5 — Foundation & Security: - SQLite domain state persistence (5 tables, 4 modules: fallback, budget, lockout, circuit breaker) - Write-through cache pattern for state survival across restarts - Race condition fix in route.js (Promise-based singleton) - Default password hardening (.env.example) - Server init error handling improvement Phase 6 — Architecture Refactoring: - OAuth providers extracted into 12 individual modules (providers.js 1051→144 lines) - Policy Engine (lockout→budget→fallback) with evaluateRequest/evaluateFirstAllowed - Deterministic round-robin via persistent counter Map - Telemetry window fix with proper recordedAt timestamps - Proxy decoupled from API settings (direct import vs HTTP self-fetch) Tests: 295 pass (22 new: domain-persistence 16, policy-engine 6) Docs: CHANGELOG, README, ARCHITECTURE.md updated
This commit is contained in:
@@ -2,17 +2,18 @@ import { callCloudWithMachineId } from "@/shared/utils/cloud.js";
|
||||
import { handleChat } from "@/sse/handlers/chat.js";
|
||||
import { initTranslators } from "@omniroute/open-sse/translator/index.js";
|
||||
|
||||
let initialized = false;
|
||||
let initPromise = null;
|
||||
|
||||
/**
|
||||
* Initialize translators once
|
||||
* Initialize translators once (Promise-based singleton — no race condition)
|
||||
*/
|
||||
async function ensureInitialized() {
|
||||
if (!initialized) {
|
||||
await initTranslators();
|
||||
initialized = true;
|
||||
console.log("[SSE] Translators initialized");
|
||||
function ensureInitialized() {
|
||||
if (!initPromise) {
|
||||
initPromise = initTranslators().then(() => {
|
||||
console.log("[SSE] Translators initialized");
|
||||
});
|
||||
}
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
* @typedef {import('./types.js').Combo} Combo
|
||||
*/
|
||||
|
||||
/** @type {Map<string, number>} Persistent round-robin counters per combo */
|
||||
const roundRobinCounters = new Map();
|
||||
|
||||
/**
|
||||
* Resolve which model to use from a combo based on its strategy.
|
||||
*
|
||||
@@ -28,9 +31,7 @@ export function resolveComboModel(combo, context = {}) {
|
||||
}
|
||||
|
||||
// Normalize models to { model, weight } format
|
||||
const normalized = models.map((m) =>
|
||||
typeof m === "string" ? { model: m, weight: 1 } : m
|
||||
);
|
||||
const normalized = models.map((m) => (typeof m === "string" ? { model: m, weight: 1 } : m));
|
||||
|
||||
const strategy = combo.strategy || "priority";
|
||||
|
||||
@@ -39,9 +40,15 @@ export function resolveComboModel(combo, context = {}) {
|
||||
return { model: normalized[0].model, index: 0 };
|
||||
|
||||
case "round-robin": {
|
||||
// Use a simple counter based on current time + combo id hash
|
||||
const tick = Date.now() % normalized.length;
|
||||
return { model: normalized[tick].model, index: tick };
|
||||
// Persistent counter per combo for deterministic round-robin
|
||||
const comboKey = combo.id || combo.name || "default";
|
||||
if (!roundRobinCounters.has(comboKey)) {
|
||||
roundRobinCounters.set(comboKey, 0);
|
||||
}
|
||||
const counter = roundRobinCounters.get(comboKey);
|
||||
const index = counter % normalized.length;
|
||||
roundRobinCounters.set(comboKey, counter + 1);
|
||||
return { model: normalized[index].model, index };
|
||||
}
|
||||
|
||||
case "random": {
|
||||
@@ -87,8 +94,6 @@ export function resolveComboModel(combo, context = {}) {
|
||||
* @returns {string[]} Remaining models in order
|
||||
*/
|
||||
export function getComboFallbacks(combo, primaryIndex) {
|
||||
const models = (combo.models || []).map((m) =>
|
||||
typeof m === "string" ? m : m.model
|
||||
);
|
||||
const models = (combo.models || []).map((m) => (typeof m === "string" ? m : m.model));
|
||||
return [...models.slice(primaryIndex + 1), ...models.slice(0, primaryIndex)];
|
||||
}
|
||||
|
||||
@@ -4,11 +4,23 @@
|
||||
* Business rules for cost management: budget thresholds,
|
||||
* quota checking, and cost summaries per API key.
|
||||
*
|
||||
* State is persisted in SQLite via domainState.js.
|
||||
*
|
||||
* @module domain/costRules
|
||||
*/
|
||||
|
||||
// @ts-check
|
||||
|
||||
import {
|
||||
saveBudget,
|
||||
loadBudget,
|
||||
saveCostEntry,
|
||||
loadCostEntries,
|
||||
deleteAllCostData,
|
||||
deleteBudget as dbDeleteBudget,
|
||||
deleteCostEntries,
|
||||
} from "../lib/db/domainState.js";
|
||||
|
||||
/**
|
||||
* @typedef {Object} BudgetConfig
|
||||
* @property {number} dailyLimitUsd - Max daily spend in USD
|
||||
@@ -22,11 +34,11 @@
|
||||
* @property {number} timestamp - Unix timestamp
|
||||
*/
|
||||
|
||||
/** @type {Map<string, BudgetConfig>} API key ID → budget config */
|
||||
/** @type {Map<string, BudgetConfig>} In-memory cache for budgets */
|
||||
const budgets = new Map();
|
||||
|
||||
/** @type {Map<string, CostEntry[]>} API key ID → cost entries */
|
||||
const costHistory = new Map();
|
||||
/** @type {boolean} */
|
||||
let _budgetsLoaded = false;
|
||||
|
||||
/**
|
||||
* Set budget for an API key.
|
||||
@@ -35,11 +47,17 @@ const costHistory = new Map();
|
||||
* @param {BudgetConfig} config
|
||||
*/
|
||||
export function setBudget(apiKeyId, config) {
|
||||
budgets.set(apiKeyId, {
|
||||
const normalized = {
|
||||
dailyLimitUsd: config.dailyLimitUsd,
|
||||
monthlyLimitUsd: config.monthlyLimitUsd || 0,
|
||||
warningThreshold: config.warningThreshold ?? 0.8,
|
||||
});
|
||||
};
|
||||
budgets.set(apiKeyId, normalized);
|
||||
try {
|
||||
saveBudget(apiKeyId, normalized);
|
||||
} catch {
|
||||
// Non-critical: in-memory still works
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,7 +67,21 @@ export function setBudget(apiKeyId, config) {
|
||||
* @returns {BudgetConfig | null}
|
||||
*/
|
||||
export function getBudget(apiKeyId) {
|
||||
return budgets.get(apiKeyId) || null;
|
||||
// Check in-memory cache first
|
||||
if (budgets.has(apiKeyId)) {
|
||||
return budgets.get(apiKeyId);
|
||||
}
|
||||
// Try loading from DB
|
||||
try {
|
||||
const fromDb = loadBudget(apiKeyId);
|
||||
if (fromDb) {
|
||||
budgets.set(apiKeyId, fromDb);
|
||||
return fromDb;
|
||||
}
|
||||
} catch {
|
||||
// DB may not be ready
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,10 +91,12 @@ export function getBudget(apiKeyId) {
|
||||
* @param {number} cost - Cost in USD
|
||||
*/
|
||||
export function recordCost(apiKeyId, cost) {
|
||||
if (!costHistory.has(apiKeyId)) {
|
||||
costHistory.set(apiKeyId, []);
|
||||
const timestamp = Date.now();
|
||||
try {
|
||||
saveCostEntry(apiKeyId, cost, timestamp);
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
costHistory.get(apiKeyId).push({ cost, timestamp: Date.now() });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,7 +107,7 @@ export function recordCost(apiKeyId, cost) {
|
||||
* @returns {{ allowed: boolean, reason?: string, dailyUsed: number, dailyLimit: number, warningReached: boolean }}
|
||||
*/
|
||||
export function checkBudget(apiKeyId, additionalCost = 0) {
|
||||
const budget = budgets.get(apiKeyId);
|
||||
const budget = getBudget(apiKeyId);
|
||||
if (!budget) {
|
||||
return { allowed: true, dailyUsed: 0, dailyLimit: 0, warningReached: false };
|
||||
}
|
||||
@@ -107,14 +141,16 @@ export function checkBudget(apiKeyId, additionalCost = 0) {
|
||||
* @returns {number} Total cost today in USD
|
||||
*/
|
||||
export function getDailyTotal(apiKeyId) {
|
||||
const entries = costHistory.get(apiKeyId) || [];
|
||||
const todayStart = new Date();
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
const startMs = todayStart.getTime();
|
||||
|
||||
return entries
|
||||
.filter((e) => e.timestamp >= startMs)
|
||||
.reduce((sum, e) => sum + e.cost, 0);
|
||||
try {
|
||||
const entries = loadCostEntries(apiKeyId, startMs);
|
||||
return entries.reduce((sum, e) => sum + e.cost, 0);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,7 +160,6 @@ export function getDailyTotal(apiKeyId) {
|
||||
* @returns {{ dailyTotal: number, monthlyTotal: number, totalEntries: number, budget: BudgetConfig | null }}
|
||||
*/
|
||||
export function getCostSummary(apiKeyId) {
|
||||
const entries = costHistory.get(apiKeyId) || [];
|
||||
const now = new Date();
|
||||
|
||||
const todayStart = new Date(now);
|
||||
@@ -132,20 +167,27 @@ export function getCostSummary(apiKeyId) {
|
||||
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
|
||||
const dailyTotal = entries
|
||||
.filter((e) => e.timestamp >= todayStart.getTime())
|
||||
.reduce((sum, e) => sum + e.cost, 0);
|
||||
try {
|
||||
const dailyEntries = loadCostEntries(apiKeyId, todayStart.getTime());
|
||||
const monthlyEntries = loadCostEntries(apiKeyId, monthStart.getTime());
|
||||
|
||||
const monthlyTotal = entries
|
||||
.filter((e) => e.timestamp >= monthStart.getTime())
|
||||
.reduce((sum, e) => sum + e.cost, 0);
|
||||
const dailyTotal = dailyEntries.reduce((sum, e) => sum + e.cost, 0);
|
||||
const monthlyTotal = monthlyEntries.reduce((sum, e) => sum + e.cost, 0);
|
||||
|
||||
return {
|
||||
dailyTotal,
|
||||
monthlyTotal,
|
||||
totalEntries: entries.length,
|
||||
budget: budgets.get(apiKeyId) || null,
|
||||
};
|
||||
return {
|
||||
dailyTotal,
|
||||
monthlyTotal,
|
||||
totalEntries: monthlyEntries.length,
|
||||
budget: getBudget(apiKeyId),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
dailyTotal: 0,
|
||||
monthlyTotal: 0,
|
||||
totalEntries: 0,
|
||||
budget: getBudget(apiKeyId),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,5 +195,10 @@ export function getCostSummary(apiKeyId) {
|
||||
*/
|
||||
export function resetCostData() {
|
||||
budgets.clear();
|
||||
costHistory.clear();
|
||||
_budgetsLoaded = false;
|
||||
try {
|
||||
deleteAllCostData();
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,21 @@
|
||||
* When a primary provider is unavailable, the policy engine
|
||||
* resolves to alternative providers in priority order.
|
||||
*
|
||||
* State is persisted in SQLite via domainState.js.
|
||||
*
|
||||
* @module domain/fallbackPolicy
|
||||
*/
|
||||
|
||||
// @ts-check
|
||||
|
||||
import {
|
||||
saveFallbackChain,
|
||||
loadFallbackChain,
|
||||
loadAllFallbackChains,
|
||||
deleteFallbackChain,
|
||||
deleteAllFallbackChains,
|
||||
} from "../lib/db/domainState.js";
|
||||
|
||||
/**
|
||||
* @typedef {Object} FallbackEntry
|
||||
* @property {string} provider - Provider ID
|
||||
@@ -17,9 +27,28 @@
|
||||
* @property {boolean} [enabled=true] - Whether this fallback is active
|
||||
*/
|
||||
|
||||
/** @type {Map<string, FallbackEntry[]>} model → fallback chain */
|
||||
/** @type {Map<string, FallbackEntry[]>} In-memory cache backed by SQLite */
|
||||
const fallbackChains = new Map();
|
||||
|
||||
/** @type {boolean} Whether we've loaded from DB yet */
|
||||
let _loaded = false;
|
||||
|
||||
/**
|
||||
* Ensure in-memory cache is hydrated from SQLite.
|
||||
*/
|
||||
function ensureLoaded() {
|
||||
if (_loaded) return;
|
||||
try {
|
||||
const all = loadAllFallbackChains();
|
||||
for (const [model, chain] of Object.entries(all)) {
|
||||
fallbackChains.set(model, chain);
|
||||
}
|
||||
} catch {
|
||||
// DB may not be ready yet (build phase), that's ok
|
||||
}
|
||||
_loaded = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a fallback chain for a model.
|
||||
*
|
||||
@@ -27,6 +56,7 @@ const fallbackChains = new Map();
|
||||
* @param {FallbackEntry[]} chain - Ordered list of fallback providers
|
||||
*/
|
||||
export function registerFallback(model, chain) {
|
||||
ensureLoaded();
|
||||
const sorted = [...chain]
|
||||
.map((e) => ({
|
||||
provider: e.provider,
|
||||
@@ -36,6 +66,11 @@ export function registerFallback(model, chain) {
|
||||
.sort((a, b) => a.priority - b.priority);
|
||||
|
||||
fallbackChains.set(model, sorted);
|
||||
try {
|
||||
saveFallbackChain(model, sorted);
|
||||
} catch {
|
||||
// Non-critical: in-memory still works
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,6 +82,7 @@ export function registerFallback(model, chain) {
|
||||
* @returns {FallbackEntry[]} Ordered list of fallback providers
|
||||
*/
|
||||
export function resolveFallbackChain(model, excludeProviders = []) {
|
||||
ensureLoaded();
|
||||
const chain = fallbackChains.get(model);
|
||||
if (!chain) return [];
|
||||
|
||||
@@ -73,6 +109,7 @@ export function getNextFallback(model, excludeProviders = []) {
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hasFallback(model) {
|
||||
ensureLoaded();
|
||||
const chain = fallbackChains.get(model);
|
||||
return !!chain && chain.some((e) => e.enabled);
|
||||
}
|
||||
@@ -84,7 +121,16 @@ export function hasFallback(model) {
|
||||
* @returns {boolean} true if removed
|
||||
*/
|
||||
export function removeFallback(model) {
|
||||
return fallbackChains.delete(model);
|
||||
ensureLoaded();
|
||||
const removed = fallbackChains.delete(model);
|
||||
if (removed) {
|
||||
try {
|
||||
deleteFallbackChain(model);
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,6 +139,7 @@ export function removeFallback(model) {
|
||||
* @returns {Record<string, FallbackEntry[]>}
|
||||
*/
|
||||
export function getAllFallbackChains() {
|
||||
ensureLoaded();
|
||||
/** @type {Record<string, FallbackEntry[]>} */
|
||||
const result = {};
|
||||
for (const [model, chain] of fallbackChains.entries()) {
|
||||
@@ -106,4 +153,10 @@ export function getAllFallbackChains() {
|
||||
*/
|
||||
export function resetAllFallbacks() {
|
||||
fallbackChains.clear();
|
||||
_loaded = false;
|
||||
try {
|
||||
deleteAllFallbackChains();
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,18 @@
|
||||
* Extracts account lockout logic from handleChat into a dedicated
|
||||
* domain service. Manages login attempt tracking and lockout decisions.
|
||||
*
|
||||
* State is persisted in SQLite via domainState.js.
|
||||
*
|
||||
* @module domain/lockoutPolicy
|
||||
*/
|
||||
|
||||
import {
|
||||
saveLockoutState,
|
||||
loadLockoutState,
|
||||
deleteLockoutState,
|
||||
loadAllLockedIdentifiers,
|
||||
} from "../lib/db/domainState.js";
|
||||
|
||||
/**
|
||||
* @typedef {Object} LockoutConfig
|
||||
* @property {number} [maxAttempts=5] - Max failed attempts before lockout
|
||||
@@ -15,8 +24,8 @@
|
||||
* @property {number} [attemptWindowMs=300000] - Window for counting attempts (5 min)
|
||||
*/
|
||||
|
||||
/** @type {Map<string, { attempts: number[], lockedUntil: number|null }>} */
|
||||
const lockoutState = new Map();
|
||||
/** @type {Map<string, { attempts: number[], lockedUntil: number|null }>} In-memory cache */
|
||||
const lockoutCache = new Map();
|
||||
|
||||
/** @type {LockoutConfig} */
|
||||
const DEFAULT_CONFIG = {
|
||||
@@ -25,6 +34,43 @@ const DEFAULT_CONFIG = {
|
||||
attemptWindowMs: 5 * 60 * 1000, // 5 minutes
|
||||
};
|
||||
|
||||
/**
|
||||
* Load state from DB into cache if not already cached.
|
||||
* @param {string} identifier
|
||||
* @returns {{ attempts: number[], lockedUntil: number|null }}
|
||||
*/
|
||||
function getState(identifier) {
|
||||
if (lockoutCache.has(identifier)) {
|
||||
return lockoutCache.get(identifier);
|
||||
}
|
||||
|
||||
try {
|
||||
const fromDb = loadLockoutState(identifier);
|
||||
if (fromDb) {
|
||||
lockoutCache.set(identifier, fromDb);
|
||||
return fromDb;
|
||||
}
|
||||
} catch {
|
||||
// DB may not be ready
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist state to both cache and DB.
|
||||
* @param {string} identifier
|
||||
* @param {{ attempts: number[], lockedUntil: number|null }} state
|
||||
*/
|
||||
function persistState(identifier, state) {
|
||||
lockoutCache.set(identifier, state);
|
||||
try {
|
||||
saveLockoutState(identifier, state);
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an identifier (IP, username, API key) is currently locked out.
|
||||
*
|
||||
@@ -33,7 +79,7 @@ const DEFAULT_CONFIG = {
|
||||
* @returns {{ locked: boolean, remainingMs?: number, attempts?: number }}
|
||||
*/
|
||||
export function checkLockout(identifier, config = DEFAULT_CONFIG) {
|
||||
const state = lockoutState.get(identifier);
|
||||
const state = getState(identifier);
|
||||
if (!state) {
|
||||
return { locked: false, attempts: 0 };
|
||||
}
|
||||
@@ -51,12 +97,14 @@ export function checkLockout(identifier, config = DEFAULT_CONFIG) {
|
||||
if (state.lockedUntil) {
|
||||
state.lockedUntil = null;
|
||||
state.attempts = [];
|
||||
persistState(identifier, state);
|
||||
}
|
||||
|
||||
// Count recent attempts within the window
|
||||
const windowStart = Date.now() - config.attemptWindowMs;
|
||||
const recentAttempts = state.attempts.filter((t) => t > windowStart);
|
||||
state.attempts = recentAttempts;
|
||||
persistState(identifier, state);
|
||||
|
||||
return { locked: false, attempts: recentAttempts.length };
|
||||
}
|
||||
@@ -69,12 +117,11 @@ export function checkLockout(identifier, config = DEFAULT_CONFIG) {
|
||||
* @returns {{ locked: boolean, remainingMs?: number }}
|
||||
*/
|
||||
export function recordFailedAttempt(identifier, config = DEFAULT_CONFIG) {
|
||||
if (!lockoutState.has(identifier)) {
|
||||
lockoutState.set(identifier, { attempts: [], lockedUntil: null });
|
||||
let state = getState(identifier);
|
||||
if (!state) {
|
||||
state = { attempts: [], lockedUntil: null };
|
||||
}
|
||||
|
||||
const state = lockoutState.get(identifier);
|
||||
|
||||
// Clean old attempts
|
||||
const windowStart = Date.now() - config.attemptWindowMs;
|
||||
state.attempts = state.attempts.filter((t) => t > windowStart);
|
||||
@@ -85,12 +132,14 @@ export function recordFailedAttempt(identifier, config = DEFAULT_CONFIG) {
|
||||
// Check if threshold exceeded
|
||||
if (state.attempts.length >= config.maxAttempts) {
|
||||
state.lockedUntil = Date.now() + config.lockoutDurationMs;
|
||||
persistState(identifier, state);
|
||||
return {
|
||||
locked: true,
|
||||
remainingMs: config.lockoutDurationMs,
|
||||
};
|
||||
}
|
||||
|
||||
persistState(identifier, state);
|
||||
return { locked: false };
|
||||
}
|
||||
|
||||
@@ -100,7 +149,12 @@ export function recordFailedAttempt(identifier, config = DEFAULT_CONFIG) {
|
||||
* @param {string} identifier
|
||||
*/
|
||||
export function recordSuccess(identifier) {
|
||||
lockoutState.delete(identifier);
|
||||
lockoutCache.delete(identifier);
|
||||
try {
|
||||
deleteLockoutState(identifier);
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,7 +163,12 @@ export function recordSuccess(identifier) {
|
||||
* @param {string} identifier
|
||||
*/
|
||||
export function forceUnlock(identifier) {
|
||||
lockoutState.delete(identifier);
|
||||
lockoutCache.delete(identifier);
|
||||
try {
|
||||
deleteLockoutState(identifier);
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,9 +178,24 @@ export function forceUnlock(identifier) {
|
||||
*/
|
||||
export function getLockedIdentifiers() {
|
||||
const now = Date.now();
|
||||
const locked = [];
|
||||
|
||||
for (const [id, state] of lockoutState.entries()) {
|
||||
// Merge cache and DB
|
||||
try {
|
||||
const fromDb = loadAllLockedIdentifiers();
|
||||
for (const entry of fromDb) {
|
||||
if (!lockoutCache.has(entry.identifier)) {
|
||||
lockoutCache.set(entry.identifier, {
|
||||
attempts: [],
|
||||
lockedUntil: entry.lockedUntil,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Use cache only
|
||||
}
|
||||
|
||||
const locked = [];
|
||||
for (const [id, state] of lockoutCache.entries()) {
|
||||
if (state.lockedUntil && state.lockedUntil > now) {
|
||||
locked.push({
|
||||
identifier: id,
|
||||
|
||||
110
src/domain/policyEngine.js
Normal file
110
src/domain/policyEngine.js
Normal file
@@ -0,0 +1,110 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Policy Engine — FASE-06 Architecture Refactoring
|
||||
*
|
||||
* Centralized policy evaluation that combines domain decisions from
|
||||
* fallback, cost, lockout, and circuit-breaker modules into a single
|
||||
* verdict before forwarding a request to a provider.
|
||||
*
|
||||
* Usage: Call `evaluateRequest(request)` before executing a chat request.
|
||||
* The function returns `{ allowed, reason, adjustments }`.
|
||||
*
|
||||
* @module domain/policyEngine
|
||||
*/
|
||||
|
||||
import { checkLockout } from "./lockoutPolicy.js";
|
||||
import { checkBudget } from "./costRules.js";
|
||||
import { resolveFallbackChain } from "./fallbackPolicy.js";
|
||||
|
||||
/**
|
||||
* @typedef {Object} PolicyRequest
|
||||
* @property {string} model - Requested model
|
||||
* @property {string} [apiKeyId] - API key identifier for budget checks
|
||||
* @property {string} [clientIp] - Client IP for lockout checks
|
||||
* @property {string} [provider] - Target provider
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PolicyVerdict
|
||||
* @property {boolean} allowed - Whether the request is permitted
|
||||
* @property {string|null} reason - Human-readable denial reason (null if allowed)
|
||||
* @property {Object} adjustments - Optional flight-path adjustments
|
||||
* @property {string} [adjustments.model] - Replaced model (from combo/fallback)
|
||||
* @property {Array} [adjustments.fallbackChain] - Available fallbacks
|
||||
* @property {string} policyPhase - Which policy phase determined the outcome
|
||||
*/
|
||||
|
||||
/**
|
||||
* Evaluate a request against all domain policies.
|
||||
*
|
||||
* Evaluation order (short-circuits on first denial):
|
||||
* 1. Lockout — is the client/IP locked out?
|
||||
* 2. Budget — is the API key within budget?
|
||||
* 3. Fallback — is there a fallback chain for the model?
|
||||
*
|
||||
* @param {PolicyRequest} request
|
||||
* @returns {PolicyVerdict}
|
||||
*/
|
||||
export function evaluateRequest(request) {
|
||||
const { model, apiKeyId, clientIp } = request;
|
||||
|
||||
// ── 1. Lockout Policy ──────────────────────────────
|
||||
if (clientIp) {
|
||||
const lockout = checkLockout(clientIp);
|
||||
if (lockout.locked) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Client locked out (${lockout.remainingMs}ms remaining)`,
|
||||
adjustments: {},
|
||||
policyPhase: "lockout",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Budget Policy ───────────────────────────────
|
||||
if (apiKeyId) {
|
||||
const budget = checkBudget(apiKeyId);
|
||||
if (budget && !budget.allowed) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `Budget exceeded: ${budget.reason || "daily limit reached"}`,
|
||||
adjustments: {},
|
||||
policyPhase: "budget",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Fallback Chain Resolution ───────────────────
|
||||
const fallbackChain = resolveFallbackChain(model);
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
reason: null,
|
||||
adjustments: {
|
||||
model,
|
||||
fallbackChain: fallbackChain || [],
|
||||
},
|
||||
policyPhase: "passed",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a set of models against policies and return the first allowed one.
|
||||
* Useful for combo/fallback scenarios where multiple models may be tried.
|
||||
*
|
||||
* @param {string[]} models - Models to evaluate in order
|
||||
* @param {Omit<PolicyRequest, 'model'>} baseRequest - Base request without model
|
||||
* @returns {{ model: string, verdict: PolicyVerdict } | { model: null, verdict: PolicyVerdict }}
|
||||
*/
|
||||
export function evaluateFirstAllowed(models, baseRequest) {
|
||||
for (const model of models) {
|
||||
const verdict = evaluateRequest({ ...baseRequest, model });
|
||||
if (verdict.allowed) {
|
||||
return { model, verdict };
|
||||
}
|
||||
}
|
||||
|
||||
// All models denied — return last denial
|
||||
const lastVerdict = evaluateRequest({ ...baseRequest, model: models[models.length - 1] });
|
||||
return { model: null, verdict: lastVerdict };
|
||||
}
|
||||
@@ -156,6 +156,42 @@ const SCHEMA_SQL = `
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cl_timestamp ON call_logs(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_cl_status ON call_logs(status);
|
||||
|
||||
-- Domain State Persistence (Phase 5)
|
||||
CREATE TABLE IF NOT EXISTS domain_fallback_chains (
|
||||
model TEXT PRIMARY KEY,
|
||||
chain TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS domain_budgets (
|
||||
api_key_id TEXT PRIMARY KEY,
|
||||
daily_limit_usd REAL NOT NULL,
|
||||
monthly_limit_usd REAL DEFAULT 0,
|
||||
warning_threshold REAL DEFAULT 0.8
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS domain_cost_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
api_key_id TEXT NOT NULL,
|
||||
cost REAL NOT NULL,
|
||||
timestamp INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dch_key ON domain_cost_history(api_key_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dch_ts ON domain_cost_history(timestamp);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS domain_lockout_state (
|
||||
identifier TEXT PRIMARY KEY,
|
||||
attempts TEXT NOT NULL,
|
||||
locked_until INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS domain_circuit_breakers (
|
||||
name TEXT PRIMARY KEY,
|
||||
state TEXT NOT NULL DEFAULT 'CLOSED',
|
||||
failure_count INTEGER DEFAULT 0,
|
||||
last_failure_time INTEGER,
|
||||
options TEXT
|
||||
);
|
||||
`;
|
||||
|
||||
// ──────────────── Column Mapping ────────────────
|
||||
|
||||
309
src/lib/db/domainState.js
Normal file
309
src/lib/db/domainState.js
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* Domain State Persistence — Phase 5 Foundation
|
||||
*
|
||||
* CRUD operations for persisting domain layer state in SQLite.
|
||||
* Replaces in-memory Map() storage with durable persistence.
|
||||
*
|
||||
* Tables: domain_fallback_chains, domain_budgets, domain_cost_history,
|
||||
* domain_lockout_state, domain_circuit_breakers
|
||||
*
|
||||
* @module lib/db/domainState
|
||||
*/
|
||||
|
||||
import { getDbInstance, isBuildPhase, isCloud } from "./core.js";
|
||||
|
||||
// ──────────────── Fallback Chains ────────────────
|
||||
|
||||
/**
|
||||
* Save a fallback chain for a model.
|
||||
* @param {string} model
|
||||
* @param {Array<{provider: string, priority: number, enabled: boolean}>} chain
|
||||
*/
|
||||
export function saveFallbackChain(model, chain) {
|
||||
const db = getDbInstance();
|
||||
db.prepare("INSERT OR REPLACE INTO domain_fallback_chains (model, chain) VALUES (?, ?)").run(
|
||||
model,
|
||||
JSON.stringify(chain)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a fallback chain for a model.
|
||||
* @param {string} model
|
||||
* @returns {Array<{provider: string, priority: number, enabled: boolean}> | null}
|
||||
*/
|
||||
export function loadFallbackChain(model) {
|
||||
const db = getDbInstance();
|
||||
const row = db.prepare("SELECT chain FROM domain_fallback_chains WHERE model = ?").get(model);
|
||||
return row ? JSON.parse(row.chain) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all fallback chains.
|
||||
* @returns {Record<string, Array<{provider: string, priority: number, enabled: boolean}>>}
|
||||
*/
|
||||
export function loadAllFallbackChains() {
|
||||
const db = getDbInstance();
|
||||
const rows = db.prepare("SELECT model, chain FROM domain_fallback_chains").all();
|
||||
const result = {};
|
||||
for (const row of rows) {
|
||||
result[row.model] = JSON.parse(row.chain);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a fallback chain.
|
||||
* @param {string} model
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function deleteFallbackChain(model) {
|
||||
const db = getDbInstance();
|
||||
const info = db.prepare("DELETE FROM domain_fallback_chains WHERE model = ?").run(model);
|
||||
return info.changes > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all fallback chains.
|
||||
*/
|
||||
export function deleteAllFallbackChains() {
|
||||
const db = getDbInstance();
|
||||
db.prepare("DELETE FROM domain_fallback_chains").run();
|
||||
}
|
||||
|
||||
// ──────────────── Budgets ────────────────
|
||||
|
||||
/**
|
||||
* Save a budget config for an API key.
|
||||
* @param {string} apiKeyId
|
||||
* @param {{ dailyLimitUsd: number, monthlyLimitUsd?: number, warningThreshold?: number }} config
|
||||
*/
|
||||
export function saveBudget(apiKeyId, config) {
|
||||
const db = getDbInstance();
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO domain_budgets (api_key_id, daily_limit_usd, monthly_limit_usd, warning_threshold)
|
||||
VALUES (?, ?, ?, ?)`
|
||||
).run(
|
||||
apiKeyId,
|
||||
config.dailyLimitUsd,
|
||||
config.monthlyLimitUsd || 0,
|
||||
config.warningThreshold ?? 0.8
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a budget config.
|
||||
* @param {string} apiKeyId
|
||||
* @returns {{ dailyLimitUsd: number, monthlyLimitUsd: number, warningThreshold: number } | null}
|
||||
*/
|
||||
export function loadBudget(apiKeyId) {
|
||||
const db = getDbInstance();
|
||||
const row = db.prepare("SELECT * FROM domain_budgets WHERE api_key_id = ?").get(apiKeyId);
|
||||
if (!row) return null;
|
||||
return {
|
||||
dailyLimitUsd: row.daily_limit_usd,
|
||||
monthlyLimitUsd: row.monthly_limit_usd,
|
||||
warningThreshold: row.warning_threshold,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a budget config.
|
||||
* @param {string} apiKeyId
|
||||
*/
|
||||
export function deleteBudget(apiKeyId) {
|
||||
const db = getDbInstance();
|
||||
db.prepare("DELETE FROM domain_budgets WHERE api_key_id = ?").run(apiKeyId);
|
||||
}
|
||||
|
||||
// ──────────────── Cost History ────────────────
|
||||
|
||||
/**
|
||||
* Record a cost entry.
|
||||
* @param {string} apiKeyId
|
||||
* @param {number} cost
|
||||
* @param {number} [timestamp]
|
||||
*/
|
||||
export function saveCostEntry(apiKeyId, cost, timestamp = Date.now()) {
|
||||
const db = getDbInstance();
|
||||
db.prepare("INSERT INTO domain_cost_history (api_key_id, cost, timestamp) VALUES (?, ?, ?)").run(
|
||||
apiKeyId,
|
||||
cost,
|
||||
timestamp
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load cost entries for an API key within a time window.
|
||||
* @param {string} apiKeyId
|
||||
* @param {number} sinceTimestamp
|
||||
* @returns {Array<{cost: number, timestamp: number}>}
|
||||
*/
|
||||
export function loadCostEntries(apiKeyId, sinceTimestamp) {
|
||||
const db = getDbInstance();
|
||||
return db
|
||||
.prepare(
|
||||
"SELECT cost, timestamp FROM domain_cost_history WHERE api_key_id = ? AND timestamp >= ? ORDER BY timestamp"
|
||||
)
|
||||
.all(apiKeyId, sinceTimestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete old cost entries (cleanup).
|
||||
* @param {number} olderThanTimestamp
|
||||
* @returns {number} deleted count
|
||||
*/
|
||||
export function cleanOldCostEntries(olderThanTimestamp) {
|
||||
const db = getDbInstance();
|
||||
const info = db
|
||||
.prepare("DELETE FROM domain_cost_history WHERE timestamp < ?")
|
||||
.run(olderThanTimestamp);
|
||||
return info.changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all cost data for an API key.
|
||||
* @param {string} apiKeyId
|
||||
*/
|
||||
export function deleteCostEntries(apiKeyId) {
|
||||
const db = getDbInstance();
|
||||
db.prepare("DELETE FROM domain_cost_history WHERE api_key_id = ?").run(apiKeyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all cost data.
|
||||
*/
|
||||
export function deleteAllCostData() {
|
||||
const db = getDbInstance();
|
||||
db.prepare("DELETE FROM domain_cost_history").run();
|
||||
db.prepare("DELETE FROM domain_budgets").run();
|
||||
}
|
||||
|
||||
// ──────────────── Lockout State ────────────────
|
||||
|
||||
/**
|
||||
* Save lockout state for an identifier.
|
||||
* @param {string} identifier
|
||||
* @param {{ attempts: number[], lockedUntil: number|null }} state
|
||||
*/
|
||||
export function saveLockoutState(identifier, state) {
|
||||
const db = getDbInstance();
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO domain_lockout_state (identifier, attempts, locked_until)
|
||||
VALUES (?, ?, ?)`
|
||||
).run(identifier, JSON.stringify(state.attempts), state.lockedUntil);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load lockout state for an identifier.
|
||||
* @param {string} identifier
|
||||
* @returns {{ attempts: number[], lockedUntil: number|null } | null}
|
||||
*/
|
||||
export function loadLockoutState(identifier) {
|
||||
const db = getDbInstance();
|
||||
const row = db.prepare("SELECT * FROM domain_lockout_state WHERE identifier = ?").get(identifier);
|
||||
if (!row) return null;
|
||||
return {
|
||||
attempts: JSON.parse(row.attempts),
|
||||
lockedUntil: row.locked_until,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete lockout state for an identifier.
|
||||
* @param {string} identifier
|
||||
*/
|
||||
export function deleteLockoutState(identifier) {
|
||||
const db = getDbInstance();
|
||||
db.prepare("DELETE FROM domain_lockout_state WHERE identifier = ?").run(identifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all locked identifiers.
|
||||
* @returns {Array<{identifier: string, lockedUntil: number}>}
|
||||
*/
|
||||
export function loadAllLockedIdentifiers() {
|
||||
const db = getDbInstance();
|
||||
const now = Date.now();
|
||||
return db
|
||||
.prepare(
|
||||
"SELECT identifier, locked_until FROM domain_lockout_state WHERE locked_until IS NOT NULL AND locked_until > ?"
|
||||
)
|
||||
.all(now)
|
||||
.map((row) => ({
|
||||
identifier: row.identifier,
|
||||
lockedUntil: row.locked_until,
|
||||
}));
|
||||
}
|
||||
|
||||
// ──────────────── Circuit Breakers ────────────────
|
||||
|
||||
/**
|
||||
* Save circuit breaker state.
|
||||
* @param {string} name
|
||||
* @param {{ state: string, failureCount: number, lastFailureTime: number|null, options?: object }} cbState
|
||||
*/
|
||||
export function saveCircuitBreakerState(name, cbState) {
|
||||
const db = getDbInstance();
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO domain_circuit_breakers (name, state, failure_count, last_failure_time, options)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
name,
|
||||
cbState.state,
|
||||
cbState.failureCount,
|
||||
cbState.lastFailureTime,
|
||||
cbState.options ? JSON.stringify(cbState.options) : null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load circuit breaker state.
|
||||
* @param {string} name
|
||||
* @returns {{ state: string, failureCount: number, lastFailureTime: number|null, options?: object } | null}
|
||||
*/
|
||||
export function loadCircuitBreakerState(name) {
|
||||
const db = getDbInstance();
|
||||
const row = db.prepare("SELECT * FROM domain_circuit_breakers WHERE name = ?").get(name);
|
||||
if (!row) return null;
|
||||
return {
|
||||
state: row.state,
|
||||
failureCount: row.failure_count,
|
||||
lastFailureTime: row.last_failure_time,
|
||||
options: row.options ? JSON.parse(row.options) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all circuit breaker states.
|
||||
* @returns {Array<{name: string, state: string, failureCount: number, lastFailureTime: number|null}>}
|
||||
*/
|
||||
export function loadAllCircuitBreakerStates() {
|
||||
const db = getDbInstance();
|
||||
return db
|
||||
.prepare("SELECT name, state, failure_count, last_failure_time FROM domain_circuit_breakers")
|
||||
.all()
|
||||
.map((row) => ({
|
||||
name: row.name,
|
||||
state: row.state,
|
||||
failureCount: row.failure_count,
|
||||
lastFailureTime: row.last_failure_time,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a circuit breaker state.
|
||||
* @param {string} name
|
||||
*/
|
||||
export function deleteCircuitBreakerState(name) {
|
||||
const db = getDbInstance();
|
||||
db.prepare("DELETE FROM domain_circuit_breakers WHERE name = ?").run(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all circuit breaker states.
|
||||
*/
|
||||
export function deleteAllCircuitBreakerStates() {
|
||||
const db = getDbInstance();
|
||||
db.prepare("DELETE FROM domain_circuit_breakers").run();
|
||||
}
|
||||
@@ -1,920 +1,14 @@
|
||||
/**
|
||||
* OAuth Provider Configurations and Handlers
|
||||
* Centralized DRY approach for all OAuth providers
|
||||
*
|
||||
* This file re-exports from the modular providers/ directory.
|
||||
* Each provider is now in its own file for maintainability.
|
||||
*
|
||||
* @see ./providers/index.js for the registry
|
||||
*/
|
||||
|
||||
import { generatePKCE, generateState } from "./utils/pkce";
|
||||
import {
|
||||
CLAUDE_CONFIG,
|
||||
CODEX_CONFIG,
|
||||
GEMINI_CONFIG,
|
||||
QWEN_CONFIG,
|
||||
IFLOW_CONFIG,
|
||||
KIMI_CODING_CONFIG,
|
||||
ANTIGRAVITY_CONFIG,
|
||||
GITHUB_CONFIG,
|
||||
KIRO_CONFIG,
|
||||
CURSOR_CONFIG,
|
||||
KILOCODE_CONFIG,
|
||||
CLINE_CONFIG,
|
||||
} from "./constants/oauth";
|
||||
|
||||
// Provider configurations
|
||||
const PROVIDERS = {
|
||||
claude: {
|
||||
config: CLAUDE_CONFIG,
|
||||
flowType: "authorization_code_pkce",
|
||||
buildAuthUrl: (config, redirectUri, state, codeChallenge) => {
|
||||
const params = new URLSearchParams({
|
||||
code: "true",
|
||||
client_id: config.clientId,
|
||||
response_type: "code",
|
||||
redirect_uri: redirectUri,
|
||||
scope: config.scopes.join(" "),
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: config.codeChallengeMethod,
|
||||
state: state,
|
||||
});
|
||||
return `${config.authorizeUrl}?${params.toString()}`;
|
||||
},
|
||||
exchangeToken: async (config, code, redirectUri, codeVerifier, state) => {
|
||||
// Parse code - may contain state after #
|
||||
let authCode = code;
|
||||
let codeState = "";
|
||||
if (authCode.includes("#")) {
|
||||
const parts = authCode.split("#");
|
||||
authCode = parts[0];
|
||||
codeState = parts[1] || "";
|
||||
}
|
||||
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code: authCode,
|
||||
state: codeState || state,
|
||||
grant_type: "authorization_code",
|
||||
client_id: config.clientId,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: codeVerifier,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
mapTokens: (tokens) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
scope: tokens.scope,
|
||||
}),
|
||||
},
|
||||
|
||||
codex: {
|
||||
config: CODEX_CONFIG,
|
||||
flowType: "authorization_code_pkce",
|
||||
fixedPort: 1455,
|
||||
callbackPath: "/auth/callback",
|
||||
buildAuthUrl: (config, redirectUri, state, codeChallenge) => {
|
||||
const params = {
|
||||
response_type: "code",
|
||||
client_id: config.clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: config.scope,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: config.codeChallengeMethod,
|
||||
...config.extraParams,
|
||||
state: state,
|
||||
};
|
||||
const queryString = Object.entries(params)
|
||||
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
|
||||
.join("&");
|
||||
return `${config.authorizeUrl}?${queryString}`;
|
||||
},
|
||||
exchangeToken: async (config, code, redirectUri, codeVerifier) => {
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: config.clientId,
|
||||
code: code,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: codeVerifier,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
mapTokens: (tokens) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
idToken: tokens.id_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
}),
|
||||
},
|
||||
|
||||
"gemini-cli": {
|
||||
config: GEMINI_CONFIG,
|
||||
flowType: "authorization_code",
|
||||
buildAuthUrl: (config, redirectUri, state) => {
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
response_type: "code",
|
||||
redirect_uri: redirectUri,
|
||||
scope: config.scopes.join(" "),
|
||||
state: state,
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
});
|
||||
return `${config.authorizeUrl}?${params.toString()}`;
|
||||
},
|
||||
exchangeToken: async (config, code, redirectUri) => {
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
code: code,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
postExchange: async (tokens) => {
|
||||
// Fetch user info
|
||||
const userInfoRes = await fetch(`${GEMINI_CONFIG.userInfoUrl}?alt=json`, {
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||
});
|
||||
const userInfo = userInfoRes.ok ? await userInfoRes.json() : {};
|
||||
|
||||
// Fetch project ID
|
||||
let projectId = "";
|
||||
try {
|
||||
const projectRes = await fetch(
|
||||
"https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
metadata: {
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (projectRes.ok) {
|
||||
const data = await projectRes.json();
|
||||
projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || "";
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Failed to fetch project ID:", e);
|
||||
}
|
||||
|
||||
return { userInfo, projectId };
|
||||
},
|
||||
mapTokens: (tokens, extra) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
scope: tokens.scope,
|
||||
email: extra?.userInfo?.email,
|
||||
projectId: extra?.projectId,
|
||||
}),
|
||||
},
|
||||
|
||||
antigravity: {
|
||||
config: ANTIGRAVITY_CONFIG,
|
||||
flowType: "authorization_code",
|
||||
buildAuthUrl: (config, redirectUri, state) => {
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
response_type: "code",
|
||||
redirect_uri: redirectUri,
|
||||
scope: config.scopes.join(" "),
|
||||
state: state,
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
});
|
||||
return `${config.authorizeUrl}?${params.toString()}`;
|
||||
},
|
||||
exchangeToken: async (config, code, redirectUri) => {
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
code: code,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
postExchange: async (tokens) => {
|
||||
const headers = {
|
||||
Authorization: `Bearer ${tokens.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": ANTIGRAVITY_CONFIG.loadCodeAssistUserAgent,
|
||||
"X-Goog-Api-Client": ANTIGRAVITY_CONFIG.loadCodeAssistApiClient,
|
||||
"Client-Metadata": ANTIGRAVITY_CONFIG.loadCodeAssistClientMetadata,
|
||||
};
|
||||
const metadata = {
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
};
|
||||
|
||||
// Fetch user info
|
||||
const userInfoRes = await fetch(`${ANTIGRAVITY_CONFIG.userInfoUrl}?alt=json`, {
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||
});
|
||||
const userInfo = userInfoRes.ok ? await userInfoRes.json() : {};
|
||||
|
||||
// Load Code Assist to get project ID and tier
|
||||
let projectId = "";
|
||||
let tierId = "legacy-tier";
|
||||
try {
|
||||
const loadRes = await fetch(ANTIGRAVITY_CONFIG.loadCodeAssistEndpoint, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ metadata }),
|
||||
});
|
||||
if (loadRes.ok) {
|
||||
const data = await loadRes.json();
|
||||
projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || "";
|
||||
// Extract tier ID
|
||||
if (Array.isArray(data.allowedTiers)) {
|
||||
for (const tier of data.allowedTiers) {
|
||||
if (tier.isDefault && tier.id) {
|
||||
tierId = tier.id.trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Failed to load code assist:", e);
|
||||
}
|
||||
|
||||
// Onboard user to enable Gemini Code Assist
|
||||
if (projectId) {
|
||||
try {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const onboardRes = await fetch(ANTIGRAVITY_CONFIG.onboardUserEndpoint, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ tierId, metadata, cloudaicompanionProject: projectId }),
|
||||
});
|
||||
if (onboardRes.ok) {
|
||||
const result = await onboardRes.json();
|
||||
if (result.done === true) {
|
||||
// Extract final project ID from response
|
||||
if (result.response?.cloudaicompanionProject) {
|
||||
const respProject = result.response.cloudaicompanionProject;
|
||||
projectId =
|
||||
typeof respProject === "string"
|
||||
? respProject.trim()
|
||||
: respProject.id || projectId;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Wait 5 seconds before retry
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Failed to onboard user:", e);
|
||||
}
|
||||
}
|
||||
|
||||
return { userInfo, projectId };
|
||||
},
|
||||
mapTokens: (tokens, extra) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
scope: tokens.scope,
|
||||
email: extra?.userInfo?.email,
|
||||
projectId: extra?.projectId,
|
||||
}),
|
||||
},
|
||||
|
||||
iflow: {
|
||||
config: IFLOW_CONFIG,
|
||||
flowType: "authorization_code",
|
||||
buildAuthUrl: (config, redirectUri, state) => {
|
||||
const params = new URLSearchParams({
|
||||
loginMethod: config.extraParams.loginMethod,
|
||||
type: config.extraParams.type,
|
||||
redirect: redirectUri,
|
||||
state: state,
|
||||
client_id: config.clientId,
|
||||
});
|
||||
return `${config.authorizeUrl}?${params.toString()}`;
|
||||
},
|
||||
exchangeToken: async (config, code, redirectUri) => {
|
||||
// Create Basic Auth header
|
||||
const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64");
|
||||
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
Authorization: `Basic ${basicAuth}`,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: code,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
postExchange: async (tokens) => {
|
||||
// Fetch user info
|
||||
const userInfoRes = await fetch(
|
||||
`${IFLOW_CONFIG.userInfoUrl}?accessToken=${encodeURIComponent(tokens.access_token)}`,
|
||||
{
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
const result = userInfoRes.ok ? await userInfoRes.json() : {};
|
||||
const userInfo = result.success ? result.data : {};
|
||||
return { userInfo };
|
||||
},
|
||||
mapTokens: (tokens, extra) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
apiKey: extra?.userInfo?.apiKey,
|
||||
email: extra?.userInfo?.email || extra?.userInfo?.phone,
|
||||
displayName: extra?.userInfo?.nickname || extra?.userInfo?.name,
|
||||
}),
|
||||
},
|
||||
|
||||
qwen: {
|
||||
config: QWEN_CONFIG,
|
||||
flowType: "device_code",
|
||||
requestDeviceCode: async (config, codeChallenge) => {
|
||||
const response = await fetch(config.deviceCodeUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
scope: config.scope,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: config.codeChallengeMethod,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Device code request failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
pollToken: async (config, deviceCode, codeVerifier) => {
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
client_id: config.clientId,
|
||||
device_code: deviceCode,
|
||||
code_verifier: codeVerifier,
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
data: await response.json(),
|
||||
};
|
||||
},
|
||||
mapTokens: (tokens) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
providerSpecificData: { resourceUrl: tokens.resource_url },
|
||||
}),
|
||||
},
|
||||
|
||||
"kimi-coding": {
|
||||
config: KIMI_CODING_CONFIG,
|
||||
flowType: "device_code",
|
||||
requestDeviceCode: async (config) => {
|
||||
const response = await fetch(config.deviceCodeUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Device code request failed: ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
device_code: data.device_code,
|
||||
user_code: data.user_code,
|
||||
verification_uri: data.verification_uri || `https://www.kimi.com/code/authorize_device`,
|
||||
verification_uri_complete:
|
||||
data.verification_uri_complete ||
|
||||
`https://www.kimi.com/code/authorize_device?user_code=${data.user_code}`,
|
||||
expires_in: data.expires_in,
|
||||
interval: data.interval || 5,
|
||||
};
|
||||
},
|
||||
pollToken: async (config, deviceCode) => {
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
client_id: config.clientId,
|
||||
device_code: deviceCode,
|
||||
}),
|
||||
});
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (e) {
|
||||
const text = await response.text();
|
||||
data = { error: "invalid_response", error_description: text };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
data: data,
|
||||
};
|
||||
},
|
||||
mapTokens: (tokens) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
}),
|
||||
},
|
||||
|
||||
github: {
|
||||
config: GITHUB_CONFIG,
|
||||
flowType: "device_code",
|
||||
requestDeviceCode: async (config) => {
|
||||
const response = await fetch(config.deviceCodeUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
scope: config.scopes,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Device code request failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
pollToken: async (config, deviceCode) => {
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
device_code: deviceCode,
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
}),
|
||||
});
|
||||
|
||||
// Handle response properly - if not ok, try to get error as text first
|
||||
let data;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (e) {
|
||||
// If response is not JSON, get as text
|
||||
const text = await response.text();
|
||||
data = { error: "invalid_response", error_description: text };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
data: data,
|
||||
};
|
||||
},
|
||||
postExchange: async (tokens) => {
|
||||
// Get Copilot token using GitHub access token
|
||||
const copilotRes = await fetch(GITHUB_CONFIG.copilotTokenUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.access_token}`,
|
||||
Accept: "application/json",
|
||||
"X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion,
|
||||
"User-Agent": GITHUB_CONFIG.userAgent,
|
||||
},
|
||||
});
|
||||
const copilotToken = copilotRes.ok ? await copilotRes.json() : {};
|
||||
|
||||
// Get user info from GitHub
|
||||
const userRes = await fetch(GITHUB_CONFIG.userInfoUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.access_token}`,
|
||||
Accept: "application/json",
|
||||
"X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion,
|
||||
"User-Agent": GITHUB_CONFIG.userAgent,
|
||||
},
|
||||
});
|
||||
const userInfo = userRes.ok ? await userRes.json() : {};
|
||||
|
||||
return { copilotToken, userInfo };
|
||||
},
|
||||
mapTokens: (tokens, extra) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
providerSpecificData: {
|
||||
copilotToken: extra?.copilotToken?.token,
|
||||
copilotTokenExpiresAt: extra?.copilotToken?.expires_at,
|
||||
githubUserId: extra?.userInfo?.id,
|
||||
githubLogin: extra?.userInfo?.login,
|
||||
githubName: extra?.userInfo?.name,
|
||||
githubEmail: extra?.userInfo?.email,
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
kiro: {
|
||||
config: KIRO_CONFIG,
|
||||
flowType: "device_code",
|
||||
// Kiro uses AWS SSO OIDC - requires client registration first
|
||||
requestDeviceCode: async (config) => {
|
||||
// Step 1: Register client with AWS SSO OIDC
|
||||
const registerRes = await fetch(config.registerClientUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientName: config.clientName,
|
||||
clientType: config.clientType,
|
||||
scopes: config.scopes,
|
||||
grantTypes: config.grantTypes,
|
||||
issuerUrl: config.issuerUrl,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!registerRes.ok) {
|
||||
const error = await registerRes.text();
|
||||
throw new Error(`Client registration failed: ${error}`);
|
||||
}
|
||||
|
||||
const clientInfo = await registerRes.json();
|
||||
|
||||
// Step 2: Request device authorization
|
||||
const deviceRes = await fetch(config.deviceAuthUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientId: clientInfo.clientId,
|
||||
clientSecret: clientInfo.clientSecret,
|
||||
startUrl: config.startUrl,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!deviceRes.ok) {
|
||||
const error = await deviceRes.text();
|
||||
throw new Error(`Device authorization failed: ${error}`);
|
||||
}
|
||||
|
||||
const deviceData = await deviceRes.json();
|
||||
|
||||
// Return combined data for polling
|
||||
return {
|
||||
device_code: deviceData.deviceCode,
|
||||
user_code: deviceData.userCode,
|
||||
verification_uri: deviceData.verificationUri,
|
||||
verification_uri_complete: deviceData.verificationUriComplete,
|
||||
expires_in: deviceData.expiresIn,
|
||||
interval: deviceData.interval || 5,
|
||||
// Store client credentials for token exchange
|
||||
_clientId: clientInfo.clientId,
|
||||
_clientSecret: clientInfo.clientSecret,
|
||||
};
|
||||
},
|
||||
pollToken: async (config, deviceCode, codeVerifier, extraData) => {
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientId: extraData?._clientId,
|
||||
clientSecret: extraData?._clientSecret,
|
||||
deviceCode: deviceCode,
|
||||
grantType: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
}),
|
||||
});
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (e) {
|
||||
const text = await response.text();
|
||||
data = { error: "invalid_response", error_description: text };
|
||||
}
|
||||
|
||||
// AWS SSO OIDC returns camelCase
|
||||
if (data.accessToken) {
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
access_token: data.accessToken,
|
||||
refresh_token: data.refreshToken,
|
||||
expires_in: data.expiresIn,
|
||||
// Store client credentials for refresh
|
||||
_clientId: extraData?._clientId,
|
||||
_clientSecret: extraData?._clientSecret,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
data: {
|
||||
error: data.error || "authorization_pending",
|
||||
error_description: data.error_description || data.message,
|
||||
},
|
||||
};
|
||||
},
|
||||
mapTokens: (tokens) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
providerSpecificData: {
|
||||
clientId: tokens._clientId,
|
||||
clientSecret: tokens._clientSecret,
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
cursor: {
|
||||
config: CURSOR_CONFIG,
|
||||
flowType: "import_token",
|
||||
// Cursor uses import token flow - tokens are extracted from local SQLite database
|
||||
// No OAuth flow needed, handled by /api/oauth/cursor/import route
|
||||
mapTokens: (tokens) => ({
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: null, // Cursor doesn't have public refresh endpoint
|
||||
expiresIn: tokens.expiresIn || 86400,
|
||||
providerSpecificData: {
|
||||
machineId: tokens.machineId,
|
||||
authMethod: "imported",
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
kilocode: {
|
||||
config: KILOCODE_CONFIG,
|
||||
flowType: "device_code",
|
||||
requestDeviceCode: async (config) => {
|
||||
// KiloCode uses a custom device auth flow (not standard OAuth)
|
||||
const response = await fetch(config.initiateUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
throw new Error("Too many pending authorization requests. Please try again later.");
|
||||
}
|
||||
const error = await response.text();
|
||||
throw new Error(`Device auth initiation failed: ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
// Map KiloCode response to standard device code format
|
||||
return {
|
||||
device_code: data.code, // Use code as device_code for polling
|
||||
user_code: data.code,
|
||||
verification_uri: data.verificationUrl,
|
||||
verification_uri_complete: data.verificationUrl,
|
||||
expires_in: data.expiresIn || 300,
|
||||
interval: 3,
|
||||
};
|
||||
},
|
||||
pollToken: async (config, deviceCode) => {
|
||||
// KiloCode polls by GET /api/device-auth/codes/{code}
|
||||
const response = await fetch(`${config.pollUrlBase}/${deviceCode}`);
|
||||
|
||||
// Handle custom status codes
|
||||
if (response.status === 202) {
|
||||
// Still pending
|
||||
return { ok: false, data: { error: "authorization_pending" } };
|
||||
}
|
||||
if (response.status === 403) {
|
||||
return {
|
||||
ok: false,
|
||||
data: { error: "access_denied", error_description: "Authorization denied by user" },
|
||||
};
|
||||
}
|
||||
if (response.status === 410) {
|
||||
return {
|
||||
ok: false,
|
||||
data: { error: "expired_token", error_description: "Authorization code expired" },
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
data: { error: "poll_failed", error_description: `Poll failed: ${response.status}` },
|
||||
};
|
||||
}
|
||||
|
||||
// Success - map KiloCode {token, userEmail} to standard format
|
||||
const data = await response.json();
|
||||
if (data.status === "approved" && data.token) {
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
access_token: data.token,
|
||||
_userEmail: data.userEmail,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: false, data: { error: "authorization_pending" } };
|
||||
},
|
||||
mapTokens: (tokens) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: null, // KiloCode JWT doesn't have refresh tokens
|
||||
expiresIn: null, // JWT expiry is embedded in the token
|
||||
email: tokens._userEmail,
|
||||
}),
|
||||
},
|
||||
|
||||
cline: {
|
||||
config: CLINE_CONFIG,
|
||||
flowType: "authorization_code",
|
||||
buildAuthUrl: (config, redirectUri) => {
|
||||
const params = new URLSearchParams({
|
||||
client_type: "extension",
|
||||
callback_url: redirectUri,
|
||||
redirect_uri: redirectUri,
|
||||
});
|
||||
return `${config.authorizeUrl}?${params.toString()}`;
|
||||
},
|
||||
exchangeToken: async (config, code, redirectUri) => {
|
||||
// Cline callback returns base64-encoded JSON with tokens directly in the code param
|
||||
// The code may have a signature suffix after the JSON - strip it
|
||||
try {
|
||||
// Add padding if needed
|
||||
let base64 = code;
|
||||
const padding = 4 - (base64.length % 4);
|
||||
if (padding !== 4) {
|
||||
base64 += "=".repeat(padding);
|
||||
}
|
||||
const decoded = Buffer.from(base64, "base64").toString("utf-8");
|
||||
// Find the JSON boundary (ends with })
|
||||
const lastBrace = decoded.lastIndexOf("}");
|
||||
if (lastBrace === -1) {
|
||||
throw new Error("No JSON found in decoded code");
|
||||
}
|
||||
const jsonStr = decoded.substring(0, lastBrace + 1);
|
||||
const tokenData = JSON.parse(jsonStr);
|
||||
return {
|
||||
access_token: tokenData.accessToken,
|
||||
refresh_token: tokenData.refreshToken,
|
||||
email: tokenData.email,
|
||||
firstName: tokenData.firstName,
|
||||
lastName: tokenData.lastName,
|
||||
expires_at: tokenData.expiresAt,
|
||||
};
|
||||
} catch (e) {
|
||||
// Fallback: try token exchange via API
|
||||
const response = await fetch(config.tokenExchangeUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: "authorization_code",
|
||||
code: code,
|
||||
client_type: "extension",
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Cline token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
access_token: data.data?.accessToken || data.accessToken,
|
||||
refresh_token: data.data?.refreshToken || data.refreshToken,
|
||||
email: data.data?.userInfo?.email || "",
|
||||
expires_at: data.data?.expiresAt || data.expiresAt,
|
||||
};
|
||||
}
|
||||
},
|
||||
mapTokens: (tokens) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_at
|
||||
? Math.floor((new Date(tokens.expires_at).getTime() - Date.now()) / 1000)
|
||||
: 3600,
|
||||
email: tokens.email,
|
||||
providerSpecificData: {
|
||||
firstName: tokens.firstName,
|
||||
lastName: tokens.lastName,
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
import { PROVIDERS } from "./providers/index.js";
|
||||
|
||||
/**
|
||||
* Get provider handler
|
||||
@@ -943,7 +37,6 @@ export function generateAuthData(providerName, redirectUri) {
|
||||
|
||||
let authUrl;
|
||||
if (provider.flowType === "device_code") {
|
||||
// Device code flow doesn't have auth URL upfront
|
||||
authUrl = null;
|
||||
} else if (provider.flowType === "authorization_code_pkce") {
|
||||
authUrl = provider.buildAuthUrl(provider.config, redirectUri, state, codeChallenge);
|
||||
@@ -1012,18 +105,14 @@ export async function pollForToken(providerName, deviceCode, codeVerifier, extra
|
||||
const result = await provider.pollToken(provider.config, deviceCode, codeVerifier, extraData);
|
||||
|
||||
if (result.ok) {
|
||||
// For device code flows, success is only when we have an access token
|
||||
if (result.data.access_token) {
|
||||
// Call postExchange to get additional data (copilotToken, userInfo, etc.)
|
||||
let extra = null;
|
||||
if (provider.postExchange) {
|
||||
extra = await provider.postExchange(result.data);
|
||||
}
|
||||
return { success: true, tokens: provider.mapTokens(result.data, extra) };
|
||||
} else {
|
||||
// Check if it's still pending authorization
|
||||
if (result.data.error === "authorization_pending" || result.data.error === "slow_down") {
|
||||
// This is not a failure, just still waiting
|
||||
return {
|
||||
success: false,
|
||||
error: result.data.error,
|
||||
@@ -1031,7 +120,6 @@ export async function pollForToken(providerName, deviceCode, codeVerifier, extra
|
||||
pending: result.data.error === "authorization_pending",
|
||||
};
|
||||
} else {
|
||||
// Actual error
|
||||
return {
|
||||
success: false,
|
||||
error: result.data.error || "no_access_token",
|
||||
|
||||
122
src/lib/oauth/providers/antigravity.js
Normal file
122
src/lib/oauth/providers/antigravity.js
Normal file
@@ -0,0 +1,122 @@
|
||||
import { ANTIGRAVITY_CONFIG } from "../constants/oauth.js";
|
||||
|
||||
export const antigravity = {
|
||||
config: ANTIGRAVITY_CONFIG,
|
||||
flowType: "authorization_code",
|
||||
buildAuthUrl: (config, redirectUri, state) => {
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
response_type: "code",
|
||||
redirect_uri: redirectUri,
|
||||
scope: config.scopes.join(" "),
|
||||
state: state,
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
});
|
||||
return `${config.authorizeUrl}?${params.toString()}`;
|
||||
},
|
||||
exchangeToken: async (config, code, redirectUri) => {
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
code: code,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
postExchange: async (tokens) => {
|
||||
const headers = {
|
||||
Authorization: `Bearer ${tokens.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": ANTIGRAVITY_CONFIG.loadCodeAssistUserAgent,
|
||||
"X-Goog-Api-Client": ANTIGRAVITY_CONFIG.loadCodeAssistApiClient,
|
||||
"Client-Metadata": ANTIGRAVITY_CONFIG.loadCodeAssistClientMetadata,
|
||||
};
|
||||
const metadata = {
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
};
|
||||
|
||||
const userInfoRes = await fetch(`${ANTIGRAVITY_CONFIG.userInfoUrl}?alt=json`, {
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||
});
|
||||
const userInfo = userInfoRes.ok ? await userInfoRes.json() : {};
|
||||
|
||||
let projectId = "";
|
||||
let tierId = "legacy-tier";
|
||||
try {
|
||||
const loadRes = await fetch(ANTIGRAVITY_CONFIG.loadCodeAssistEndpoint, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ metadata }),
|
||||
});
|
||||
if (loadRes.ok) {
|
||||
const data = await loadRes.json();
|
||||
projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || "";
|
||||
if (Array.isArray(data.allowedTiers)) {
|
||||
for (const tier of data.allowedTiers) {
|
||||
if (tier.isDefault && tier.id) {
|
||||
tierId = tier.id.trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Failed to load code assist:", e);
|
||||
}
|
||||
|
||||
if (projectId) {
|
||||
try {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const onboardRes = await fetch(ANTIGRAVITY_CONFIG.onboardUserEndpoint, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ tierId, metadata, cloudaicompanionProject: projectId }),
|
||||
});
|
||||
if (onboardRes.ok) {
|
||||
const result = await onboardRes.json();
|
||||
if (result.done === true) {
|
||||
if (result.response?.cloudaicompanionProject) {
|
||||
const respProject = result.response.cloudaicompanionProject;
|
||||
projectId =
|
||||
typeof respProject === "string"
|
||||
? respProject.trim()
|
||||
: respProject.id || projectId;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Failed to onboard user:", e);
|
||||
}
|
||||
}
|
||||
|
||||
return { userInfo, projectId };
|
||||
},
|
||||
mapTokens: (tokens, extra) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
scope: tokens.scope,
|
||||
email: extra?.userInfo?.email,
|
||||
projectId: extra?.projectId,
|
||||
}),
|
||||
};
|
||||
57
src/lib/oauth/providers/claude.js
Normal file
57
src/lib/oauth/providers/claude.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import { CLAUDE_CONFIG } from "../constants/oauth.js";
|
||||
|
||||
export const claude = {
|
||||
config: CLAUDE_CONFIG,
|
||||
flowType: "authorization_code_pkce",
|
||||
buildAuthUrl: (config, redirectUri, state, codeChallenge) => {
|
||||
const params = new URLSearchParams({
|
||||
code: "true",
|
||||
client_id: config.clientId,
|
||||
response_type: "code",
|
||||
redirect_uri: redirectUri,
|
||||
scope: config.scopes.join(" "),
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: config.codeChallengeMethod,
|
||||
state: state,
|
||||
});
|
||||
return `${config.authorizeUrl}?${params.toString()}`;
|
||||
},
|
||||
exchangeToken: async (config, code, redirectUri, codeVerifier, state) => {
|
||||
let authCode = code;
|
||||
let codeState = "";
|
||||
if (authCode.includes("#")) {
|
||||
const parts = authCode.split("#");
|
||||
authCode = parts[0];
|
||||
codeState = parts[1] || "";
|
||||
}
|
||||
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
code: authCode,
|
||||
state: codeState || state,
|
||||
grant_type: "authorization_code",
|
||||
client_id: config.clientId,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: codeVerifier,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
mapTokens: (tokens) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
scope: tokens.scope,
|
||||
}),
|
||||
};
|
||||
77
src/lib/oauth/providers/cline.js
Normal file
77
src/lib/oauth/providers/cline.js
Normal file
@@ -0,0 +1,77 @@
|
||||
import { CLINE_CONFIG } from "../constants/oauth.js";
|
||||
|
||||
export const cline = {
|
||||
config: CLINE_CONFIG,
|
||||
flowType: "authorization_code",
|
||||
buildAuthUrl: (config, redirectUri) => {
|
||||
const params = new URLSearchParams({
|
||||
client_type: "extension",
|
||||
callback_url: redirectUri,
|
||||
redirect_uri: redirectUri,
|
||||
});
|
||||
return `${config.authorizeUrl}?${params.toString()}`;
|
||||
},
|
||||
exchangeToken: async (config, code, redirectUri) => {
|
||||
try {
|
||||
let base64 = code;
|
||||
const padding = 4 - (base64.length % 4);
|
||||
if (padding !== 4) {
|
||||
base64 += "=".repeat(padding);
|
||||
}
|
||||
const decoded = Buffer.from(base64, "base64").toString("utf-8");
|
||||
const lastBrace = decoded.lastIndexOf("}");
|
||||
if (lastBrace === -1) {
|
||||
throw new Error("No JSON found in decoded code");
|
||||
}
|
||||
const jsonStr = decoded.substring(0, lastBrace + 1);
|
||||
const tokenData = JSON.parse(jsonStr);
|
||||
return {
|
||||
access_token: tokenData.accessToken,
|
||||
refresh_token: tokenData.refreshToken,
|
||||
email: tokenData.email,
|
||||
firstName: tokenData.firstName,
|
||||
lastName: tokenData.lastName,
|
||||
expires_at: tokenData.expiresAt,
|
||||
};
|
||||
} catch (e) {
|
||||
const response = await fetch(config.tokenExchangeUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: "authorization_code",
|
||||
code: code,
|
||||
client_type: "extension",
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Cline token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
access_token: data.data?.accessToken || data.accessToken,
|
||||
refresh_token: data.data?.refreshToken || data.refreshToken,
|
||||
email: data.data?.userInfo?.email || "",
|
||||
expires_at: data.data?.expiresAt || data.expiresAt,
|
||||
};
|
||||
}
|
||||
},
|
||||
mapTokens: (tokens) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_at
|
||||
? Math.floor((new Date(tokens.expires_at).getTime() - Date.now()) / 1000)
|
||||
: 3600,
|
||||
email: tokens.email,
|
||||
providerSpecificData: {
|
||||
firstName: tokens.firstName,
|
||||
lastName: tokens.lastName,
|
||||
},
|
||||
}),
|
||||
};
|
||||
53
src/lib/oauth/providers/codex.js
Normal file
53
src/lib/oauth/providers/codex.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import { CODEX_CONFIG } from "../constants/oauth.js";
|
||||
|
||||
export const codex = {
|
||||
config: CODEX_CONFIG,
|
||||
flowType: "authorization_code_pkce",
|
||||
fixedPort: 1455,
|
||||
callbackPath: "/auth/callback",
|
||||
buildAuthUrl: (config, redirectUri, state, codeChallenge) => {
|
||||
const params = {
|
||||
response_type: "code",
|
||||
client_id: config.clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: config.scope,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: config.codeChallengeMethod,
|
||||
...config.extraParams,
|
||||
state: state,
|
||||
};
|
||||
const queryString = Object.entries(params)
|
||||
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
|
||||
.join("&");
|
||||
return `${config.authorizeUrl}?${queryString}`;
|
||||
},
|
||||
exchangeToken: async (config, code, redirectUri, codeVerifier) => {
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: config.clientId,
|
||||
code: code,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: codeVerifier,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
mapTokens: (tokens) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
idToken: tokens.id_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
}),
|
||||
};
|
||||
15
src/lib/oauth/providers/cursor.js
Normal file
15
src/lib/oauth/providers/cursor.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { CURSOR_CONFIG } from "../constants/oauth.js";
|
||||
|
||||
export const cursor = {
|
||||
config: CURSOR_CONFIG,
|
||||
flowType: "import_token",
|
||||
mapTokens: (tokens) => ({
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: null,
|
||||
expiresIn: tokens.expiresIn || 86400,
|
||||
providerSpecificData: {
|
||||
machineId: tokens.machineId,
|
||||
authMethod: "imported",
|
||||
},
|
||||
}),
|
||||
};
|
||||
84
src/lib/oauth/providers/gemini.js
Normal file
84
src/lib/oauth/providers/gemini.js
Normal file
@@ -0,0 +1,84 @@
|
||||
import { GEMINI_CONFIG } from "../constants/oauth.js";
|
||||
|
||||
export const gemini = {
|
||||
config: GEMINI_CONFIG,
|
||||
flowType: "authorization_code",
|
||||
buildAuthUrl: (config, redirectUri, state) => {
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
response_type: "code",
|
||||
redirect_uri: redirectUri,
|
||||
scope: config.scopes.join(" "),
|
||||
state: state,
|
||||
access_type: "offline",
|
||||
prompt: "consent",
|
||||
});
|
||||
return `${config.authorizeUrl}?${params.toString()}`;
|
||||
},
|
||||
exchangeToken: async (config, code, redirectUri) => {
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
code: code,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
postExchange: async (tokens) => {
|
||||
const userInfoRes = await fetch(`${GEMINI_CONFIG.userInfoUrl}?alt=json`, {
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||
});
|
||||
const userInfo = userInfoRes.ok ? await userInfoRes.json() : {};
|
||||
|
||||
let projectId = "";
|
||||
try {
|
||||
const projectRes = await fetch(
|
||||
"https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.access_token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
metadata: {
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (projectRes.ok) {
|
||||
const data = await projectRes.json();
|
||||
projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || "";
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Failed to fetch project ID:", e);
|
||||
}
|
||||
|
||||
return { userInfo, projectId };
|
||||
},
|
||||
mapTokens: (tokens, extra) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
scope: tokens.scope,
|
||||
email: extra?.userInfo?.email,
|
||||
projectId: extra?.projectId,
|
||||
}),
|
||||
};
|
||||
89
src/lib/oauth/providers/github.js
Normal file
89
src/lib/oauth/providers/github.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import { GITHUB_CONFIG } from "../constants/oauth.js";
|
||||
|
||||
export const github = {
|
||||
config: GITHUB_CONFIG,
|
||||
flowType: "device_code",
|
||||
requestDeviceCode: async (config) => {
|
||||
const response = await fetch(config.deviceCodeUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
scope: config.scopes,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Device code request failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
pollToken: async (config, deviceCode) => {
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
device_code: deviceCode,
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
}),
|
||||
});
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (e) {
|
||||
const text = await response.text();
|
||||
data = { error: "invalid_response", error_description: text };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
data: data,
|
||||
};
|
||||
},
|
||||
postExchange: async (tokens) => {
|
||||
const copilotRes = await fetch(GITHUB_CONFIG.copilotTokenUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.access_token}`,
|
||||
Accept: "application/json",
|
||||
"X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion,
|
||||
"User-Agent": GITHUB_CONFIG.userAgent,
|
||||
},
|
||||
});
|
||||
const copilotToken = copilotRes.ok ? await copilotRes.json() : {};
|
||||
|
||||
const userRes = await fetch(GITHUB_CONFIG.userInfoUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens.access_token}`,
|
||||
Accept: "application/json",
|
||||
"X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion,
|
||||
"User-Agent": GITHUB_CONFIG.userAgent,
|
||||
},
|
||||
});
|
||||
const userInfo = userRes.ok ? await userRes.json() : {};
|
||||
|
||||
return { copilotToken, userInfo };
|
||||
},
|
||||
mapTokens: (tokens, extra) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
providerSpecificData: {
|
||||
copilotToken: extra?.copilotToken?.token,
|
||||
copilotTokenExpiresAt: extra?.copilotToken?.expires_at,
|
||||
githubUserId: extra?.userInfo?.id,
|
||||
githubLogin: extra?.userInfo?.login,
|
||||
githubName: extra?.userInfo?.name,
|
||||
githubEmail: extra?.userInfo?.email,
|
||||
},
|
||||
}),
|
||||
};
|
||||
59
src/lib/oauth/providers/iflow.js
Normal file
59
src/lib/oauth/providers/iflow.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import { IFLOW_CONFIG } from "../constants/oauth.js";
|
||||
|
||||
export const iflow = {
|
||||
config: IFLOW_CONFIG,
|
||||
flowType: "authorization_code",
|
||||
buildAuthUrl: (config, redirectUri, state) => {
|
||||
const params = new URLSearchParams({
|
||||
loginMethod: config.extraParams.loginMethod,
|
||||
type: config.extraParams.type,
|
||||
redirect: redirectUri,
|
||||
state: state,
|
||||
client_id: config.clientId,
|
||||
});
|
||||
return `${config.authorizeUrl}?${params.toString()}`;
|
||||
},
|
||||
exchangeToken: async (config, code, redirectUri) => {
|
||||
const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64");
|
||||
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
Authorization: `Basic ${basicAuth}`,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: code,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Token exchange failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
postExchange: async (tokens) => {
|
||||
const userInfoRes = await fetch(
|
||||
`${IFLOW_CONFIG.userInfoUrl}?accessToken=${encodeURIComponent(tokens.access_token)}`,
|
||||
{ headers: { Accept: "application/json" } }
|
||||
);
|
||||
const result = userInfoRes.ok ? await userInfoRes.json() : {};
|
||||
const userInfo = result.success ? result.data : {};
|
||||
return { userInfo };
|
||||
},
|
||||
mapTokens: (tokens, extra) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
apiKey: extra?.userInfo?.apiKey,
|
||||
email: extra?.userInfo?.email || extra?.userInfo?.phone,
|
||||
displayName: extra?.userInfo?.nickname || extra?.userInfo?.name,
|
||||
}),
|
||||
};
|
||||
41
src/lib/oauth/providers/index.js
Normal file
41
src/lib/oauth/providers/index.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* OAuth Provider Registry — Extracted from monolithic providers.js
|
||||
*
|
||||
* Each provider is now defined in its own module under providers/.
|
||||
* This index re-exports the full PROVIDERS map and utility functions.
|
||||
*
|
||||
* Provider modules follow the interface:
|
||||
* { config, flowType, buildAuthUrl?, exchangeToken?, requestDeviceCode?, pollToken?, postExchange?, mapTokens }
|
||||
*
|
||||
* @module lib/oauth/providers/index
|
||||
*/
|
||||
|
||||
import { claude } from "./claude.js";
|
||||
import { codex } from "./codex.js";
|
||||
import { gemini } from "./gemini.js";
|
||||
import { antigravity } from "./antigravity.js";
|
||||
import { iflow } from "./iflow.js";
|
||||
import { qwen } from "./qwen.js";
|
||||
import { kimiCoding } from "./kimi-coding.js";
|
||||
import { github } from "./github.js";
|
||||
import { kiro } from "./kiro.js";
|
||||
import { cursor } from "./cursor.js";
|
||||
import { kilocode } from "./kilocode.js";
|
||||
import { cline } from "./cline.js";
|
||||
|
||||
export const PROVIDERS = {
|
||||
claude,
|
||||
codex,
|
||||
"gemini-cli": gemini,
|
||||
antigravity,
|
||||
iflow,
|
||||
qwen,
|
||||
"kimi-coding": kimiCoding,
|
||||
github,
|
||||
kiro,
|
||||
cursor,
|
||||
kilocode,
|
||||
cline,
|
||||
};
|
||||
|
||||
export default PROVIDERS;
|
||||
77
src/lib/oauth/providers/kilocode.js
Normal file
77
src/lib/oauth/providers/kilocode.js
Normal file
@@ -0,0 +1,77 @@
|
||||
import { KILOCODE_CONFIG } from "../constants/oauth.js";
|
||||
|
||||
export const kilocode = {
|
||||
config: KILOCODE_CONFIG,
|
||||
flowType: "device_code",
|
||||
requestDeviceCode: async (config) => {
|
||||
const response = await fetch(config.initiateUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
throw new Error("Too many pending authorization requests. Please try again later.");
|
||||
}
|
||||
const error = await response.text();
|
||||
throw new Error(`Device auth initiation failed: ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
device_code: data.code,
|
||||
user_code: data.code,
|
||||
verification_uri: data.verificationUrl,
|
||||
verification_uri_complete: data.verificationUrl,
|
||||
expires_in: data.expiresIn || 300,
|
||||
interval: 3,
|
||||
};
|
||||
},
|
||||
pollToken: async (config, deviceCode) => {
|
||||
const response = await fetch(`${config.pollUrlBase}/${deviceCode}`);
|
||||
|
||||
if (response.status === 202) {
|
||||
return { ok: false, data: { error: "authorization_pending" } };
|
||||
}
|
||||
if (response.status === 403) {
|
||||
return {
|
||||
ok: false,
|
||||
data: { error: "access_denied", error_description: "Authorization denied by user" },
|
||||
};
|
||||
}
|
||||
if (response.status === 410) {
|
||||
return {
|
||||
ok: false,
|
||||
data: { error: "expired_token", error_description: "Authorization code expired" },
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
data: { error: "poll_failed", error_description: `Poll failed: ${response.status}` },
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status === "approved" && data.token) {
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
access_token: data.token,
|
||||
_userEmail: data.userEmail,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: false, data: { error: "authorization_pending" } };
|
||||
},
|
||||
mapTokens: (tokens) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: null,
|
||||
expiresIn: null,
|
||||
email: tokens._userEmail,
|
||||
}),
|
||||
};
|
||||
67
src/lib/oauth/providers/kimi-coding.js
Normal file
67
src/lib/oauth/providers/kimi-coding.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import { KIMI_CODING_CONFIG } from "../constants/oauth.js";
|
||||
|
||||
export const kimiCoding = {
|
||||
config: KIMI_CODING_CONFIG,
|
||||
flowType: "device_code",
|
||||
requestDeviceCode: async (config) => {
|
||||
const response = await fetch(config.deviceCodeUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Device code request failed: ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
device_code: data.device_code,
|
||||
user_code: data.user_code,
|
||||
verification_uri: data.verification_uri || `https://www.kimi.com/code/authorize_device`,
|
||||
verification_uri_complete:
|
||||
data.verification_uri_complete ||
|
||||
`https://www.kimi.com/code/authorize_device?user_code=${data.user_code}`,
|
||||
expires_in: data.expires_in,
|
||||
interval: data.interval || 5,
|
||||
};
|
||||
},
|
||||
pollToken: async (config, deviceCode) => {
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
client_id: config.clientId,
|
||||
device_code: deviceCode,
|
||||
}),
|
||||
});
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (e) {
|
||||
const text = await response.text();
|
||||
data = { error: "invalid_response", error_description: text };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
data: data,
|
||||
};
|
||||
},
|
||||
mapTokens: (tokens) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
}),
|
||||
};
|
||||
115
src/lib/oauth/providers/kiro.js
Normal file
115
src/lib/oauth/providers/kiro.js
Normal file
@@ -0,0 +1,115 @@
|
||||
import { KIRO_CONFIG } from "../constants/oauth.js";
|
||||
|
||||
export const kiro = {
|
||||
config: KIRO_CONFIG,
|
||||
flowType: "device_code",
|
||||
requestDeviceCode: async (config) => {
|
||||
// Step 1: Register client with AWS SSO OIDC
|
||||
const registerRes = await fetch(config.registerClientUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientName: config.clientName,
|
||||
clientType: config.clientType,
|
||||
scopes: config.scopes,
|
||||
grantTypes: config.grantTypes,
|
||||
issuerUrl: config.issuerUrl,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!registerRes.ok) {
|
||||
const error = await registerRes.text();
|
||||
throw new Error(`Client registration failed: ${error}`);
|
||||
}
|
||||
|
||||
const clientInfo = await registerRes.json();
|
||||
|
||||
// Step 2: Request device authorization
|
||||
const deviceRes = await fetch(config.deviceAuthUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientId: clientInfo.clientId,
|
||||
clientSecret: clientInfo.clientSecret,
|
||||
startUrl: config.startUrl,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!deviceRes.ok) {
|
||||
const error = await deviceRes.text();
|
||||
throw new Error(`Device authorization failed: ${error}`);
|
||||
}
|
||||
|
||||
const deviceData = await deviceRes.json();
|
||||
|
||||
return {
|
||||
device_code: deviceData.deviceCode,
|
||||
user_code: deviceData.userCode,
|
||||
verification_uri: deviceData.verificationUri,
|
||||
verification_uri_complete: deviceData.verificationUriComplete,
|
||||
expires_in: deviceData.expiresIn,
|
||||
interval: deviceData.interval || 5,
|
||||
_clientId: clientInfo.clientId,
|
||||
_clientSecret: clientInfo.clientSecret,
|
||||
};
|
||||
},
|
||||
pollToken: async (config, deviceCode, codeVerifier, extraData) => {
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientId: extraData?._clientId,
|
||||
clientSecret: extraData?._clientSecret,
|
||||
deviceCode: deviceCode,
|
||||
grantType: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
}),
|
||||
});
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch (e) {
|
||||
const text = await response.text();
|
||||
data = { error: "invalid_response", error_description: text };
|
||||
}
|
||||
|
||||
if (data.accessToken) {
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
access_token: data.accessToken,
|
||||
refresh_token: data.refreshToken,
|
||||
expires_in: data.expiresIn,
|
||||
_clientId: extraData?._clientId,
|
||||
_clientSecret: extraData?._clientSecret,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
data: {
|
||||
error: data.error || "authorization_pending",
|
||||
error_description: data.error_description || data.message,
|
||||
},
|
||||
};
|
||||
},
|
||||
mapTokens: (tokens) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
providerSpecificData: {
|
||||
clientId: tokens._clientId,
|
||||
clientSecret: tokens._clientSecret,
|
||||
},
|
||||
}),
|
||||
};
|
||||
54
src/lib/oauth/providers/qwen.js
Normal file
54
src/lib/oauth/providers/qwen.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import { QWEN_CONFIG } from "../constants/oauth.js";
|
||||
|
||||
export const qwen = {
|
||||
config: QWEN_CONFIG,
|
||||
flowType: "device_code",
|
||||
requestDeviceCode: async (config, codeChallenge) => {
|
||||
const response = await fetch(config.deviceCodeUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
scope: config.scope,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: config.codeChallengeMethod,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`Device code request failed: ${error}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
},
|
||||
pollToken: async (config, deviceCode, codeVerifier) => {
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
||||
client_id: config.clientId,
|
||||
device_code: deviceCode,
|
||||
code_verifier: codeVerifier,
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
data: await response.json(),
|
||||
};
|
||||
},
|
||||
mapTokens: (tokens) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
providerSpecificData: { resourceUrl: tokens.resource_url },
|
||||
}),
|
||||
};
|
||||
16
src/proxy.js
16
src/proxy.js
@@ -1,7 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { jwtVerify } from "jose";
|
||||
import { fetchWithTimeout } from "./shared/utils/fetchTimeout.js";
|
||||
import { generateRequestId } from "./shared/utils/requestId.js";
|
||||
import { getSettings } from "./lib/localDb.js";
|
||||
|
||||
// FASE-01: Fail-fast — no hardcoded fallback. Server must have JWT_SECRET configured.
|
||||
if (!process.env.JWT_SECRET) {
|
||||
@@ -42,25 +42,22 @@ export async function proxy(request) {
|
||||
}
|
||||
}
|
||||
|
||||
const origin = request.nextUrl.origin;
|
||||
try {
|
||||
// Pipeline: Use fetchWithTimeout instead of bare fetch
|
||||
const res = await fetchWithTimeout(`${origin}/api/settings`, { timeoutMs: 5000 });
|
||||
const data = await res.json();
|
||||
// Direct import — no HTTP self-fetch overhead
|
||||
const settings = await getSettings();
|
||||
// Skip auth if login is not required
|
||||
if (data.requireLogin === false) {
|
||||
if (settings.requireLogin === false) {
|
||||
return response;
|
||||
}
|
||||
// Skip auth if no password has been set yet (fresh install)
|
||||
// This prevents an unresolvable loop where requireLogin=true but no password exists
|
||||
if (!data.hasPassword) {
|
||||
if (!settings.password) {
|
||||
return response;
|
||||
}
|
||||
} catch (err) {
|
||||
// FASE-01: Log settings fetch errors instead of silencing them
|
||||
console.error("[Middleware] settings_error: Settings fetch failed:", err.message, {
|
||||
console.error("[Middleware] settings_error: Settings read failed:", err.message, {
|
||||
path: pathname,
|
||||
origin,
|
||||
requestId,
|
||||
});
|
||||
// On error, require login
|
||||
@@ -79,4 +76,3 @@ export async function proxy(request) {
|
||||
export const config = {
|
||||
matcher: ["/", "/dashboard/:path*"],
|
||||
};
|
||||
|
||||
|
||||
@@ -35,14 +35,16 @@ async function startServer() {
|
||||
// Log server start event to audit log
|
||||
logAuditEvent({ action: "server.start", details: { timestamp: new Date().toISOString() } });
|
||||
} catch (error) {
|
||||
console.log("Error initializing cloud sync:", error);
|
||||
console.error("[FATAL] Error initializing cloud sync:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Start the server initialization
|
||||
startServer().catch(console.log);
|
||||
startServer().catch((err) => {
|
||||
console.error("[FATAL] Server initialization failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Export for use as module if needed
|
||||
export default startServer;
|
||||
|
||||
|
||||
@@ -8,9 +8,19 @@
|
||||
*
|
||||
* States: CLOSED → OPEN → HALF_OPEN → CLOSED
|
||||
*
|
||||
* State is persisted in SQLite via domainState.js for restart durability.
|
||||
*
|
||||
* @module shared/utils/circuitBreaker
|
||||
*/
|
||||
|
||||
import {
|
||||
saveCircuitBreakerState,
|
||||
loadCircuitBreakerState,
|
||||
loadAllCircuitBreakerStates,
|
||||
deleteCircuitBreakerState,
|
||||
deleteAllCircuitBreakerStates,
|
||||
} from "../../lib/db/domainState.js";
|
||||
|
||||
const STATE = {
|
||||
CLOSED: "CLOSED",
|
||||
OPEN: "OPEN",
|
||||
@@ -44,6 +54,50 @@ export class CircuitBreaker {
|
||||
this.successCount = 0;
|
||||
this.lastFailureTime = null;
|
||||
this.halfOpenAllowed = 0;
|
||||
|
||||
// Try to restore state from DB
|
||||
this._restoreFromDb();
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore state from SQLite if available.
|
||||
* @private
|
||||
*/
|
||||
_restoreFromDb() {
|
||||
try {
|
||||
const saved = loadCircuitBreakerState(this.name);
|
||||
if (saved) {
|
||||
this.state = saved.state;
|
||||
this.failureCount = saved.failureCount;
|
||||
this.lastFailureTime = saved.lastFailureTime;
|
||||
if (this.state === STATE.HALF_OPEN) {
|
||||
this.halfOpenAllowed = this.halfOpenRequests;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// DB may not be ready yet (build phase)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist current state to SQLite.
|
||||
* @private
|
||||
*/
|
||||
_persistToDb() {
|
||||
try {
|
||||
saveCircuitBreakerState(this.name, {
|
||||
state: this.state,
|
||||
failureCount: this.failureCount,
|
||||
lastFailureTime: this.lastFailureTime,
|
||||
options: {
|
||||
failureThreshold: this.failureThreshold,
|
||||
resetTimeout: this.resetTimeout,
|
||||
halfOpenRequests: this.halfOpenRequests,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Non-critical: in-memory still works
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,6 +177,7 @@ export class CircuitBreaker {
|
||||
this.failureCount = 0;
|
||||
this.successCount = 0;
|
||||
this.lastFailureTime = null;
|
||||
this._persistToDb();
|
||||
}
|
||||
|
||||
// ─── Internal Methods ────────────────────────
|
||||
@@ -135,6 +190,7 @@ export class CircuitBreaker {
|
||||
}
|
||||
// In CLOSED state, just reset failure count
|
||||
this.failureCount = 0;
|
||||
this._persistToDb();
|
||||
}
|
||||
|
||||
_onFailure() {
|
||||
@@ -146,6 +202,7 @@ export class CircuitBreaker {
|
||||
} else if (this.failureCount >= this.failureThreshold) {
|
||||
this._transition(STATE.OPEN);
|
||||
}
|
||||
this._persistToDb();
|
||||
}
|
||||
|
||||
_shouldAttemptReset() {
|
||||
@@ -210,7 +267,34 @@ export function getCircuitBreaker(name, options) {
|
||||
* @returns {Array<{ name: string, state: string, failureCount: number }>}
|
||||
*/
|
||||
export function getAllCircuitBreakerStatuses() {
|
||||
// Merge registry with any persisted states not yet loaded
|
||||
try {
|
||||
const persisted = loadAllCircuitBreakerStates();
|
||||
for (const cb of persisted) {
|
||||
if (!registry.has(cb.name)) {
|
||||
// Load the breaker (will restore from DB in constructor)
|
||||
getCircuitBreaker(cb.name);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Use registry only
|
||||
}
|
||||
return Array.from(registry.values()).map((cb) => cb.getStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all circuit breakers (for admin/testing).
|
||||
*/
|
||||
export function resetAllCircuitBreakers() {
|
||||
for (const cb of registry.values()) {
|
||||
cb.reset();
|
||||
}
|
||||
registry.clear();
|
||||
try {
|
||||
deleteAllCircuitBreakerStates();
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
|
||||
export { STATE };
|
||||
|
||||
@@ -112,7 +112,9 @@ const history = [];
|
||||
* @param {RequestTelemetry} telemetry
|
||||
*/
|
||||
export function recordTelemetry(telemetry) {
|
||||
history.push(telemetry.getSummary());
|
||||
const summary = telemetry.getSummary();
|
||||
summary.recordedAt = Date.now();
|
||||
history.push(summary);
|
||||
while (history.length > MAX_HISTORY) {
|
||||
history.shift();
|
||||
}
|
||||
@@ -138,8 +140,7 @@ function percentile(sorted, p) {
|
||||
export function getTelemetrySummary(windowMs = 300000) {
|
||||
const cutoff = Date.now() - windowMs;
|
||||
const recent = history.filter((h) => {
|
||||
// Approximate: use most recent entries
|
||||
return true; // We don't store timestamps in history, so use all
|
||||
return (h.recordedAt || 0) >= cutoff;
|
||||
});
|
||||
|
||||
if (recent.length === 0) {
|
||||
|
||||
Reference in New Issue
Block a user