diff --git a/.env.example b/.env.example index 8f1178a210..953c1962fc 100644 --- a/.env.example +++ b/.env.example @@ -9,8 +9,8 @@ JWT_SECRET= # Generate with: openssl rand -hex 32 API_KEY_SECRET= -# Initial admin password (change after first login) -INITIAL_PASSWORD=123456 +# Initial admin password — CHANGE THIS before first use! +INITIAL_PASSWORD=CHANGEME DATA_DIR=/var/lib/omniroute # Storage (SQLite) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d24c9ff63..60ac81c4d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +#### Phase 5 — Foundation & Security + +- **Domain State Persistence** — SQLite-backed persistence for 4 domain modules (fallback chains, budgets, cost history, lockout state, circuit breakers) via `domainState.js` with 5 new tables in `core.js` +- **Write-Through Cache** — `fallbackPolicy.js`, `costRules.js`, `lockoutPolicy.js`, and `circuitBreaker.js` now use in-memory Map + SQLite write-through for state survival across restarts +- **Race Condition Fix** — `route.js` `ensureInitialized()` replaced boolean flag with Promise-based singleton to prevent parallel initialization + +#### Phase 6 — Architecture Refactoring + +- **OAuth Provider Extraction** — Monolithic `providers.js` (1051 → 144 lines) split into 12 individual modules under `src/lib/oauth/providers/` with a thin wrapper + registry index +- **Policy Engine** — Centralized request evaluation against lockout, budget, and fallback policies (`policyEngine.js`) with `evaluateRequest()` and `evaluateFirstAllowed()` entry points +- **Deterministic Round-Robin** — `comboResolver.js` now uses persistent `Map` counter per combo instead of time-based modulo +- **Telemetry Window Accuracy** — `requestTelemetry.js` now adds `recordedAt` timestamps to history entries and accurately filters by `windowMs` + +#### Tests + +- 22 new tests: `domain-persistence.test.mjs` (16 tests), `policy-engine.test.mjs` (6 tests) +- Test isolation fix for `observability-fase04.test.mjs` — unique CircuitBreaker names prevent DB state bleed +- Total: **295+ tests passing** (up from 273 in v0.3.0) + +### Fixed + +- **Proxy decoupling** — `proxy.js` no longer self-fetches `/api/settings`; imports `getSettings()` directly from `localDb` +- **Default password** — `.env.example` `INITIAL_PASSWORD` changed from `123456` → `CHANGEME` +- **Server init error handling** — `server-init.js` now uses `console.error` + `process.exit(1)` instead of silent `console.log` + --- ## [0.3.0] — 2026-02-15 diff --git a/README.md b/README.md index c9dba362f5..20ad8620e9 100644 --- a/README.md +++ b/README.md @@ -1100,15 +1100,15 @@ Types: `chat`, `embedding`, `image`. Custom models are flagged with `custom: tru - **Framework**: Next.js 16 - **UI**: React 19 + Tailwind CSS 4 - **Charts**: Recharts (SVG, accessible) -- **Database**: LowDB (JSON file-based) +- **Database**: LowDB (JSON file-based) + SQLite (domain state persistence) - **Streaming**: Server-Sent Events (SSE) - **Auth**: OAuth 2.0 (PKCE) + JWT + API Keys -- **Testing**: Playwright (E2E) + Node.js test runner (273+ unit tests) +- **Testing**: Playwright (E2E) + Node.js test runner (295+ unit tests) - **Monorepo**: npm workspaces (`@omniroute/open-sse`) - **CI/CD**: GitHub Actions (auto npm publish on release) + Dependabot - **Package**: [npmjs.com/package/omniroute](https://www.npmjs.com/package/omniroute) - **Compliance**: `/terms` and `/privacy` pages + audit log -- **Resilience**: Circuit breaker, exponential backoff, anti-thundering herd +- **Resilience**: Circuit breaker, exponential backoff, anti-thundering herd, policy engine - **Observability**: Request telemetry (p50/p95/p99), correlation IDs, structured error codes --- diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 62301316ba..8ecaadeb9e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -29,11 +29,14 @@ Core capabilities: - Anti-thundering herd protection with mutex locking - Signature-based request deduplication cache - Domain layer: model availability, cost rules, fallback policy, lockout policy +- Domain state persistence (SQLite write-through cache for fallbacks, budgets, lockouts, circuit breakers) +- Policy engine for centralized request evaluation (lockout → budget → fallback) - Request telemetry with p50/p95/p99 latency aggregation - Correlation ID (X-Request-Id) for end-to-end tracing - Compliance audit logging with opt-out per API key - Eval framework for LLM quality assurance - Resilience UI dashboard with real-time circuit breaker status +- Modular OAuth providers (12 individual modules under `src/lib/oauth/providers/`) Primary runtime model: @@ -198,12 +201,20 @@ Domain layer modules: - Fallback policy: `src/lib/domain/fallbackPolicy.js` - Combo resolver: `src/lib/domain/comboResolver.js` - Lockout policy: `src/lib/domain/lockoutPolicy.js` +- Policy engine: `src/domain/policyEngine.js` — centralized lockout → budget → fallback evaluation - Error codes catalog: `src/lib/domain/errorCodes.js` - Request ID: `src/lib/domain/requestId.js` - Fetch timeout: `src/lib/domain/fetchTimeout.js` - Request telemetry: `src/lib/domain/requestTelemetry.js` - Compliance/audit: `src/lib/domain/compliance/index.js` - Eval runner: `src/lib/domain/evalRunner.js` +- Domain state persistence: `src/lib/db/domainState.js` — SQLite CRUD for fallback chains, budgets, cost history, lockout state, circuit breakers + +OAuth provider modules (12 individual files under `src/lib/oauth/providers/`): + +- Registry index: `src/lib/oauth/providers/index.js` +- Individual providers: `claude.js`, `codex.js`, `gemini.js`, `antigravity.js`, `iflow.js`, `qwen.js`, `kimi-coding.js`, `github.js`, `kiro.js`, `cursor.js`, `kilocode.js`, `cline.js` +- Thin wrapper: `src/lib/oauth/providers.js` — re-exports from individual modules ## 3) Persistence Layer @@ -220,6 +231,12 @@ Usage DB: - follows same base directory policy as `localDb` (`DATA_DIR`, then `XDG_CONFIG_HOME/omniroute` when set) - decomposed into focused sub-modules: `migrations.js`, `usageHistory.js`, `costCalculator.js`, `usageStats.js`, `callLogs.js` +Domain State DB (SQLite): + +- `src/lib/db/domainState.js` — CRUD operations for domain state +- Tables (created in `src/lib/db/core.js`): `domain_fallback_chains`, `domain_budgets`, `domain_cost_history`, `domain_lockout_state`, `domain_circuit_breakers` +- Write-through cache pattern: in-memory Maps are authoritative at runtime; mutations are written synchronously to SQLite; state is restored from DB on cold start + ## 4) Auth + Security Surfaces - Dashboard cookie auth: `src/proxy.js`, `src/app/api/auth/login/route.js` diff --git a/src/app/api/v1/chat/completions/route.js b/src/app/api/v1/chat/completions/route.js index af532b590b..2c223984c3 100644 --- a/src/app/api/v1/chat/completions/route.js +++ b/src/app/api/v1/chat/completions/route.js @@ -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; } /** diff --git a/src/domain/comboResolver.js b/src/domain/comboResolver.js index 2974955048..572ee4c386 100644 --- a/src/domain/comboResolver.js +++ b/src/domain/comboResolver.js @@ -13,6 +13,9 @@ * @typedef {import('./types.js').Combo} Combo */ +/** @type {Map} 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)]; } diff --git a/src/domain/costRules.js b/src/domain/costRules.js index f464e978aa..ce7e9bc926 100644 --- a/src/domain/costRules.js +++ b/src/domain/costRules.js @@ -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} API key ID → budget config */ +/** @type {Map} In-memory cache for budgets */ const budgets = new Map(); -/** @type {Map} 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 + } } diff --git a/src/domain/fallbackPolicy.js b/src/domain/fallbackPolicy.js index 7a7268c9b2..32d941f8ec 100644 --- a/src/domain/fallbackPolicy.js +++ b/src/domain/fallbackPolicy.js @@ -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} model → fallback chain */ +/** @type {Map} 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} */ export function getAllFallbackChains() { + ensureLoaded(); /** @type {Record} */ 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 + } } diff --git a/src/domain/lockoutPolicy.js b/src/domain/lockoutPolicy.js index 21c367a82c..04548d4f6c 100644 --- a/src/domain/lockoutPolicy.js +++ b/src/domain/lockoutPolicy.js @@ -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} */ -const lockoutState = new Map(); +/** @type {Map} 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, diff --git a/src/domain/policyEngine.js b/src/domain/policyEngine.js new file mode 100644 index 0000000000..e4b6530d05 --- /dev/null +++ b/src/domain/policyEngine.js @@ -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} 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 }; +} diff --git a/src/lib/db/core.js b/src/lib/db/core.js index 58ac05ace2..ca53d5852a 100644 --- a/src/lib/db/core.js +++ b/src/lib/db/core.js @@ -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 ──────────────── diff --git a/src/lib/db/domainState.js b/src/lib/db/domainState.js new file mode 100644 index 0000000000..ff14d175d9 --- /dev/null +++ b/src/lib/db/domainState.js @@ -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>} + */ +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(); +} diff --git a/src/lib/oauth/providers.js b/src/lib/oauth/providers.js index f5cf562da3..a25ddaf1dd 100644 --- a/src/lib/oauth/providers.js +++ b/src/lib/oauth/providers.js @@ -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", diff --git a/src/lib/oauth/providers/antigravity.js b/src/lib/oauth/providers/antigravity.js new file mode 100644 index 0000000000..df063e5844 --- /dev/null +++ b/src/lib/oauth/providers/antigravity.js @@ -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, + }), +}; diff --git a/src/lib/oauth/providers/claude.js b/src/lib/oauth/providers/claude.js new file mode 100644 index 0000000000..47546352d6 --- /dev/null +++ b/src/lib/oauth/providers/claude.js @@ -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, + }), +}; diff --git a/src/lib/oauth/providers/cline.js b/src/lib/oauth/providers/cline.js new file mode 100644 index 0000000000..5b525c9851 --- /dev/null +++ b/src/lib/oauth/providers/cline.js @@ -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, + }, + }), +}; diff --git a/src/lib/oauth/providers/codex.js b/src/lib/oauth/providers/codex.js new file mode 100644 index 0000000000..979887860d --- /dev/null +++ b/src/lib/oauth/providers/codex.js @@ -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, + }), +}; diff --git a/src/lib/oauth/providers/cursor.js b/src/lib/oauth/providers/cursor.js new file mode 100644 index 0000000000..fdec0957f7 --- /dev/null +++ b/src/lib/oauth/providers/cursor.js @@ -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", + }, + }), +}; diff --git a/src/lib/oauth/providers/gemini.js b/src/lib/oauth/providers/gemini.js new file mode 100644 index 0000000000..f534f35cff --- /dev/null +++ b/src/lib/oauth/providers/gemini.js @@ -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, + }), +}; diff --git a/src/lib/oauth/providers/github.js b/src/lib/oauth/providers/github.js new file mode 100644 index 0000000000..da055d806d --- /dev/null +++ b/src/lib/oauth/providers/github.js @@ -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, + }, + }), +}; diff --git a/src/lib/oauth/providers/iflow.js b/src/lib/oauth/providers/iflow.js new file mode 100644 index 0000000000..a317ee24fd --- /dev/null +++ b/src/lib/oauth/providers/iflow.js @@ -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, + }), +}; diff --git a/src/lib/oauth/providers/index.js b/src/lib/oauth/providers/index.js new file mode 100644 index 0000000000..cbf672a7ec --- /dev/null +++ b/src/lib/oauth/providers/index.js @@ -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; diff --git a/src/lib/oauth/providers/kilocode.js b/src/lib/oauth/providers/kilocode.js new file mode 100644 index 0000000000..ac60d735fc --- /dev/null +++ b/src/lib/oauth/providers/kilocode.js @@ -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, + }), +}; diff --git a/src/lib/oauth/providers/kimi-coding.js b/src/lib/oauth/providers/kimi-coding.js new file mode 100644 index 0000000000..5748bdaec7 --- /dev/null +++ b/src/lib/oauth/providers/kimi-coding.js @@ -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, + }), +}; diff --git a/src/lib/oauth/providers/kiro.js b/src/lib/oauth/providers/kiro.js new file mode 100644 index 0000000000..b0f1b1455f --- /dev/null +++ b/src/lib/oauth/providers/kiro.js @@ -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, + }, + }), +}; diff --git a/src/lib/oauth/providers/qwen.js b/src/lib/oauth/providers/qwen.js new file mode 100644 index 0000000000..14b1df7ecc --- /dev/null +++ b/src/lib/oauth/providers/qwen.js @@ -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 }, + }), +}; diff --git a/src/proxy.js b/src/proxy.js index d720a22ff6..6140c1e428 100644 --- a/src/proxy.js +++ b/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*"], }; - diff --git a/src/server-init.js b/src/server-init.js index ba179420e2..aa05a7fb63 100644 --- a/src/server-init.js +++ b/src/server-init.js @@ -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; - diff --git a/src/shared/utils/circuitBreaker.js b/src/shared/utils/circuitBreaker.js index ddeb29fe6c..f87deb436d 100644 --- a/src/shared/utils/circuitBreaker.js +++ b/src/shared/utils/circuitBreaker.js @@ -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 }; diff --git a/src/shared/utils/requestTelemetry.js b/src/shared/utils/requestTelemetry.js index 5b4b6b3e97..8bf910e5e5 100644 --- a/src/shared/utils/requestTelemetry.js +++ b/src/shared/utils/requestTelemetry.js @@ -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) { diff --git a/tests/unit/domain-persistence.test.mjs b/tests/unit/domain-persistence.test.mjs new file mode 100644 index 0000000000..c112777d3e --- /dev/null +++ b/tests/unit/domain-persistence.test.mjs @@ -0,0 +1,345 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import Database from "better-sqlite3"; +import path from "node:path"; +import fs from "node:fs"; +import os from "node:os"; + +// ─── Test Setup: Use temp DB ──────────────────────── + +let tmpDir; +let originalEnv; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omni-domain-test-")); + originalEnv = process.env.DATA_DIR; + process.env.DATA_DIR = tmpDir; +}); + +afterEach(() => { + process.env.DATA_DIR = originalEnv; + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +// ─── Fallback Policy Tests ──────────────────────── + +describe("fallbackPolicy persistence", () => { + it("should register and resolve a fallback chain", async () => { + const { registerFallback, resolveFallbackChain, hasFallback, resetAllFallbacks } = + await import("../../src/domain/fallbackPolicy.js"); + + resetAllFallbacks(); + + registerFallback("gpt-4o", [ + { provider: "openai", priority: 0, enabled: true }, + { provider: "anthropic", priority: 1, enabled: true }, + { provider: "disabled-one", priority: 2, enabled: false }, + ]); + + assert.ok(hasFallback("gpt-4o")); + + const chain = resolveFallbackChain("gpt-4o"); + assert.equal(chain.length, 2); // disabled-one excluded + assert.equal(chain[0].provider, "openai"); + assert.equal(chain[1].provider, "anthropic"); + + resetAllFallbacks(); + }); + + it("should resolve with exclusions", async () => { + const { registerFallback, resolveFallbackChain, resetAllFallbacks } = + await import("../../src/domain/fallbackPolicy.js"); + + resetAllFallbacks(); + + registerFallback("claude-3", [ + { provider: "anthropic", priority: 0 }, + { provider: "openai", priority: 1 }, + ]); + + const chain = resolveFallbackChain("claude-3", ["anthropic"]); + assert.equal(chain.length, 1); + assert.equal(chain[0].provider, "openai"); + + resetAllFallbacks(); + }); + + it("should get next fallback correctly", async () => { + const { registerFallback, getNextFallback, resetAllFallbacks } = + await import("../../src/domain/fallbackPolicy.js"); + + resetAllFallbacks(); + + registerFallback("test-model", [ + { provider: "p1", priority: 0 }, + { provider: "p2", priority: 1 }, + ]); + + assert.equal(getNextFallback("test-model"), "p1"); + assert.equal(getNextFallback("test-model", ["p1"]), "p2"); + assert.equal(getNextFallback("test-model", ["p1", "p2"]), null); + + // Non-existing model + assert.equal(getNextFallback("no-model"), null); + + resetAllFallbacks(); + }); + + it("should remove fallback chain", async () => { + const { registerFallback, removeFallback, hasFallback, resetAllFallbacks } = + await import("../../src/domain/fallbackPolicy.js"); + + resetAllFallbacks(); + + registerFallback("gpt-4", [{ provider: "openai" }]); + assert.ok(hasFallback("gpt-4")); + + removeFallback("gpt-4"); + assert.ok(!hasFallback("gpt-4")); + + resetAllFallbacks(); + }); + + it("should get all fallback chains", async () => { + const { registerFallback, getAllFallbackChains, resetAllFallbacks } = + await import("../../src/domain/fallbackPolicy.js"); + + resetAllFallbacks(); + + registerFallback("m1", [{ provider: "p1" }]); + registerFallback("m2", [{ provider: "p2" }]); + + const all = getAllFallbackChains(); + assert.ok("m1" in all); + assert.ok("m2" in all); + + resetAllFallbacks(); + }); +}); + +// ─── Cost Rules Tests ──────────────────────── + +describe("costRules persistence", () => { + it("should set and check budget", async () => { + const { setBudget, getBudget, checkBudget, resetCostData } = + await import("../../src/domain/costRules.js"); + + resetCostData(); + + setBudget("key1", { dailyLimitUsd: 10 }); + const budget = getBudget("key1"); + assert.equal(budget.dailyLimitUsd, 10); + assert.equal(budget.warningThreshold, 0.8); + + const check = checkBudget("key1"); + assert.ok(check.allowed); + assert.equal(check.dailyLimit, 10); + + resetCostData(); + }); + + it("should record cost and check daily total", async () => { + const { setBudget, recordCost, getDailyTotal, checkBudget, resetCostData } = + await import("../../src/domain/costRules.js"); + + resetCostData(); + + setBudget("key2", { dailyLimitUsd: 5 }); + recordCost("key2", 3.5); + recordCost("key2", 1.0); + + const total = getDailyTotal("key2"); + assert.ok(total >= 4.5); + + // Should still be allowed + const check = checkBudget("key2", 0); + assert.ok(check.allowed); + + // Should be denied with additional cost + const checkOver = checkBudget("key2", 1.0); + assert.ok(!checkOver.allowed); + + resetCostData(); + }); + + it("should return allowed=true when no budget set", async () => { + const { checkBudget, resetCostData } = await import("../../src/domain/costRules.js"); + + resetCostData(); + + const check = checkBudget("no-budget-key"); + assert.ok(check.allowed); + assert.equal(check.dailyLimit, 0); + + resetCostData(); + }); + + it("should get cost summary", async () => { + const { setBudget, recordCost, getCostSummary, resetCostData } = + await import("../../src/domain/costRules.js"); + + resetCostData(); + + setBudget("key3", { dailyLimitUsd: 100 }); + recordCost("key3", 1.5); + recordCost("key3", 2.5); + + const summary = getCostSummary("key3"); + assert.ok(summary.dailyTotal >= 4.0); + assert.ok(summary.monthlyTotal >= 4.0); + assert.equal(summary.budget.dailyLimitUsd, 100); + + resetCostData(); + }); +}); + +// ─── Lockout Policy Tests ──────────────────────── + +describe("lockoutPolicy persistence", () => { + it("should track failed attempts and trigger lockout", async () => { + const { recordFailedAttempt, checkLockout, recordSuccess } = + await import("../../src/domain/lockoutPolicy.js"); + + const id = "test-ip-" + Date.now(); + const config = { maxAttempts: 3, lockoutDurationMs: 5000, attemptWindowMs: 10000 }; + + // First attempts should not lock + let result = recordFailedAttempt(id, config); + assert.ok(!result.locked); + + result = recordFailedAttempt(id, config); + assert.ok(!result.locked); + + // Third attempt triggers lockout + result = recordFailedAttempt(id, config); + assert.ok(result.locked); + assert.ok(result.remainingMs > 0); + + // Check lockout + const lockCheck = checkLockout(id, config); + assert.ok(lockCheck.locked); + + // Clean up + recordSuccess(id); + }); + + it("should unlock after success", async () => { + const { recordFailedAttempt, recordSuccess, checkLockout } = + await import("../../src/domain/lockoutPolicy.js"); + + const id = "test-unlock-" + Date.now(); + const config = { maxAttempts: 3, lockoutDurationMs: 5000, attemptWindowMs: 10000 }; + + recordFailedAttempt(id, config); + recordFailedAttempt(id, config); + + recordSuccess(id); + + const check = checkLockout(id, config); + assert.ok(!check.locked); + }); + + it("should force-unlock", async () => { + const { recordFailedAttempt, forceUnlock, checkLockout } = + await import("../../src/domain/lockoutPolicy.js"); + + const id = "test-force-" + Date.now(); + const config = { maxAttempts: 2, lockoutDurationMs: 60000, attemptWindowMs: 60000 }; + + recordFailedAttempt(id, config); + recordFailedAttempt(id, config); + + const lockCheck = checkLockout(id, config); + assert.ok(lockCheck.locked); + + forceUnlock(id); + + const afterUnlock = checkLockout(id, config); + assert.ok(!afterUnlock.locked); + }); +}); + +// ─── Circuit Breaker Tests ──────────────────────── + +describe("circuitBreaker persistence", () => { + it("should open after threshold failures", async () => { + const { CircuitBreaker, STATE } = await import("../../src/shared/utils/circuitBreaker.js"); + + const cb = new CircuitBreaker("test-cb-" + Date.now(), { failureThreshold: 3 }); + assert.equal(cb.state, STATE.CLOSED); + + // Simulate failures + for (let i = 0; i < 3; i++) { + try { + await cb.execute(() => Promise.reject(new Error("fail"))); + } catch { + // expected + } + } + + assert.equal(cb.state, STATE.OPEN); + assert.ok(!cb.canExecute()); + }); + + it("should reset correctly", async () => { + const { CircuitBreaker, STATE } = await import("../../src/shared/utils/circuitBreaker.js"); + + const cb = new CircuitBreaker("test-reset-" + Date.now(), { failureThreshold: 2 }); + + try { + await cb.execute(() => Promise.reject(new Error("fail"))); + } catch {} + try { + await cb.execute(() => Promise.reject(new Error("fail"))); + } catch {} + + assert.equal(cb.state, STATE.OPEN); + + cb.reset(); + assert.equal(cb.state, STATE.CLOSED); + assert.equal(cb.failureCount, 0); + assert.ok(cb.canExecute()); + }); + + it("should close on success after half-open", async () => { + const { CircuitBreaker, STATE } = await import("../../src/shared/utils/circuitBreaker.js"); + + const cb = new CircuitBreaker("test-halfopen-" + Date.now(), { + failureThreshold: 2, + resetTimeout: 10, // 10ms for test speed + }); + + // Open the circuit + try { + await cb.execute(() => Promise.reject(new Error("fail"))); + } catch {} + try { + await cb.execute(() => Promise.reject(new Error("fail"))); + } catch {} + assert.equal(cb.state, STATE.OPEN); + + // Wait for resetTimeout + await new Promise((r) => setTimeout(r, 20)); + + // Should transition to HALF_OPEN and succeed + const result = await cb.execute(() => Promise.resolve("ok")); + assert.equal(result, "ok"); + assert.equal(cb.state, STATE.CLOSED); + }); + + it("registry should return statuses", async () => { + const { getCircuitBreaker, getAllCircuitBreakerStatuses } = + await import("../../src/shared/utils/circuitBreaker.js"); + + const name = "reg-test-" + Date.now(); + const cb = getCircuitBreaker(name, { failureThreshold: 5 }); + assert.ok(cb); + + const statuses = getAllCircuitBreakerStatuses(); + const found = statuses.find((s) => s.name === name); + assert.ok(found); + assert.equal(found.state, "CLOSED"); + }); +}); diff --git a/tests/unit/observability-fase04.test.mjs b/tests/unit/observability-fase04.test.mjs index 1f760ebd66..fce509f76a 100644 --- a/tests/unit/observability-fase04.test.mjs +++ b/tests/unit/observability-fase04.test.mjs @@ -14,20 +14,22 @@ import { STATE, } from "../../src/shared/utils/circuitBreaker.js"; +const cbSuffix = `-${Date.now()}`; + test("CircuitBreaker: starts in CLOSED state", () => { - const cb = new CircuitBreaker("test-closed"); + const cb = new CircuitBreaker(`test-closed${cbSuffix}`); assert.equal(cb.getStatus().state, STATE.CLOSED); }); test("CircuitBreaker: stays CLOSED on success", async () => { - const cb = new CircuitBreaker("test-success"); + const cb = new CircuitBreaker(`test-success${cbSuffix}`); const result = await cb.execute(async () => "ok"); assert.equal(result, "ok"); assert.equal(cb.getStatus().state, STATE.CLOSED); }); test("CircuitBreaker: opens after failure threshold", async () => { - const cb = new CircuitBreaker("test-open", { failureThreshold: 3 }); + const cb = new CircuitBreaker(`test-open${cbSuffix}`, { failureThreshold: 3 }); for (let i = 0; i < 3; i++) { try { @@ -42,7 +44,10 @@ test("CircuitBreaker: opens after failure threshold", async () => { }); test("CircuitBreaker: rejects requests when open", async () => { - const cb = new CircuitBreaker("test-reject", { failureThreshold: 1, resetTimeout: 60000 }); + const cb = new CircuitBreaker(`test-reject${cbSuffix}`, { + failureThreshold: 1, + resetTimeout: 60000, + }); try { await cb.execute(async () => { @@ -57,7 +62,10 @@ test("CircuitBreaker: rejects requests when open", async () => { }); test("CircuitBreaker: transitions to HALF_OPEN after reset timeout", async () => { - const cb = new CircuitBreaker("test-halfopen", { failureThreshold: 1, resetTimeout: 10 }); + const cb = new CircuitBreaker(`test-halfopen${cbSuffix}`, { + failureThreshold: 1, + resetTimeout: 10, + }); try { await cb.execute(async () => { @@ -77,7 +85,7 @@ test("CircuitBreaker: transitions to HALF_OPEN after reset timeout", async () => }); test("CircuitBreaker: reset() forces back to CLOSED", () => { - const cb = new CircuitBreaker("test-reset", { failureThreshold: 1 }); + const cb = new CircuitBreaker(`test-reset${cbSuffix}`, { failureThreshold: 1 }); cb.state = STATE.OPEN; cb.failureCount = 5; cb.reset(); @@ -87,7 +95,7 @@ test("CircuitBreaker: reset() forces back to CLOSED", () => { test("CircuitBreaker: calls onStateChange callback", async () => { const changes = []; - const cb = new CircuitBreaker("test-callback", { + const cb = new CircuitBreaker(`test-callback${cbSuffix}`, { failureThreshold: 1, onStateChange: (name, from, to) => changes.push({ name, from, to }), }); @@ -133,10 +141,7 @@ test("requestTimeout: getProviderTimeout returns provider-specific value", () => // ─── Correlation ID Tests ──────────────────────────── -import { - getCorrelationId, - runWithCorrelation, -} from "../../src/shared/middleware/correlationId.js"; +import { getCorrelationId, runWithCorrelation } from "../../src/shared/middleware/correlationId.js"; test("correlationId: getCorrelationId returns undefined outside context", () => { assert.equal(getCorrelationId(), undefined); diff --git a/tests/unit/policy-engine.test.mjs b/tests/unit/policy-engine.test.mjs new file mode 100644 index 0000000000..8a03a7c180 --- /dev/null +++ b/tests/unit/policy-engine.test.mjs @@ -0,0 +1,89 @@ +import { describe, test, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +// ────────────────────────────────────────────────────── +// Policy Engine Tests — FASE-06 +// ────────────────────────────────────────────────────── + +let tmpDir; + +// Set up isolated DB for each test run +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pe-test-")); + process.env.DATA_DIR = tmpDir; +}); + +afterEach(() => { + delete process.env.DATA_DIR; + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("policyEngine", async () => { + // Dynamic import to pick up DATA_DIR + const { evaluateRequest, evaluateFirstAllowed } = + await import("../../src/domain/policyEngine.js"); + const { registerFallback } = await import("../../src/domain/fallbackPolicy.js"); + const { setBudget, recordCost } = await import("../../src/domain/costRules.js"); + const { recordFailedAttempt } = await import("../../src/domain/lockoutPolicy.js"); + + test("allows a basic request with no restrictions", () => { + const verdict = evaluateRequest({ model: "gpt-4o" }); + assert.equal(verdict.allowed, true); + assert.equal(verdict.policyPhase, "passed"); + assert.equal(verdict.reason, null); + }); + + test("returns fallback chain when registered", () => { + registerFallback("pe-model-fb", [ + { provider: "openai", priority: 1, enabled: true }, + { provider: "azure", priority: 2, enabled: true }, + ]); + + const verdict = evaluateRequest({ model: "pe-model-fb" }); + assert.equal(verdict.allowed, true); + assert.ok(Array.isArray(verdict.adjustments.fallbackChain)); + assert.ok(verdict.adjustments.fallbackChain.length > 0); + }); + + test("denies when budget is exceeded", () => { + const keyId = `pe-budget-${Date.now()}`; + setBudget(keyId, { dailyLimitUsd: 0.001 }); + recordCost(keyId, 100); + + const verdict = evaluateRequest({ model: "gpt-4o", apiKeyId: keyId }); + assert.equal(verdict.allowed, false); + assert.equal(verdict.policyPhase, "budget"); + assert.ok(verdict.reason.includes("Budget exceeded")); + }); + + test("denies when client is locked out", () => { + const ip = `pe-lockout-${Date.now()}`; + // Force lockout by recording many failures + for (let i = 0; i < 20; i++) { + recordFailedAttempt(ip); + } + + const verdict = evaluateRequest({ model: "gpt-4o", clientIp: ip }); + assert.equal(verdict.allowed, false); + assert.equal(verdict.policyPhase, "lockout"); + }); + + test("evaluateFirstAllowed returns first allowed model", () => { + const result = evaluateFirstAllowed(["model-a", "model-b", "model-c"], {}); + assert.equal(result.model, "model-a"); + assert.equal(result.verdict.allowed, true); + }); + + test("evaluateFirstAllowed returns null when all denied", () => { + const keyId = `pe-all-denied-${Date.now()}`; + setBudget(keyId, { dailyLimitUsd: 0.001 }); + recordCost(keyId, 100); + + const result = evaluateFirstAllowed(["model-a", "model-b"], { apiKeyId: keyId }); + assert.equal(result.model, null); + assert.equal(result.verdict.allowed, false); + }); +});