refactor(phase5-6): domain persistence, policy engine, OAuth extraction, proxy decoupling

Phase 5 — Foundation & Security:
- SQLite domain state persistence (5 tables, 4 modules: fallback, budget, lockout, circuit breaker)
- Write-through cache pattern for state survival across restarts
- Race condition fix in route.js (Promise-based singleton)
- Default password hardening (.env.example)
- Server init error handling improvement

Phase 6 — Architecture Refactoring:
- OAuth providers extracted into 12 individual modules (providers.js 1051→144 lines)
- Policy Engine (lockout→budget→fallback) with evaluateRequest/evaluateFirstAllowed
- Deterministic round-robin via persistent counter Map
- Telemetry window fix with proper recordedAt timestamps
- Proxy decoupled from API settings (direct import vs HTTP self-fetch)

Tests: 295 pass (22 new: domain-persistence 16, policy-engine 6)
Docs: CHANGELOG, README, ARCHITECTURE.md updated
This commit is contained in:
diegosouzapw
2026-02-15 08:12:12 -03:00
parent 25f4f5987c
commit 33c28f73db
33 changed files with 2206 additions and 1007 deletions

View File

@@ -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)

View File

@@ -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<string, number>` 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

View File

@@ -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
---

View File

@@ -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`

View File

@@ -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;
}
/**

View File

@@ -13,6 +13,9 @@
* @typedef {import('./types.js').Combo} Combo
*/
/** @type {Map<string, number>} Persistent round-robin counters per combo */
const roundRobinCounters = new Map();
/**
* Resolve which model to use from a combo based on its strategy.
*
@@ -28,9 +31,7 @@ export function resolveComboModel(combo, context = {}) {
}
// Normalize models to { model, weight } format
const normalized = models.map((m) =>
typeof m === "string" ? { model: m, weight: 1 } : m
);
const normalized = models.map((m) => (typeof m === "string" ? { model: m, weight: 1 } : m));
const strategy = combo.strategy || "priority";
@@ -39,9 +40,15 @@ export function resolveComboModel(combo, context = {}) {
return { model: normalized[0].model, index: 0 };
case "round-robin": {
// Use a simple counter based on current time + combo id hash
const tick = Date.now() % normalized.length;
return { model: normalized[tick].model, index: tick };
// Persistent counter per combo for deterministic round-robin
const comboKey = combo.id || combo.name || "default";
if (!roundRobinCounters.has(comboKey)) {
roundRobinCounters.set(comboKey, 0);
}
const counter = roundRobinCounters.get(comboKey);
const index = counter % normalized.length;
roundRobinCounters.set(comboKey, counter + 1);
return { model: normalized[index].model, index };
}
case "random": {
@@ -87,8 +94,6 @@ export function resolveComboModel(combo, context = {}) {
* @returns {string[]} Remaining models in order
*/
export function getComboFallbacks(combo, primaryIndex) {
const models = (combo.models || []).map((m) =>
typeof m === "string" ? m : m.model
);
const models = (combo.models || []).map((m) => (typeof m === "string" ? m : m.model));
return [...models.slice(primaryIndex + 1), ...models.slice(0, primaryIndex)];
}

View File

@@ -4,11 +4,23 @@
* Business rules for cost management: budget thresholds,
* quota checking, and cost summaries per API key.
*
* State is persisted in SQLite via domainState.js.
*
* @module domain/costRules
*/
// @ts-check
import {
saveBudget,
loadBudget,
saveCostEntry,
loadCostEntries,
deleteAllCostData,
deleteBudget as dbDeleteBudget,
deleteCostEntries,
} from "../lib/db/domainState.js";
/**
* @typedef {Object} BudgetConfig
* @property {number} dailyLimitUsd - Max daily spend in USD
@@ -22,11 +34,11 @@
* @property {number} timestamp - Unix timestamp
*/
/** @type {Map<string, BudgetConfig>} API key ID → budget config */
/** @type {Map<string, BudgetConfig>} In-memory cache for budgets */
const budgets = new Map();
/** @type {Map<string, CostEntry[]>} API key ID → cost entries */
const costHistory = new Map();
/** @type {boolean} */
let _budgetsLoaded = false;
/**
* Set budget for an API key.
@@ -35,11 +47,17 @@ const costHistory = new Map();
* @param {BudgetConfig} config
*/
export function setBudget(apiKeyId, config) {
budgets.set(apiKeyId, {
const normalized = {
dailyLimitUsd: config.dailyLimitUsd,
monthlyLimitUsd: config.monthlyLimitUsd || 0,
warningThreshold: config.warningThreshold ?? 0.8,
});
};
budgets.set(apiKeyId, normalized);
try {
saveBudget(apiKeyId, normalized);
} catch {
// Non-critical: in-memory still works
}
}
/**
@@ -49,7 +67,21 @@ export function setBudget(apiKeyId, config) {
* @returns {BudgetConfig | null}
*/
export function getBudget(apiKeyId) {
return budgets.get(apiKeyId) || null;
// Check in-memory cache first
if (budgets.has(apiKeyId)) {
return budgets.get(apiKeyId);
}
// Try loading from DB
try {
const fromDb = loadBudget(apiKeyId);
if (fromDb) {
budgets.set(apiKeyId, fromDb);
return fromDb;
}
} catch {
// DB may not be ready
}
return null;
}
/**
@@ -59,10 +91,12 @@ export function getBudget(apiKeyId) {
* @param {number} cost - Cost in USD
*/
export function recordCost(apiKeyId, cost) {
if (!costHistory.has(apiKeyId)) {
costHistory.set(apiKeyId, []);
const timestamp = Date.now();
try {
saveCostEntry(apiKeyId, cost, timestamp);
} catch {
// Non-critical
}
costHistory.get(apiKeyId).push({ cost, timestamp: Date.now() });
}
/**
@@ -73,7 +107,7 @@ export function recordCost(apiKeyId, cost) {
* @returns {{ allowed: boolean, reason?: string, dailyUsed: number, dailyLimit: number, warningReached: boolean }}
*/
export function checkBudget(apiKeyId, additionalCost = 0) {
const budget = budgets.get(apiKeyId);
const budget = getBudget(apiKeyId);
if (!budget) {
return { allowed: true, dailyUsed: 0, dailyLimit: 0, warningReached: false };
}
@@ -107,14 +141,16 @@ export function checkBudget(apiKeyId, additionalCost = 0) {
* @returns {number} Total cost today in USD
*/
export function getDailyTotal(apiKeyId) {
const entries = costHistory.get(apiKeyId) || [];
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const startMs = todayStart.getTime();
return entries
.filter((e) => e.timestamp >= startMs)
.reduce((sum, e) => sum + e.cost, 0);
try {
const entries = loadCostEntries(apiKeyId, startMs);
return entries.reduce((sum, e) => sum + e.cost, 0);
} catch {
return 0;
}
}
/**
@@ -124,7 +160,6 @@ export function getDailyTotal(apiKeyId) {
* @returns {{ dailyTotal: number, monthlyTotal: number, totalEntries: number, budget: BudgetConfig | null }}
*/
export function getCostSummary(apiKeyId) {
const entries = costHistory.get(apiKeyId) || [];
const now = new Date();
const todayStart = new Date(now);
@@ -132,20 +167,27 @@ export function getCostSummary(apiKeyId) {
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const dailyTotal = entries
.filter((e) => e.timestamp >= todayStart.getTime())
.reduce((sum, e) => sum + e.cost, 0);
try {
const dailyEntries = loadCostEntries(apiKeyId, todayStart.getTime());
const monthlyEntries = loadCostEntries(apiKeyId, monthStart.getTime());
const monthlyTotal = entries
.filter((e) => e.timestamp >= monthStart.getTime())
.reduce((sum, e) => sum + e.cost, 0);
const dailyTotal = dailyEntries.reduce((sum, e) => sum + e.cost, 0);
const monthlyTotal = monthlyEntries.reduce((sum, e) => sum + e.cost, 0);
return {
dailyTotal,
monthlyTotal,
totalEntries: entries.length,
budget: budgets.get(apiKeyId) || null,
};
return {
dailyTotal,
monthlyTotal,
totalEntries: monthlyEntries.length,
budget: getBudget(apiKeyId),
};
} catch {
return {
dailyTotal: 0,
monthlyTotal: 0,
totalEntries: 0,
budget: getBudget(apiKeyId),
};
}
}
/**
@@ -153,5 +195,10 @@ export function getCostSummary(apiKeyId) {
*/
export function resetCostData() {
budgets.clear();
costHistory.clear();
_budgetsLoaded = false;
try {
deleteAllCostData();
} catch {
// Non-critical
}
}

View File

@@ -5,11 +5,21 @@
* When a primary provider is unavailable, the policy engine
* resolves to alternative providers in priority order.
*
* State is persisted in SQLite via domainState.js.
*
* @module domain/fallbackPolicy
*/
// @ts-check
import {
saveFallbackChain,
loadFallbackChain,
loadAllFallbackChains,
deleteFallbackChain,
deleteAllFallbackChains,
} from "../lib/db/domainState.js";
/**
* @typedef {Object} FallbackEntry
* @property {string} provider - Provider ID
@@ -17,9 +27,28 @@
* @property {boolean} [enabled=true] - Whether this fallback is active
*/
/** @type {Map<string, FallbackEntry[]>} model → fallback chain */
/** @type {Map<string, FallbackEntry[]>} In-memory cache backed by SQLite */
const fallbackChains = new Map();
/** @type {boolean} Whether we've loaded from DB yet */
let _loaded = false;
/**
* Ensure in-memory cache is hydrated from SQLite.
*/
function ensureLoaded() {
if (_loaded) return;
try {
const all = loadAllFallbackChains();
for (const [model, chain] of Object.entries(all)) {
fallbackChains.set(model, chain);
}
} catch {
// DB may not be ready yet (build phase), that's ok
}
_loaded = true;
}
/**
* Register a fallback chain for a model.
*
@@ -27,6 +56,7 @@ const fallbackChains = new Map();
* @param {FallbackEntry[]} chain - Ordered list of fallback providers
*/
export function registerFallback(model, chain) {
ensureLoaded();
const sorted = [...chain]
.map((e) => ({
provider: e.provider,
@@ -36,6 +66,11 @@ export function registerFallback(model, chain) {
.sort((a, b) => a.priority - b.priority);
fallbackChains.set(model, sorted);
try {
saveFallbackChain(model, sorted);
} catch {
// Non-critical: in-memory still works
}
}
/**
@@ -47,6 +82,7 @@ export function registerFallback(model, chain) {
* @returns {FallbackEntry[]} Ordered list of fallback providers
*/
export function resolveFallbackChain(model, excludeProviders = []) {
ensureLoaded();
const chain = fallbackChains.get(model);
if (!chain) return [];
@@ -73,6 +109,7 @@ export function getNextFallback(model, excludeProviders = []) {
* @returns {boolean}
*/
export function hasFallback(model) {
ensureLoaded();
const chain = fallbackChains.get(model);
return !!chain && chain.some((e) => e.enabled);
}
@@ -84,7 +121,16 @@ export function hasFallback(model) {
* @returns {boolean} true if removed
*/
export function removeFallback(model) {
return fallbackChains.delete(model);
ensureLoaded();
const removed = fallbackChains.delete(model);
if (removed) {
try {
deleteFallbackChain(model);
} catch {
// Non-critical
}
}
return removed;
}
/**
@@ -93,6 +139,7 @@ export function removeFallback(model) {
* @returns {Record<string, FallbackEntry[]>}
*/
export function getAllFallbackChains() {
ensureLoaded();
/** @type {Record<string, FallbackEntry[]>} */
const result = {};
for (const [model, chain] of fallbackChains.entries()) {
@@ -106,4 +153,10 @@ export function getAllFallbackChains() {
*/
export function resetAllFallbacks() {
fallbackChains.clear();
_loaded = false;
try {
deleteAllFallbackChains();
} catch {
// Non-critical
}
}

View File

@@ -5,9 +5,18 @@
* Extracts account lockout logic from handleChat into a dedicated
* domain service. Manages login attempt tracking and lockout decisions.
*
* State is persisted in SQLite via domainState.js.
*
* @module domain/lockoutPolicy
*/
import {
saveLockoutState,
loadLockoutState,
deleteLockoutState,
loadAllLockedIdentifiers,
} from "../lib/db/domainState.js";
/**
* @typedef {Object} LockoutConfig
* @property {number} [maxAttempts=5] - Max failed attempts before lockout
@@ -15,8 +24,8 @@
* @property {number} [attemptWindowMs=300000] - Window for counting attempts (5 min)
*/
/** @type {Map<string, { attempts: number[], lockedUntil: number|null }>} */
const lockoutState = new Map();
/** @type {Map<string, { attempts: number[], lockedUntil: number|null }>} In-memory cache */
const lockoutCache = new Map();
/** @type {LockoutConfig} */
const DEFAULT_CONFIG = {
@@ -25,6 +34,43 @@ const DEFAULT_CONFIG = {
attemptWindowMs: 5 * 60 * 1000, // 5 minutes
};
/**
* Load state from DB into cache if not already cached.
* @param {string} identifier
* @returns {{ attempts: number[], lockedUntil: number|null }}
*/
function getState(identifier) {
if (lockoutCache.has(identifier)) {
return lockoutCache.get(identifier);
}
try {
const fromDb = loadLockoutState(identifier);
if (fromDb) {
lockoutCache.set(identifier, fromDb);
return fromDb;
}
} catch {
// DB may not be ready
}
return null;
}
/**
* Persist state to both cache and DB.
* @param {string} identifier
* @param {{ attempts: number[], lockedUntil: number|null }} state
*/
function persistState(identifier, state) {
lockoutCache.set(identifier, state);
try {
saveLockoutState(identifier, state);
} catch {
// Non-critical
}
}
/**
* Check if an identifier (IP, username, API key) is currently locked out.
*
@@ -33,7 +79,7 @@ const DEFAULT_CONFIG = {
* @returns {{ locked: boolean, remainingMs?: number, attempts?: number }}
*/
export function checkLockout(identifier, config = DEFAULT_CONFIG) {
const state = lockoutState.get(identifier);
const state = getState(identifier);
if (!state) {
return { locked: false, attempts: 0 };
}
@@ -51,12 +97,14 @@ export function checkLockout(identifier, config = DEFAULT_CONFIG) {
if (state.lockedUntil) {
state.lockedUntil = null;
state.attempts = [];
persistState(identifier, state);
}
// Count recent attempts within the window
const windowStart = Date.now() - config.attemptWindowMs;
const recentAttempts = state.attempts.filter((t) => t > windowStart);
state.attempts = recentAttempts;
persistState(identifier, state);
return { locked: false, attempts: recentAttempts.length };
}
@@ -69,12 +117,11 @@ export function checkLockout(identifier, config = DEFAULT_CONFIG) {
* @returns {{ locked: boolean, remainingMs?: number }}
*/
export function recordFailedAttempt(identifier, config = DEFAULT_CONFIG) {
if (!lockoutState.has(identifier)) {
lockoutState.set(identifier, { attempts: [], lockedUntil: null });
let state = getState(identifier);
if (!state) {
state = { attempts: [], lockedUntil: null };
}
const state = lockoutState.get(identifier);
// Clean old attempts
const windowStart = Date.now() - config.attemptWindowMs;
state.attempts = state.attempts.filter((t) => t > windowStart);
@@ -85,12 +132,14 @@ export function recordFailedAttempt(identifier, config = DEFAULT_CONFIG) {
// Check if threshold exceeded
if (state.attempts.length >= config.maxAttempts) {
state.lockedUntil = Date.now() + config.lockoutDurationMs;
persistState(identifier, state);
return {
locked: true,
remainingMs: config.lockoutDurationMs,
};
}
persistState(identifier, state);
return { locked: false };
}
@@ -100,7 +149,12 @@ export function recordFailedAttempt(identifier, config = DEFAULT_CONFIG) {
* @param {string} identifier
*/
export function recordSuccess(identifier) {
lockoutState.delete(identifier);
lockoutCache.delete(identifier);
try {
deleteLockoutState(identifier);
} catch {
// Non-critical
}
}
/**
@@ -109,7 +163,12 @@ export function recordSuccess(identifier) {
* @param {string} identifier
*/
export function forceUnlock(identifier) {
lockoutState.delete(identifier);
lockoutCache.delete(identifier);
try {
deleteLockoutState(identifier);
} catch {
// Non-critical
}
}
/**
@@ -119,9 +178,24 @@ export function forceUnlock(identifier) {
*/
export function getLockedIdentifiers() {
const now = Date.now();
const locked = [];
for (const [id, state] of lockoutState.entries()) {
// Merge cache and DB
try {
const fromDb = loadAllLockedIdentifiers();
for (const entry of fromDb) {
if (!lockoutCache.has(entry.identifier)) {
lockoutCache.set(entry.identifier, {
attempts: [],
lockedUntil: entry.lockedUntil,
});
}
}
} catch {
// Use cache only
}
const locked = [];
for (const [id, state] of lockoutCache.entries()) {
if (state.lockedUntil && state.lockedUntil > now) {
locked.push({
identifier: id,

110
src/domain/policyEngine.js Normal file
View File

@@ -0,0 +1,110 @@
// @ts-check
/**
* Policy Engine — FASE-06 Architecture Refactoring
*
* Centralized policy evaluation that combines domain decisions from
* fallback, cost, lockout, and circuit-breaker modules into a single
* verdict before forwarding a request to a provider.
*
* Usage: Call `evaluateRequest(request)` before executing a chat request.
* The function returns `{ allowed, reason, adjustments }`.
*
* @module domain/policyEngine
*/
import { checkLockout } from "./lockoutPolicy.js";
import { checkBudget } from "./costRules.js";
import { resolveFallbackChain } from "./fallbackPolicy.js";
/**
* @typedef {Object} PolicyRequest
* @property {string} model - Requested model
* @property {string} [apiKeyId] - API key identifier for budget checks
* @property {string} [clientIp] - Client IP for lockout checks
* @property {string} [provider] - Target provider
*/
/**
* @typedef {Object} PolicyVerdict
* @property {boolean} allowed - Whether the request is permitted
* @property {string|null} reason - Human-readable denial reason (null if allowed)
* @property {Object} adjustments - Optional flight-path adjustments
* @property {string} [adjustments.model] - Replaced model (from combo/fallback)
* @property {Array} [adjustments.fallbackChain] - Available fallbacks
* @property {string} policyPhase - Which policy phase determined the outcome
*/
/**
* Evaluate a request against all domain policies.
*
* Evaluation order (short-circuits on first denial):
* 1. Lockout — is the client/IP locked out?
* 2. Budget — is the API key within budget?
* 3. Fallback — is there a fallback chain for the model?
*
* @param {PolicyRequest} request
* @returns {PolicyVerdict}
*/
export function evaluateRequest(request) {
const { model, apiKeyId, clientIp } = request;
// ── 1. Lockout Policy ──────────────────────────────
if (clientIp) {
const lockout = checkLockout(clientIp);
if (lockout.locked) {
return {
allowed: false,
reason: `Client locked out (${lockout.remainingMs}ms remaining)`,
adjustments: {},
policyPhase: "lockout",
};
}
}
// ── 2. Budget Policy ───────────────────────────────
if (apiKeyId) {
const budget = checkBudget(apiKeyId);
if (budget && !budget.allowed) {
return {
allowed: false,
reason: `Budget exceeded: ${budget.reason || "daily limit reached"}`,
adjustments: {},
policyPhase: "budget",
};
}
}
// ── 3. Fallback Chain Resolution ───────────────────
const fallbackChain = resolveFallbackChain(model);
return {
allowed: true,
reason: null,
adjustments: {
model,
fallbackChain: fallbackChain || [],
},
policyPhase: "passed",
};
}
/**
* Evaluate a set of models against policies and return the first allowed one.
* Useful for combo/fallback scenarios where multiple models may be tried.
*
* @param {string[]} models - Models to evaluate in order
* @param {Omit<PolicyRequest, 'model'>} baseRequest - Base request without model
* @returns {{ model: string, verdict: PolicyVerdict } | { model: null, verdict: PolicyVerdict }}
*/
export function evaluateFirstAllowed(models, baseRequest) {
for (const model of models) {
const verdict = evaluateRequest({ ...baseRequest, model });
if (verdict.allowed) {
return { model, verdict };
}
}
// All models denied — return last denial
const lastVerdict = evaluateRequest({ ...baseRequest, model: models[models.length - 1] });
return { model: null, verdict: lastVerdict };
}

View File

@@ -156,6 +156,42 @@ const SCHEMA_SQL = `
);
CREATE INDEX IF NOT EXISTS idx_cl_timestamp ON call_logs(timestamp);
CREATE INDEX IF NOT EXISTS idx_cl_status ON call_logs(status);
-- Domain State Persistence (Phase 5)
CREATE TABLE IF NOT EXISTS domain_fallback_chains (
model TEXT PRIMARY KEY,
chain TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS domain_budgets (
api_key_id TEXT PRIMARY KEY,
daily_limit_usd REAL NOT NULL,
monthly_limit_usd REAL DEFAULT 0,
warning_threshold REAL DEFAULT 0.8
);
CREATE TABLE IF NOT EXISTS domain_cost_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
api_key_id TEXT NOT NULL,
cost REAL NOT NULL,
timestamp INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_dch_key ON domain_cost_history(api_key_id);
CREATE INDEX IF NOT EXISTS idx_dch_ts ON domain_cost_history(timestamp);
CREATE TABLE IF NOT EXISTS domain_lockout_state (
identifier TEXT PRIMARY KEY,
attempts TEXT NOT NULL,
locked_until INTEGER
);
CREATE TABLE IF NOT EXISTS domain_circuit_breakers (
name TEXT PRIMARY KEY,
state TEXT NOT NULL DEFAULT 'CLOSED',
failure_count INTEGER DEFAULT 0,
last_failure_time INTEGER,
options TEXT
);
`;
// ──────────────── Column Mapping ────────────────

309
src/lib/db/domainState.js Normal file
View File

@@ -0,0 +1,309 @@
/**
* Domain State Persistence — Phase 5 Foundation
*
* CRUD operations for persisting domain layer state in SQLite.
* Replaces in-memory Map() storage with durable persistence.
*
* Tables: domain_fallback_chains, domain_budgets, domain_cost_history,
* domain_lockout_state, domain_circuit_breakers
*
* @module lib/db/domainState
*/
import { getDbInstance, isBuildPhase, isCloud } from "./core.js";
// ──────────────── Fallback Chains ────────────────
/**
* Save a fallback chain for a model.
* @param {string} model
* @param {Array<{provider: string, priority: number, enabled: boolean}>} chain
*/
export function saveFallbackChain(model, chain) {
const db = getDbInstance();
db.prepare("INSERT OR REPLACE INTO domain_fallback_chains (model, chain) VALUES (?, ?)").run(
model,
JSON.stringify(chain)
);
}
/**
* Load a fallback chain for a model.
* @param {string} model
* @returns {Array<{provider: string, priority: number, enabled: boolean}> | null}
*/
export function loadFallbackChain(model) {
const db = getDbInstance();
const row = db.prepare("SELECT chain FROM domain_fallback_chains WHERE model = ?").get(model);
return row ? JSON.parse(row.chain) : null;
}
/**
* Load all fallback chains.
* @returns {Record<string, Array<{provider: string, priority: number, enabled: boolean}>>}
*/
export function loadAllFallbackChains() {
const db = getDbInstance();
const rows = db.prepare("SELECT model, chain FROM domain_fallback_chains").all();
const result = {};
for (const row of rows) {
result[row.model] = JSON.parse(row.chain);
}
return result;
}
/**
* Delete a fallback chain.
* @param {string} model
* @returns {boolean}
*/
export function deleteFallbackChain(model) {
const db = getDbInstance();
const info = db.prepare("DELETE FROM domain_fallback_chains WHERE model = ?").run(model);
return info.changes > 0;
}
/**
* Delete all fallback chains.
*/
export function deleteAllFallbackChains() {
const db = getDbInstance();
db.prepare("DELETE FROM domain_fallback_chains").run();
}
// ──────────────── Budgets ────────────────
/**
* Save a budget config for an API key.
* @param {string} apiKeyId
* @param {{ dailyLimitUsd: number, monthlyLimitUsd?: number, warningThreshold?: number }} config
*/
export function saveBudget(apiKeyId, config) {
const db = getDbInstance();
db.prepare(
`INSERT OR REPLACE INTO domain_budgets (api_key_id, daily_limit_usd, monthly_limit_usd, warning_threshold)
VALUES (?, ?, ?, ?)`
).run(
apiKeyId,
config.dailyLimitUsd,
config.monthlyLimitUsd || 0,
config.warningThreshold ?? 0.8
);
}
/**
* Load a budget config.
* @param {string} apiKeyId
* @returns {{ dailyLimitUsd: number, monthlyLimitUsd: number, warningThreshold: number } | null}
*/
export function loadBudget(apiKeyId) {
const db = getDbInstance();
const row = db.prepare("SELECT * FROM domain_budgets WHERE api_key_id = ?").get(apiKeyId);
if (!row) return null;
return {
dailyLimitUsd: row.daily_limit_usd,
monthlyLimitUsd: row.monthly_limit_usd,
warningThreshold: row.warning_threshold,
};
}
/**
* Delete a budget config.
* @param {string} apiKeyId
*/
export function deleteBudget(apiKeyId) {
const db = getDbInstance();
db.prepare("DELETE FROM domain_budgets WHERE api_key_id = ?").run(apiKeyId);
}
// ──────────────── Cost History ────────────────
/**
* Record a cost entry.
* @param {string} apiKeyId
* @param {number} cost
* @param {number} [timestamp]
*/
export function saveCostEntry(apiKeyId, cost, timestamp = Date.now()) {
const db = getDbInstance();
db.prepare("INSERT INTO domain_cost_history (api_key_id, cost, timestamp) VALUES (?, ?, ?)").run(
apiKeyId,
cost,
timestamp
);
}
/**
* Load cost entries for an API key within a time window.
* @param {string} apiKeyId
* @param {number} sinceTimestamp
* @returns {Array<{cost: number, timestamp: number}>}
*/
export function loadCostEntries(apiKeyId, sinceTimestamp) {
const db = getDbInstance();
return db
.prepare(
"SELECT cost, timestamp FROM domain_cost_history WHERE api_key_id = ? AND timestamp >= ? ORDER BY timestamp"
)
.all(apiKeyId, sinceTimestamp);
}
/**
* Delete old cost entries (cleanup).
* @param {number} olderThanTimestamp
* @returns {number} deleted count
*/
export function cleanOldCostEntries(olderThanTimestamp) {
const db = getDbInstance();
const info = db
.prepare("DELETE FROM domain_cost_history WHERE timestamp < ?")
.run(olderThanTimestamp);
return info.changes;
}
/**
* Delete all cost data for an API key.
* @param {string} apiKeyId
*/
export function deleteCostEntries(apiKeyId) {
const db = getDbInstance();
db.prepare("DELETE FROM domain_cost_history WHERE api_key_id = ?").run(apiKeyId);
}
/**
* Delete all cost data.
*/
export function deleteAllCostData() {
const db = getDbInstance();
db.prepare("DELETE FROM domain_cost_history").run();
db.prepare("DELETE FROM domain_budgets").run();
}
// ──────────────── Lockout State ────────────────
/**
* Save lockout state for an identifier.
* @param {string} identifier
* @param {{ attempts: number[], lockedUntil: number|null }} state
*/
export function saveLockoutState(identifier, state) {
const db = getDbInstance();
db.prepare(
`INSERT OR REPLACE INTO domain_lockout_state (identifier, attempts, locked_until)
VALUES (?, ?, ?)`
).run(identifier, JSON.stringify(state.attempts), state.lockedUntil);
}
/**
* Load lockout state for an identifier.
* @param {string} identifier
* @returns {{ attempts: number[], lockedUntil: number|null } | null}
*/
export function loadLockoutState(identifier) {
const db = getDbInstance();
const row = db.prepare("SELECT * FROM domain_lockout_state WHERE identifier = ?").get(identifier);
if (!row) return null;
return {
attempts: JSON.parse(row.attempts),
lockedUntil: row.locked_until,
};
}
/**
* Delete lockout state for an identifier.
* @param {string} identifier
*/
export function deleteLockoutState(identifier) {
const db = getDbInstance();
db.prepare("DELETE FROM domain_lockout_state WHERE identifier = ?").run(identifier);
}
/**
* Get all locked identifiers.
* @returns {Array<{identifier: string, lockedUntil: number}>}
*/
export function loadAllLockedIdentifiers() {
const db = getDbInstance();
const now = Date.now();
return db
.prepare(
"SELECT identifier, locked_until FROM domain_lockout_state WHERE locked_until IS NOT NULL AND locked_until > ?"
)
.all(now)
.map((row) => ({
identifier: row.identifier,
lockedUntil: row.locked_until,
}));
}
// ──────────────── Circuit Breakers ────────────────
/**
* Save circuit breaker state.
* @param {string} name
* @param {{ state: string, failureCount: number, lastFailureTime: number|null, options?: object }} cbState
*/
export function saveCircuitBreakerState(name, cbState) {
const db = getDbInstance();
db.prepare(
`INSERT OR REPLACE INTO domain_circuit_breakers (name, state, failure_count, last_failure_time, options)
VALUES (?, ?, ?, ?, ?)`
).run(
name,
cbState.state,
cbState.failureCount,
cbState.lastFailureTime,
cbState.options ? JSON.stringify(cbState.options) : null
);
}
/**
* Load circuit breaker state.
* @param {string} name
* @returns {{ state: string, failureCount: number, lastFailureTime: number|null, options?: object } | null}
*/
export function loadCircuitBreakerState(name) {
const db = getDbInstance();
const row = db.prepare("SELECT * FROM domain_circuit_breakers WHERE name = ?").get(name);
if (!row) return null;
return {
state: row.state,
failureCount: row.failure_count,
lastFailureTime: row.last_failure_time,
options: row.options ? JSON.parse(row.options) : null,
};
}
/**
* Load all circuit breaker states.
* @returns {Array<{name: string, state: string, failureCount: number, lastFailureTime: number|null}>}
*/
export function loadAllCircuitBreakerStates() {
const db = getDbInstance();
return db
.prepare("SELECT name, state, failure_count, last_failure_time FROM domain_circuit_breakers")
.all()
.map((row) => ({
name: row.name,
state: row.state,
failureCount: row.failure_count,
lastFailureTime: row.last_failure_time,
}));
}
/**
* Delete a circuit breaker state.
* @param {string} name
*/
export function deleteCircuitBreakerState(name) {
const db = getDbInstance();
db.prepare("DELETE FROM domain_circuit_breakers WHERE name = ?").run(name);
}
/**
* Delete all circuit breaker states.
*/
export function deleteAllCircuitBreakerStates() {
const db = getDbInstance();
db.prepare("DELETE FROM domain_circuit_breakers").run();
}

View File

@@ -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",

View File

@@ -0,0 +1,122 @@
import { ANTIGRAVITY_CONFIG } from "../constants/oauth.js";
export const antigravity = {
config: ANTIGRAVITY_CONFIG,
flowType: "authorization_code",
buildAuthUrl: (config, redirectUri, state) => {
const params = new URLSearchParams({
client_id: config.clientId,
response_type: "code",
redirect_uri: redirectUri,
scope: config.scopes.join(" "),
state: state,
access_type: "offline",
prompt: "consent",
});
return `${config.authorizeUrl}?${params.toString()}`;
},
exchangeToken: async (config, code, redirectUri) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: config.clientId,
client_secret: config.clientSecret,
code: code,
redirect_uri: redirectUri,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${error}`);
}
return await response.json();
},
postExchange: async (tokens) => {
const headers = {
Authorization: `Bearer ${tokens.access_token}`,
"Content-Type": "application/json",
"User-Agent": ANTIGRAVITY_CONFIG.loadCodeAssistUserAgent,
"X-Goog-Api-Client": ANTIGRAVITY_CONFIG.loadCodeAssistApiClient,
"Client-Metadata": ANTIGRAVITY_CONFIG.loadCodeAssistClientMetadata,
};
const metadata = {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
};
const userInfoRes = await fetch(`${ANTIGRAVITY_CONFIG.userInfoUrl}?alt=json`, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
const userInfo = userInfoRes.ok ? await userInfoRes.json() : {};
let projectId = "";
let tierId = "legacy-tier";
try {
const loadRes = await fetch(ANTIGRAVITY_CONFIG.loadCodeAssistEndpoint, {
method: "POST",
headers,
body: JSON.stringify({ metadata }),
});
if (loadRes.ok) {
const data = await loadRes.json();
projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || "";
if (Array.isArray(data.allowedTiers)) {
for (const tier of data.allowedTiers) {
if (tier.isDefault && tier.id) {
tierId = tier.id.trim();
break;
}
}
}
}
} catch (e) {
console.log("Failed to load code assist:", e);
}
if (projectId) {
try {
for (let i = 0; i < 10; i++) {
const onboardRes = await fetch(ANTIGRAVITY_CONFIG.onboardUserEndpoint, {
method: "POST",
headers,
body: JSON.stringify({ tierId, metadata, cloudaicompanionProject: projectId }),
});
if (onboardRes.ok) {
const result = await onboardRes.json();
if (result.done === true) {
if (result.response?.cloudaicompanionProject) {
const respProject = result.response.cloudaicompanionProject;
projectId =
typeof respProject === "string"
? respProject.trim()
: respProject.id || projectId;
}
break;
}
}
await new Promise((resolve) => setTimeout(resolve, 5000));
}
} catch (e) {
console.log("Failed to onboard user:", e);
}
}
return { userInfo, projectId };
},
mapTokens: (tokens, extra) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
scope: tokens.scope,
email: extra?.userInfo?.email,
projectId: extra?.projectId,
}),
};

View File

@@ -0,0 +1,57 @@
import { CLAUDE_CONFIG } from "../constants/oauth.js";
export const claude = {
config: CLAUDE_CONFIG,
flowType: "authorization_code_pkce",
buildAuthUrl: (config, redirectUri, state, codeChallenge) => {
const params = new URLSearchParams({
code: "true",
client_id: config.clientId,
response_type: "code",
redirect_uri: redirectUri,
scope: config.scopes.join(" "),
code_challenge: codeChallenge,
code_challenge_method: config.codeChallengeMethod,
state: state,
});
return `${config.authorizeUrl}?${params.toString()}`;
},
exchangeToken: async (config, code, redirectUri, codeVerifier, state) => {
let authCode = code;
let codeState = "";
if (authCode.includes("#")) {
const parts = authCode.split("#");
authCode = parts[0];
codeState = parts[1] || "";
}
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
code: authCode,
state: codeState || state,
grant_type: "authorization_code",
client_id: config.clientId,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${error}`);
}
return await response.json();
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
scope: tokens.scope,
}),
};

View File

@@ -0,0 +1,77 @@
import { CLINE_CONFIG } from "../constants/oauth.js";
export const cline = {
config: CLINE_CONFIG,
flowType: "authorization_code",
buildAuthUrl: (config, redirectUri) => {
const params = new URLSearchParams({
client_type: "extension",
callback_url: redirectUri,
redirect_uri: redirectUri,
});
return `${config.authorizeUrl}?${params.toString()}`;
},
exchangeToken: async (config, code, redirectUri) => {
try {
let base64 = code;
const padding = 4 - (base64.length % 4);
if (padding !== 4) {
base64 += "=".repeat(padding);
}
const decoded = Buffer.from(base64, "base64").toString("utf-8");
const lastBrace = decoded.lastIndexOf("}");
if (lastBrace === -1) {
throw new Error("No JSON found in decoded code");
}
const jsonStr = decoded.substring(0, lastBrace + 1);
const tokenData = JSON.parse(jsonStr);
return {
access_token: tokenData.accessToken,
refresh_token: tokenData.refreshToken,
email: tokenData.email,
firstName: tokenData.firstName,
lastName: tokenData.lastName,
expires_at: tokenData.expiresAt,
};
} catch (e) {
const response = await fetch(config.tokenExchangeUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
grant_type: "authorization_code",
code: code,
client_type: "extension",
redirect_uri: redirectUri,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Cline token exchange failed: ${error}`);
}
const data = await response.json();
return {
access_token: data.data?.accessToken || data.accessToken,
refresh_token: data.data?.refreshToken || data.refreshToken,
email: data.data?.userInfo?.email || "",
expires_at: data.data?.expiresAt || data.expiresAt,
};
}
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_at
? Math.floor((new Date(tokens.expires_at).getTime() - Date.now()) / 1000)
: 3600,
email: tokens.email,
providerSpecificData: {
firstName: tokens.firstName,
lastName: tokens.lastName,
},
}),
};

View File

@@ -0,0 +1,53 @@
import { CODEX_CONFIG } from "../constants/oauth.js";
export const codex = {
config: CODEX_CONFIG,
flowType: "authorization_code_pkce",
fixedPort: 1455,
callbackPath: "/auth/callback",
buildAuthUrl: (config, redirectUri, state, codeChallenge) => {
const params = {
response_type: "code",
client_id: config.clientId,
redirect_uri: redirectUri,
scope: config.scope,
code_challenge: codeChallenge,
code_challenge_method: config.codeChallengeMethod,
...config.extraParams,
state: state,
};
const queryString = Object.entries(params)
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
.join("&");
return `${config.authorizeUrl}?${queryString}`;
},
exchangeToken: async (config, code, redirectUri, codeVerifier) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: config.clientId,
code: code,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${error}`);
}
return await response.json();
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
idToken: tokens.id_token,
expiresIn: tokens.expires_in,
}),
};

View File

@@ -0,0 +1,15 @@
import { CURSOR_CONFIG } from "../constants/oauth.js";
export const cursor = {
config: CURSOR_CONFIG,
flowType: "import_token",
mapTokens: (tokens) => ({
accessToken: tokens.accessToken,
refreshToken: null,
expiresIn: tokens.expiresIn || 86400,
providerSpecificData: {
machineId: tokens.machineId,
authMethod: "imported",
},
}),
};

View File

@@ -0,0 +1,84 @@
import { GEMINI_CONFIG } from "../constants/oauth.js";
export const gemini = {
config: GEMINI_CONFIG,
flowType: "authorization_code",
buildAuthUrl: (config, redirectUri, state) => {
const params = new URLSearchParams({
client_id: config.clientId,
response_type: "code",
redirect_uri: redirectUri,
scope: config.scopes.join(" "),
state: state,
access_type: "offline",
prompt: "consent",
});
return `${config.authorizeUrl}?${params.toString()}`;
},
exchangeToken: async (config, code, redirectUri) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: config.clientId,
client_secret: config.clientSecret,
code: code,
redirect_uri: redirectUri,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${error}`);
}
return await response.json();
},
postExchange: async (tokens) => {
const userInfoRes = await fetch(`${GEMINI_CONFIG.userInfoUrl}?alt=json`, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
const userInfo = userInfoRes.ok ? await userInfoRes.json() : {};
let projectId = "";
try {
const projectRes = await fetch(
"https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens.access_token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
metadata: {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
},
}),
}
);
if (projectRes.ok) {
const data = await projectRes.json();
projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || "";
}
} catch (e) {
console.log("Failed to fetch project ID:", e);
}
return { userInfo, projectId };
},
mapTokens: (tokens, extra) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
scope: tokens.scope,
email: extra?.userInfo?.email,
projectId: extra?.projectId,
}),
};

View File

@@ -0,0 +1,89 @@
import { GITHUB_CONFIG } from "../constants/oauth.js";
export const github = {
config: GITHUB_CONFIG,
flowType: "device_code",
requestDeviceCode: async (config) => {
const response = await fetch(config.deviceCodeUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
client_id: config.clientId,
scope: config.scopes,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Device code request failed: ${error}`);
}
return await response.json();
},
pollToken: async (config, deviceCode) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
client_id: config.clientId,
device_code: deviceCode,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}),
});
let data;
try {
data = await response.json();
} catch (e) {
const text = await response.text();
data = { error: "invalid_response", error_description: text };
}
return {
ok: response.ok,
data: data,
};
},
postExchange: async (tokens) => {
const copilotRes = await fetch(GITHUB_CONFIG.copilotTokenUrl, {
headers: {
Authorization: `Bearer ${tokens.access_token}`,
Accept: "application/json",
"X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion,
"User-Agent": GITHUB_CONFIG.userAgent,
},
});
const copilotToken = copilotRes.ok ? await copilotRes.json() : {};
const userRes = await fetch(GITHUB_CONFIG.userInfoUrl, {
headers: {
Authorization: `Bearer ${tokens.access_token}`,
Accept: "application/json",
"X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion,
"User-Agent": GITHUB_CONFIG.userAgent,
},
});
const userInfo = userRes.ok ? await userRes.json() : {};
return { copilotToken, userInfo };
},
mapTokens: (tokens, extra) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
providerSpecificData: {
copilotToken: extra?.copilotToken?.token,
copilotTokenExpiresAt: extra?.copilotToken?.expires_at,
githubUserId: extra?.userInfo?.id,
githubLogin: extra?.userInfo?.login,
githubName: extra?.userInfo?.name,
githubEmail: extra?.userInfo?.email,
},
}),
};

View File

@@ -0,0 +1,59 @@
import { IFLOW_CONFIG } from "../constants/oauth.js";
export const iflow = {
config: IFLOW_CONFIG,
flowType: "authorization_code",
buildAuthUrl: (config, redirectUri, state) => {
const params = new URLSearchParams({
loginMethod: config.extraParams.loginMethod,
type: config.extraParams.type,
redirect: redirectUri,
state: state,
client_id: config.clientId,
});
return `${config.authorizeUrl}?${params.toString()}`;
},
exchangeToken: async (config, code, redirectUri) => {
const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64");
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
Authorization: `Basic ${basicAuth}`,
},
body: new URLSearchParams({
grant_type: "authorization_code",
code: code,
redirect_uri: redirectUri,
client_id: config.clientId,
client_secret: config.clientSecret,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${error}`);
}
return await response.json();
},
postExchange: async (tokens) => {
const userInfoRes = await fetch(
`${IFLOW_CONFIG.userInfoUrl}?accessToken=${encodeURIComponent(tokens.access_token)}`,
{ headers: { Accept: "application/json" } }
);
const result = userInfoRes.ok ? await userInfoRes.json() : {};
const userInfo = result.success ? result.data : {};
return { userInfo };
},
mapTokens: (tokens, extra) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
apiKey: extra?.userInfo?.apiKey,
email: extra?.userInfo?.email || extra?.userInfo?.phone,
displayName: extra?.userInfo?.nickname || extra?.userInfo?.name,
}),
};

View File

@@ -0,0 +1,41 @@
/**
* OAuth Provider Registry — Extracted from monolithic providers.js
*
* Each provider is now defined in its own module under providers/.
* This index re-exports the full PROVIDERS map and utility functions.
*
* Provider modules follow the interface:
* { config, flowType, buildAuthUrl?, exchangeToken?, requestDeviceCode?, pollToken?, postExchange?, mapTokens }
*
* @module lib/oauth/providers/index
*/
import { claude } from "./claude.js";
import { codex } from "./codex.js";
import { gemini } from "./gemini.js";
import { antigravity } from "./antigravity.js";
import { iflow } from "./iflow.js";
import { qwen } from "./qwen.js";
import { kimiCoding } from "./kimi-coding.js";
import { github } from "./github.js";
import { kiro } from "./kiro.js";
import { cursor } from "./cursor.js";
import { kilocode } from "./kilocode.js";
import { cline } from "./cline.js";
export const PROVIDERS = {
claude,
codex,
"gemini-cli": gemini,
antigravity,
iflow,
qwen,
"kimi-coding": kimiCoding,
github,
kiro,
cursor,
kilocode,
cline,
};
export default PROVIDERS;

View File

@@ -0,0 +1,77 @@
import { KILOCODE_CONFIG } from "../constants/oauth.js";
export const kilocode = {
config: KILOCODE_CONFIG,
flowType: "device_code",
requestDeviceCode: async (config) => {
const response = await fetch(config.initiateUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
if (response.status === 429) {
throw new Error("Too many pending authorization requests. Please try again later.");
}
const error = await response.text();
throw new Error(`Device auth initiation failed: ${error}`);
}
const data = await response.json();
return {
device_code: data.code,
user_code: data.code,
verification_uri: data.verificationUrl,
verification_uri_complete: data.verificationUrl,
expires_in: data.expiresIn || 300,
interval: 3,
};
},
pollToken: async (config, deviceCode) => {
const response = await fetch(`${config.pollUrlBase}/${deviceCode}`);
if (response.status === 202) {
return { ok: false, data: { error: "authorization_pending" } };
}
if (response.status === 403) {
return {
ok: false,
data: { error: "access_denied", error_description: "Authorization denied by user" },
};
}
if (response.status === 410) {
return {
ok: false,
data: { error: "expired_token", error_description: "Authorization code expired" },
};
}
if (!response.ok) {
return {
ok: false,
data: { error: "poll_failed", error_description: `Poll failed: ${response.status}` },
};
}
const data = await response.json();
if (data.status === "approved" && data.token) {
return {
ok: true,
data: {
access_token: data.token,
_userEmail: data.userEmail,
},
};
}
return { ok: false, data: { error: "authorization_pending" } };
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: null,
expiresIn: null,
email: tokens._userEmail,
}),
};

View File

@@ -0,0 +1,67 @@
import { KIMI_CODING_CONFIG } from "../constants/oauth.js";
export const kimiCoding = {
config: KIMI_CODING_CONFIG,
flowType: "device_code",
requestDeviceCode: async (config) => {
const response = await fetch(config.deviceCodeUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
client_id: config.clientId,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Device code request failed: ${error}`);
}
const data = await response.json();
return {
device_code: data.device_code,
user_code: data.user_code,
verification_uri: data.verification_uri || `https://www.kimi.com/code/authorize_device`,
verification_uri_complete:
data.verification_uri_complete ||
`https://www.kimi.com/code/authorize_device?user_code=${data.user_code}`,
expires_in: data.expires_in,
interval: data.interval || 5,
};
},
pollToken: async (config, deviceCode) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
client_id: config.clientId,
device_code: deviceCode,
}),
});
let data;
try {
data = await response.json();
} catch (e) {
const text = await response.text();
data = { error: "invalid_response", error_description: text };
}
return {
ok: response.ok,
data: data,
};
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
}),
};

View File

@@ -0,0 +1,115 @@
import { KIRO_CONFIG } from "../constants/oauth.js";
export const kiro = {
config: KIRO_CONFIG,
flowType: "device_code",
requestDeviceCode: async (config) => {
// Step 1: Register client with AWS SSO OIDC
const registerRes = await fetch(config.registerClientUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
clientName: config.clientName,
clientType: config.clientType,
scopes: config.scopes,
grantTypes: config.grantTypes,
issuerUrl: config.issuerUrl,
}),
});
if (!registerRes.ok) {
const error = await registerRes.text();
throw new Error(`Client registration failed: ${error}`);
}
const clientInfo = await registerRes.json();
// Step 2: Request device authorization
const deviceRes = await fetch(config.deviceAuthUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
clientId: clientInfo.clientId,
clientSecret: clientInfo.clientSecret,
startUrl: config.startUrl,
}),
});
if (!deviceRes.ok) {
const error = await deviceRes.text();
throw new Error(`Device authorization failed: ${error}`);
}
const deviceData = await deviceRes.json();
return {
device_code: deviceData.deviceCode,
user_code: deviceData.userCode,
verification_uri: deviceData.verificationUri,
verification_uri_complete: deviceData.verificationUriComplete,
expires_in: deviceData.expiresIn,
interval: deviceData.interval || 5,
_clientId: clientInfo.clientId,
_clientSecret: clientInfo.clientSecret,
};
},
pollToken: async (config, deviceCode, codeVerifier, extraData) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
clientId: extraData?._clientId,
clientSecret: extraData?._clientSecret,
deviceCode: deviceCode,
grantType: "urn:ietf:params:oauth:grant-type:device_code",
}),
});
let data;
try {
data = await response.json();
} catch (e) {
const text = await response.text();
data = { error: "invalid_response", error_description: text };
}
if (data.accessToken) {
return {
ok: true,
data: {
access_token: data.accessToken,
refresh_token: data.refreshToken,
expires_in: data.expiresIn,
_clientId: extraData?._clientId,
_clientSecret: extraData?._clientSecret,
},
};
}
return {
ok: false,
data: {
error: data.error || "authorization_pending",
error_description: data.error_description || data.message,
},
};
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
providerSpecificData: {
clientId: tokens._clientId,
clientSecret: tokens._clientSecret,
},
}),
};

View File

@@ -0,0 +1,54 @@
import { QWEN_CONFIG } from "../constants/oauth.js";
export const qwen = {
config: QWEN_CONFIG,
flowType: "device_code",
requestDeviceCode: async (config, codeChallenge) => {
const response = await fetch(config.deviceCodeUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
client_id: config.clientId,
scope: config.scope,
code_challenge: codeChallenge,
code_challenge_method: config.codeChallengeMethod,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Device code request failed: ${error}`);
}
return await response.json();
},
pollToken: async (config, deviceCode, codeVerifier) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
client_id: config.clientId,
device_code: deviceCode,
code_verifier: codeVerifier,
}),
});
return {
ok: response.ok,
data: await response.json(),
};
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
providerSpecificData: { resourceUrl: tokens.resource_url },
}),
};

View File

@@ -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*"],
};

View File

@@ -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;

View File

@@ -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 };

View File

@@ -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) {

View File

@@ -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");
});
});

View File

@@ -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);

View File

@@ -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);
});
});