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