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

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

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

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

View File

@@ -2,17 +2,18 @@ import { callCloudWithMachineId } from "@/shared/utils/cloud.js";
import { handleChat } from "@/sse/handlers/chat.js";
import { initTranslators } from "@omniroute/open-sse/translator/index.js";
let initialized = false;
let initPromise = null;
/**
* Initialize translators once
* Initialize translators once (Promise-based singleton — no race condition)
*/
async function ensureInitialized() {
if (!initialized) {
await initTranslators();
initialized = true;
console.log("[SSE] Translators initialized");
function ensureInitialized() {
if (!initPromise) {
initPromise = initTranslators().then(() => {
console.log("[SSE] Translators initialized");
});
}
return initPromise;
}
/**

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

@@ -1,920 +1,14 @@
/**
* OAuth Provider Configurations and Handlers
* Centralized DRY approach for all OAuth providers
*
* This file re-exports from the modular providers/ directory.
* Each provider is now in its own file for maintainability.
*
* @see ./providers/index.js for the registry
*/
import { generatePKCE, generateState } from "./utils/pkce";
import {
CLAUDE_CONFIG,
CODEX_CONFIG,
GEMINI_CONFIG,
QWEN_CONFIG,
IFLOW_CONFIG,
KIMI_CODING_CONFIG,
ANTIGRAVITY_CONFIG,
GITHUB_CONFIG,
KIRO_CONFIG,
CURSOR_CONFIG,
KILOCODE_CONFIG,
CLINE_CONFIG,
} from "./constants/oauth";
// Provider configurations
const PROVIDERS = {
claude: {
config: CLAUDE_CONFIG,
flowType: "authorization_code_pkce",
buildAuthUrl: (config, redirectUri, state, codeChallenge) => {
const params = new URLSearchParams({
code: "true",
client_id: config.clientId,
response_type: "code",
redirect_uri: redirectUri,
scope: config.scopes.join(" "),
code_challenge: codeChallenge,
code_challenge_method: config.codeChallengeMethod,
state: state,
});
return `${config.authorizeUrl}?${params.toString()}`;
},
exchangeToken: async (config, code, redirectUri, codeVerifier, state) => {
// Parse code - may contain state after #
let authCode = code;
let codeState = "";
if (authCode.includes("#")) {
const parts = authCode.split("#");
authCode = parts[0];
codeState = parts[1] || "";
}
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
code: authCode,
state: codeState || state,
grant_type: "authorization_code",
client_id: config.clientId,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${error}`);
}
return await response.json();
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
scope: tokens.scope,
}),
},
codex: {
config: CODEX_CONFIG,
flowType: "authorization_code_pkce",
fixedPort: 1455,
callbackPath: "/auth/callback",
buildAuthUrl: (config, redirectUri, state, codeChallenge) => {
const params = {
response_type: "code",
client_id: config.clientId,
redirect_uri: redirectUri,
scope: config.scope,
code_challenge: codeChallenge,
code_challenge_method: config.codeChallengeMethod,
...config.extraParams,
state: state,
};
const queryString = Object.entries(params)
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
.join("&");
return `${config.authorizeUrl}?${queryString}`;
},
exchangeToken: async (config, code, redirectUri, codeVerifier) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: config.clientId,
code: code,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${error}`);
}
return await response.json();
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
idToken: tokens.id_token,
expiresIn: tokens.expires_in,
}),
},
"gemini-cli": {
config: GEMINI_CONFIG,
flowType: "authorization_code",
buildAuthUrl: (config, redirectUri, state) => {
const params = new URLSearchParams({
client_id: config.clientId,
response_type: "code",
redirect_uri: redirectUri,
scope: config.scopes.join(" "),
state: state,
access_type: "offline",
prompt: "consent",
});
return `${config.authorizeUrl}?${params.toString()}`;
},
exchangeToken: async (config, code, redirectUri) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: config.clientId,
client_secret: config.clientSecret,
code: code,
redirect_uri: redirectUri,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${error}`);
}
return await response.json();
},
postExchange: async (tokens) => {
// Fetch user info
const userInfoRes = await fetch(`${GEMINI_CONFIG.userInfoUrl}?alt=json`, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
const userInfo = userInfoRes.ok ? await userInfoRes.json() : {};
// Fetch project ID
let projectId = "";
try {
const projectRes = await fetch(
"https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens.access_token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
metadata: {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
},
}),
}
);
if (projectRes.ok) {
const data = await projectRes.json();
projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || "";
}
} catch (e) {
console.log("Failed to fetch project ID:", e);
}
return { userInfo, projectId };
},
mapTokens: (tokens, extra) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
scope: tokens.scope,
email: extra?.userInfo?.email,
projectId: extra?.projectId,
}),
},
antigravity: {
config: ANTIGRAVITY_CONFIG,
flowType: "authorization_code",
buildAuthUrl: (config, redirectUri, state) => {
const params = new URLSearchParams({
client_id: config.clientId,
response_type: "code",
redirect_uri: redirectUri,
scope: config.scopes.join(" "),
state: state,
access_type: "offline",
prompt: "consent",
});
return `${config.authorizeUrl}?${params.toString()}`;
},
exchangeToken: async (config, code, redirectUri) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: config.clientId,
client_secret: config.clientSecret,
code: code,
redirect_uri: redirectUri,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${error}`);
}
return await response.json();
},
postExchange: async (tokens) => {
const headers = {
Authorization: `Bearer ${tokens.access_token}`,
"Content-Type": "application/json",
"User-Agent": ANTIGRAVITY_CONFIG.loadCodeAssistUserAgent,
"X-Goog-Api-Client": ANTIGRAVITY_CONFIG.loadCodeAssistApiClient,
"Client-Metadata": ANTIGRAVITY_CONFIG.loadCodeAssistClientMetadata,
};
const metadata = {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
};
// Fetch user info
const userInfoRes = await fetch(`${ANTIGRAVITY_CONFIG.userInfoUrl}?alt=json`, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
const userInfo = userInfoRes.ok ? await userInfoRes.json() : {};
// Load Code Assist to get project ID and tier
let projectId = "";
let tierId = "legacy-tier";
try {
const loadRes = await fetch(ANTIGRAVITY_CONFIG.loadCodeAssistEndpoint, {
method: "POST",
headers,
body: JSON.stringify({ metadata }),
});
if (loadRes.ok) {
const data = await loadRes.json();
projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || "";
// Extract tier ID
if (Array.isArray(data.allowedTiers)) {
for (const tier of data.allowedTiers) {
if (tier.isDefault && tier.id) {
tierId = tier.id.trim();
break;
}
}
}
}
} catch (e) {
console.log("Failed to load code assist:", e);
}
// Onboard user to enable Gemini Code Assist
if (projectId) {
try {
for (let i = 0; i < 10; i++) {
const onboardRes = await fetch(ANTIGRAVITY_CONFIG.onboardUserEndpoint, {
method: "POST",
headers,
body: JSON.stringify({ tierId, metadata, cloudaicompanionProject: projectId }),
});
if (onboardRes.ok) {
const result = await onboardRes.json();
if (result.done === true) {
// Extract final project ID from response
if (result.response?.cloudaicompanionProject) {
const respProject = result.response.cloudaicompanionProject;
projectId =
typeof respProject === "string"
? respProject.trim()
: respProject.id || projectId;
}
break;
}
}
// Wait 5 seconds before retry
await new Promise((resolve) => setTimeout(resolve, 5000));
}
} catch (e) {
console.log("Failed to onboard user:", e);
}
}
return { userInfo, projectId };
},
mapTokens: (tokens, extra) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
scope: tokens.scope,
email: extra?.userInfo?.email,
projectId: extra?.projectId,
}),
},
iflow: {
config: IFLOW_CONFIG,
flowType: "authorization_code",
buildAuthUrl: (config, redirectUri, state) => {
const params = new URLSearchParams({
loginMethod: config.extraParams.loginMethod,
type: config.extraParams.type,
redirect: redirectUri,
state: state,
client_id: config.clientId,
});
return `${config.authorizeUrl}?${params.toString()}`;
},
exchangeToken: async (config, code, redirectUri) => {
// Create Basic Auth header
const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64");
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
Authorization: `Basic ${basicAuth}`,
},
body: new URLSearchParams({
grant_type: "authorization_code",
code: code,
redirect_uri: redirectUri,
client_id: config.clientId,
client_secret: config.clientSecret,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${error}`);
}
return await response.json();
},
postExchange: async (tokens) => {
// Fetch user info
const userInfoRes = await fetch(
`${IFLOW_CONFIG.userInfoUrl}?accessToken=${encodeURIComponent(tokens.access_token)}`,
{
headers: {
Accept: "application/json",
},
}
);
const result = userInfoRes.ok ? await userInfoRes.json() : {};
const userInfo = result.success ? result.data : {};
return { userInfo };
},
mapTokens: (tokens, extra) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
apiKey: extra?.userInfo?.apiKey,
email: extra?.userInfo?.email || extra?.userInfo?.phone,
displayName: extra?.userInfo?.nickname || extra?.userInfo?.name,
}),
},
qwen: {
config: QWEN_CONFIG,
flowType: "device_code",
requestDeviceCode: async (config, codeChallenge) => {
const response = await fetch(config.deviceCodeUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
client_id: config.clientId,
scope: config.scope,
code_challenge: codeChallenge,
code_challenge_method: config.codeChallengeMethod,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Device code request failed: ${error}`);
}
return await response.json();
},
pollToken: async (config, deviceCode, codeVerifier) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
client_id: config.clientId,
device_code: deviceCode,
code_verifier: codeVerifier,
}),
});
return {
ok: response.ok,
data: await response.json(),
};
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
providerSpecificData: { resourceUrl: tokens.resource_url },
}),
},
"kimi-coding": {
config: KIMI_CODING_CONFIG,
flowType: "device_code",
requestDeviceCode: async (config) => {
const response = await fetch(config.deviceCodeUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
client_id: config.clientId,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Device code request failed: ${error}`);
}
const data = await response.json();
return {
device_code: data.device_code,
user_code: data.user_code,
verification_uri: data.verification_uri || `https://www.kimi.com/code/authorize_device`,
verification_uri_complete:
data.verification_uri_complete ||
`https://www.kimi.com/code/authorize_device?user_code=${data.user_code}`,
expires_in: data.expires_in,
interval: data.interval || 5,
};
},
pollToken: async (config, deviceCode) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
client_id: config.clientId,
device_code: deviceCode,
}),
});
let data;
try {
data = await response.json();
} catch (e) {
const text = await response.text();
data = { error: "invalid_response", error_description: text };
}
return {
ok: response.ok,
data: data,
};
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
}),
},
github: {
config: GITHUB_CONFIG,
flowType: "device_code",
requestDeviceCode: async (config) => {
const response = await fetch(config.deviceCodeUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
client_id: config.clientId,
scope: config.scopes,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Device code request failed: ${error}`);
}
return await response.json();
},
pollToken: async (config, deviceCode) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
client_id: config.clientId,
device_code: deviceCode,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}),
});
// Handle response properly - if not ok, try to get error as text first
let data;
try {
data = await response.json();
} catch (e) {
// If response is not JSON, get as text
const text = await response.text();
data = { error: "invalid_response", error_description: text };
}
return {
ok: response.ok,
data: data,
};
},
postExchange: async (tokens) => {
// Get Copilot token using GitHub access token
const copilotRes = await fetch(GITHUB_CONFIG.copilotTokenUrl, {
headers: {
Authorization: `Bearer ${tokens.access_token}`,
Accept: "application/json",
"X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion,
"User-Agent": GITHUB_CONFIG.userAgent,
},
});
const copilotToken = copilotRes.ok ? await copilotRes.json() : {};
// Get user info from GitHub
const userRes = await fetch(GITHUB_CONFIG.userInfoUrl, {
headers: {
Authorization: `Bearer ${tokens.access_token}`,
Accept: "application/json",
"X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion,
"User-Agent": GITHUB_CONFIG.userAgent,
},
});
const userInfo = userRes.ok ? await userRes.json() : {};
return { copilotToken, userInfo };
},
mapTokens: (tokens, extra) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
providerSpecificData: {
copilotToken: extra?.copilotToken?.token,
copilotTokenExpiresAt: extra?.copilotToken?.expires_at,
githubUserId: extra?.userInfo?.id,
githubLogin: extra?.userInfo?.login,
githubName: extra?.userInfo?.name,
githubEmail: extra?.userInfo?.email,
},
}),
},
kiro: {
config: KIRO_CONFIG,
flowType: "device_code",
// Kiro uses AWS SSO OIDC - requires client registration first
requestDeviceCode: async (config) => {
// Step 1: Register client with AWS SSO OIDC
const registerRes = await fetch(config.registerClientUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
clientName: config.clientName,
clientType: config.clientType,
scopes: config.scopes,
grantTypes: config.grantTypes,
issuerUrl: config.issuerUrl,
}),
});
if (!registerRes.ok) {
const error = await registerRes.text();
throw new Error(`Client registration failed: ${error}`);
}
const clientInfo = await registerRes.json();
// Step 2: Request device authorization
const deviceRes = await fetch(config.deviceAuthUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
clientId: clientInfo.clientId,
clientSecret: clientInfo.clientSecret,
startUrl: config.startUrl,
}),
});
if (!deviceRes.ok) {
const error = await deviceRes.text();
throw new Error(`Device authorization failed: ${error}`);
}
const deviceData = await deviceRes.json();
// Return combined data for polling
return {
device_code: deviceData.deviceCode,
user_code: deviceData.userCode,
verification_uri: deviceData.verificationUri,
verification_uri_complete: deviceData.verificationUriComplete,
expires_in: deviceData.expiresIn,
interval: deviceData.interval || 5,
// Store client credentials for token exchange
_clientId: clientInfo.clientId,
_clientSecret: clientInfo.clientSecret,
};
},
pollToken: async (config, deviceCode, codeVerifier, extraData) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
clientId: extraData?._clientId,
clientSecret: extraData?._clientSecret,
deviceCode: deviceCode,
grantType: "urn:ietf:params:oauth:grant-type:device_code",
}),
});
let data;
try {
data = await response.json();
} catch (e) {
const text = await response.text();
data = { error: "invalid_response", error_description: text };
}
// AWS SSO OIDC returns camelCase
if (data.accessToken) {
return {
ok: true,
data: {
access_token: data.accessToken,
refresh_token: data.refreshToken,
expires_in: data.expiresIn,
// Store client credentials for refresh
_clientId: extraData?._clientId,
_clientSecret: extraData?._clientSecret,
},
};
}
return {
ok: false,
data: {
error: data.error || "authorization_pending",
error_description: data.error_description || data.message,
},
};
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
providerSpecificData: {
clientId: tokens._clientId,
clientSecret: tokens._clientSecret,
},
}),
},
cursor: {
config: CURSOR_CONFIG,
flowType: "import_token",
// Cursor uses import token flow - tokens are extracted from local SQLite database
// No OAuth flow needed, handled by /api/oauth/cursor/import route
mapTokens: (tokens) => ({
accessToken: tokens.accessToken,
refreshToken: null, // Cursor doesn't have public refresh endpoint
expiresIn: tokens.expiresIn || 86400,
providerSpecificData: {
machineId: tokens.machineId,
authMethod: "imported",
},
}),
},
kilocode: {
config: KILOCODE_CONFIG,
flowType: "device_code",
requestDeviceCode: async (config) => {
// KiloCode uses a custom device auth flow (not standard OAuth)
const response = await fetch(config.initiateUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
if (response.status === 429) {
throw new Error("Too many pending authorization requests. Please try again later.");
}
const error = await response.text();
throw new Error(`Device auth initiation failed: ${error}`);
}
const data = await response.json();
// Map KiloCode response to standard device code format
return {
device_code: data.code, // Use code as device_code for polling
user_code: data.code,
verification_uri: data.verificationUrl,
verification_uri_complete: data.verificationUrl,
expires_in: data.expiresIn || 300,
interval: 3,
};
},
pollToken: async (config, deviceCode) => {
// KiloCode polls by GET /api/device-auth/codes/{code}
const response = await fetch(`${config.pollUrlBase}/${deviceCode}`);
// Handle custom status codes
if (response.status === 202) {
// Still pending
return { ok: false, data: { error: "authorization_pending" } };
}
if (response.status === 403) {
return {
ok: false,
data: { error: "access_denied", error_description: "Authorization denied by user" },
};
}
if (response.status === 410) {
return {
ok: false,
data: { error: "expired_token", error_description: "Authorization code expired" },
};
}
if (!response.ok) {
return {
ok: false,
data: { error: "poll_failed", error_description: `Poll failed: ${response.status}` },
};
}
// Success - map KiloCode {token, userEmail} to standard format
const data = await response.json();
if (data.status === "approved" && data.token) {
return {
ok: true,
data: {
access_token: data.token,
_userEmail: data.userEmail,
},
};
}
return { ok: false, data: { error: "authorization_pending" } };
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: null, // KiloCode JWT doesn't have refresh tokens
expiresIn: null, // JWT expiry is embedded in the token
email: tokens._userEmail,
}),
},
cline: {
config: CLINE_CONFIG,
flowType: "authorization_code",
buildAuthUrl: (config, redirectUri) => {
const params = new URLSearchParams({
client_type: "extension",
callback_url: redirectUri,
redirect_uri: redirectUri,
});
return `${config.authorizeUrl}?${params.toString()}`;
},
exchangeToken: async (config, code, redirectUri) => {
// Cline callback returns base64-encoded JSON with tokens directly in the code param
// The code may have a signature suffix after the JSON - strip it
try {
// Add padding if needed
let base64 = code;
const padding = 4 - (base64.length % 4);
if (padding !== 4) {
base64 += "=".repeat(padding);
}
const decoded = Buffer.from(base64, "base64").toString("utf-8");
// Find the JSON boundary (ends with })
const lastBrace = decoded.lastIndexOf("}");
if (lastBrace === -1) {
throw new Error("No JSON found in decoded code");
}
const jsonStr = decoded.substring(0, lastBrace + 1);
const tokenData = JSON.parse(jsonStr);
return {
access_token: tokenData.accessToken,
refresh_token: tokenData.refreshToken,
email: tokenData.email,
firstName: tokenData.firstName,
lastName: tokenData.lastName,
expires_at: tokenData.expiresAt,
};
} catch (e) {
// Fallback: try token exchange via API
const response = await fetch(config.tokenExchangeUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
grant_type: "authorization_code",
code: code,
client_type: "extension",
redirect_uri: redirectUri,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Cline token exchange failed: ${error}`);
}
const data = await response.json();
return {
access_token: data.data?.accessToken || data.accessToken,
refresh_token: data.data?.refreshToken || data.refreshToken,
email: data.data?.userInfo?.email || "",
expires_at: data.data?.expiresAt || data.expiresAt,
};
}
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_at
? Math.floor((new Date(tokens.expires_at).getTime() - Date.now()) / 1000)
: 3600,
email: tokens.email,
providerSpecificData: {
firstName: tokens.firstName,
lastName: tokens.lastName,
},
}),
},
};
import { PROVIDERS } from "./providers/index.js";
/**
* Get provider handler
@@ -943,7 +37,6 @@ export function generateAuthData(providerName, redirectUri) {
let authUrl;
if (provider.flowType === "device_code") {
// Device code flow doesn't have auth URL upfront
authUrl = null;
} else if (provider.flowType === "authorization_code_pkce") {
authUrl = provider.buildAuthUrl(provider.config, redirectUri, state, codeChallenge);
@@ -1012,18 +105,14 @@ export async function pollForToken(providerName, deviceCode, codeVerifier, extra
const result = await provider.pollToken(provider.config, deviceCode, codeVerifier, extraData);
if (result.ok) {
// For device code flows, success is only when we have an access token
if (result.data.access_token) {
// Call postExchange to get additional data (copilotToken, userInfo, etc.)
let extra = null;
if (provider.postExchange) {
extra = await provider.postExchange(result.data);
}
return { success: true, tokens: provider.mapTokens(result.data, extra) };
} else {
// Check if it's still pending authorization
if (result.data.error === "authorization_pending" || result.data.error === "slow_down") {
// This is not a failure, just still waiting
return {
success: false,
error: result.data.error,
@@ -1031,7 +120,6 @@ export async function pollForToken(providerName, deviceCode, codeVerifier, extra
pending: result.data.error === "authorization_pending",
};
} else {
// Actual error
return {
success: false,
error: result.data.error || "no_access_token",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { jwtVerify } from "jose";
import { fetchWithTimeout } from "./shared/utils/fetchTimeout.js";
import { generateRequestId } from "./shared/utils/requestId.js";
import { getSettings } from "./lib/localDb.js";
// FASE-01: Fail-fast — no hardcoded fallback. Server must have JWT_SECRET configured.
if (!process.env.JWT_SECRET) {
@@ -42,25 +42,22 @@ export async function proxy(request) {
}
}
const origin = request.nextUrl.origin;
try {
// Pipeline: Use fetchWithTimeout instead of bare fetch
const res = await fetchWithTimeout(`${origin}/api/settings`, { timeoutMs: 5000 });
const data = await res.json();
// Direct import — no HTTP self-fetch overhead
const settings = await getSettings();
// Skip auth if login is not required
if (data.requireLogin === false) {
if (settings.requireLogin === false) {
return response;
}
// Skip auth if no password has been set yet (fresh install)
// This prevents an unresolvable loop where requireLogin=true but no password exists
if (!data.hasPassword) {
if (!settings.password) {
return response;
}
} catch (err) {
// FASE-01: Log settings fetch errors instead of silencing them
console.error("[Middleware] settings_error: Settings fetch failed:", err.message, {
console.error("[Middleware] settings_error: Settings read failed:", err.message, {
path: pathname,
origin,
requestId,
});
// On error, require login
@@ -79,4 +76,3 @@ export async function proxy(request) {
export const config = {
matcher: ["/", "/dashboard/:path*"],
};

View File

@@ -35,14 +35,16 @@ async function startServer() {
// Log server start event to audit log
logAuditEvent({ action: "server.start", details: { timestamp: new Date().toISOString() } });
} catch (error) {
console.log("Error initializing cloud sync:", error);
console.error("[FATAL] Error initializing cloud sync:", error);
process.exit(1);
}
}
// Start the server initialization
startServer().catch(console.log);
startServer().catch((err) => {
console.error("[FATAL] Server initialization failed:", err);
process.exit(1);
});
// Export for use as module if needed
export default startServer;

View File

@@ -8,9 +8,19 @@
*
* States: CLOSED → OPEN → HALF_OPEN → CLOSED
*
* State is persisted in SQLite via domainState.js for restart durability.
*
* @module shared/utils/circuitBreaker
*/
import {
saveCircuitBreakerState,
loadCircuitBreakerState,
loadAllCircuitBreakerStates,
deleteCircuitBreakerState,
deleteAllCircuitBreakerStates,
} from "../../lib/db/domainState.js";
const STATE = {
CLOSED: "CLOSED",
OPEN: "OPEN",
@@ -44,6 +54,50 @@ export class CircuitBreaker {
this.successCount = 0;
this.lastFailureTime = null;
this.halfOpenAllowed = 0;
// Try to restore state from DB
this._restoreFromDb();
}
/**
* Restore state from SQLite if available.
* @private
*/
_restoreFromDb() {
try {
const saved = loadCircuitBreakerState(this.name);
if (saved) {
this.state = saved.state;
this.failureCount = saved.failureCount;
this.lastFailureTime = saved.lastFailureTime;
if (this.state === STATE.HALF_OPEN) {
this.halfOpenAllowed = this.halfOpenRequests;
}
}
} catch {
// DB may not be ready yet (build phase)
}
}
/**
* Persist current state to SQLite.
* @private
*/
_persistToDb() {
try {
saveCircuitBreakerState(this.name, {
state: this.state,
failureCount: this.failureCount,
lastFailureTime: this.lastFailureTime,
options: {
failureThreshold: this.failureThreshold,
resetTimeout: this.resetTimeout,
halfOpenRequests: this.halfOpenRequests,
},
});
} catch {
// Non-critical: in-memory still works
}
}
/**
@@ -123,6 +177,7 @@ export class CircuitBreaker {
this.failureCount = 0;
this.successCount = 0;
this.lastFailureTime = null;
this._persistToDb();
}
// ─── Internal Methods ────────────────────────
@@ -135,6 +190,7 @@ export class CircuitBreaker {
}
// In CLOSED state, just reset failure count
this.failureCount = 0;
this._persistToDb();
}
_onFailure() {
@@ -146,6 +202,7 @@ export class CircuitBreaker {
} else if (this.failureCount >= this.failureThreshold) {
this._transition(STATE.OPEN);
}
this._persistToDb();
}
_shouldAttemptReset() {
@@ -210,7 +267,34 @@ export function getCircuitBreaker(name, options) {
* @returns {Array<{ name: string, state: string, failureCount: number }>}
*/
export function getAllCircuitBreakerStatuses() {
// Merge registry with any persisted states not yet loaded
try {
const persisted = loadAllCircuitBreakerStates();
for (const cb of persisted) {
if (!registry.has(cb.name)) {
// Load the breaker (will restore from DB in constructor)
getCircuitBreaker(cb.name);
}
}
} catch {
// Use registry only
}
return Array.from(registry.values()).map((cb) => cb.getStatus());
}
/**
* Reset all circuit breakers (for admin/testing).
*/
export function resetAllCircuitBreakers() {
for (const cb of registry.values()) {
cb.reset();
}
registry.clear();
try {
deleteAllCircuitBreakerStates();
} catch {
// Non-critical
}
}
export { STATE };

View File

@@ -112,7 +112,9 @@ const history = [];
* @param {RequestTelemetry} telemetry
*/
export function recordTelemetry(telemetry) {
history.push(telemetry.getSummary());
const summary = telemetry.getSummary();
summary.recordedAt = Date.now();
history.push(summary);
while (history.length > MAX_HISTORY) {
history.shift();
}
@@ -138,8 +140,7 @@ function percentile(sorted, p) {
export function getTelemetrySummary(windowMs = 300000) {
const cutoff = Date.now() - windowMs;
const recent = history.filter((h) => {
// Approximate: use most recent entries
return true; // We don't store timestamps in history, so use all
return (h.recordedAt || 0) >= cutoff;
});
if (recent.length === 0) {