mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
feat: add GitLab Duo and CodeBuddy support, update observability settings
This commit is contained in:
+87
-49
@@ -40,6 +40,11 @@ if (!isCloud && !fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Seed db.json with defaults on first run so proper-lockfile never hits ENOENT
|
||||
if (!isCloud && DB_FILE && !fs.existsSync(DB_FILE)) {
|
||||
fs.writeFileSync(DB_FILE, JSON.stringify(defaultData, null, 2));
|
||||
}
|
||||
|
||||
// Default data structure
|
||||
const defaultData = {
|
||||
providerConnections: [],
|
||||
@@ -58,7 +63,7 @@ const defaultData = {
|
||||
comboStrategy: "fallback",
|
||||
comboStrategies: {},
|
||||
requireLogin: true,
|
||||
observabilityEnabled: true,
|
||||
enableObservability: false,
|
||||
observabilityMaxRecords: 1000,
|
||||
observabilityBatchSize: 20,
|
||||
observabilityFlushIntervalMs: 5000,
|
||||
@@ -88,7 +93,7 @@ function cloneDefaultData() {
|
||||
comboStrategy: "fallback",
|
||||
comboStrategies: {},
|
||||
requireLogin: true,
|
||||
observabilityEnabled: true,
|
||||
enableObservability: false,
|
||||
observabilityMaxRecords: 1000,
|
||||
observabilityBatchSize: 20,
|
||||
observabilityFlushIntervalMs: 5000,
|
||||
@@ -162,6 +167,19 @@ function ensureDbShape(data) {
|
||||
// Singleton instance
|
||||
let dbInstance = null;
|
||||
|
||||
// In-memory read cache to avoid redundant disk reads under high load
|
||||
const DB_CACHE_TTL = 500; // ms
|
||||
let dbCache = { data: null, ts: 0 };
|
||||
|
||||
// Serialize all DB operations (reads on cache-miss + writes) to prevent race conditions
|
||||
let dbQueue = Promise.resolve();
|
||||
|
||||
function withDbLock(fn) {
|
||||
const next = dbQueue.then(fn, fn);
|
||||
dbQueue = next.catch(() => {});
|
||||
return next;
|
||||
}
|
||||
|
||||
// Lock options for proper-lockfile
|
||||
const LOCK_OPTIONS = {
|
||||
retries: {
|
||||
@@ -204,7 +222,8 @@ async function safeRead(db) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely write database with file locking
|
||||
* Safely write database with file locking.
|
||||
* Always invalidates read cache so next read reflects the latest state.
|
||||
*/
|
||||
async function safeWrite(db) {
|
||||
if (isCloud) {
|
||||
@@ -214,9 +233,10 @@ async function safeWrite(db) {
|
||||
|
||||
let release = null;
|
||||
try {
|
||||
// Acquire lock before writing
|
||||
release = await lockfile.lock(DB_FILE, LOCK_OPTIONS);
|
||||
await db.write();
|
||||
// Invalidate cache immediately after a successful write
|
||||
dbCache.ts = 0;
|
||||
} catch (error) {
|
||||
if (error.code === "ELOCKED") {
|
||||
console.warn("[DB] File is locked, retrying write...");
|
||||
@@ -235,11 +255,14 @@ async function safeWrite(db) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database instance (singleton)
|
||||
* Get database instance (singleton).
|
||||
*
|
||||
* Hot path: if cache is fresh, return immediately without any I/O or queuing.
|
||||
* Cold path: serialize via withDbLock to prevent concurrent reads from racing
|
||||
* against in-flight writes (eliminates lost-update race condition).
|
||||
*/
|
||||
export async function getDb() {
|
||||
if (isCloud) {
|
||||
// Return in-memory DB for Workers
|
||||
if (!dbInstance) {
|
||||
const data = cloneDefaultData();
|
||||
dbInstance = new Low({ read: async () => {}, write: async () => {} }, data);
|
||||
@@ -248,37 +271,57 @@ export async function getDb() {
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
if (!dbInstance) {
|
||||
const adapter = new JSONFile(DB_FILE);
|
||||
dbInstance = new Low(adapter, cloneDefaultData());
|
||||
// Hot path: cache hit — no lock, no disk I/O
|
||||
if (dbCache.data && Date.now() - dbCache.ts < DB_CACHE_TTL) {
|
||||
if (!dbInstance) {
|
||||
const adapter = new JSONFile(DB_FILE);
|
||||
dbInstance = new Low(adapter, dbCache.data);
|
||||
}
|
||||
dbInstance.data = dbCache.data;
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
// Always read latest disk state to avoid stale singleton data across route workers.
|
||||
try {
|
||||
await safeRead(dbInstance);
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
console.warn('[DB] Corrupt JSON detected, resetting to defaults...');
|
||||
// Cold path: serialize with writes to prevent race conditions
|
||||
return withDbLock(async () => {
|
||||
// Re-check cache inside lock — another queued task may have already loaded it
|
||||
if (dbCache.data && Date.now() - dbCache.ts < DB_CACHE_TTL) {
|
||||
dbInstance.data = dbCache.data;
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
if (!dbInstance) {
|
||||
const adapter = new JSONFile(DB_FILE);
|
||||
dbInstance = new Low(adapter, cloneDefaultData());
|
||||
}
|
||||
|
||||
try {
|
||||
await safeRead(dbInstance);
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
console.warn("[DB] Corrupt JSON detected, resetting to defaults...");
|
||||
dbInstance.data = cloneDefaultData();
|
||||
await safeWrite(dbInstance);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize/migrate missing keys for older DB schema versions
|
||||
if (!dbInstance.data) {
|
||||
dbInstance.data = cloneDefaultData();
|
||||
await safeWrite(dbInstance);
|
||||
} else {
|
||||
throw error;
|
||||
const { data, changed } = ensureDbShape(dbInstance.data);
|
||||
dbInstance.data = data;
|
||||
if (changed) await safeWrite(dbInstance);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize/migrate missing keys for older DB schema versions.
|
||||
if (!dbInstance.data) {
|
||||
dbInstance.data = cloneDefaultData();
|
||||
await safeWrite(dbInstance);
|
||||
} else {
|
||||
const { data, changed } = ensureDbShape(dbInstance.data);
|
||||
dbInstance.data = data;
|
||||
if (changed) {
|
||||
await safeWrite(dbInstance);
|
||||
}
|
||||
}
|
||||
// Update cache after successful read
|
||||
dbCache.data = dbInstance.data;
|
||||
dbCache.ts = Date.now();
|
||||
|
||||
return dbInstance;
|
||||
return dbInstance;
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Provider Connections ============
|
||||
@@ -608,10 +651,7 @@ export async function createProviderConnection(data) {
|
||||
}
|
||||
|
||||
db.data.providerConnections.push(connection);
|
||||
await safeWrite(db);
|
||||
|
||||
// Reorder to ensure consistency
|
||||
await reorderProviderConnections(data.provider);
|
||||
await reorderProviderConnections(data.provider, db);
|
||||
|
||||
return connection;
|
||||
}
|
||||
@@ -633,11 +673,11 @@ export async function updateProviderConnection(id, data) {
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await safeWrite(db);
|
||||
|
||||
// Reorder if priority was changed
|
||||
// Reorder if priority was changed, reuse same db instance to avoid double-read
|
||||
if (data.priority !== undefined) {
|
||||
await reorderProviderConnections(providerId);
|
||||
await reorderProviderConnections(providerId, db);
|
||||
} else {
|
||||
await safeWrite(db);
|
||||
}
|
||||
|
||||
return db.data.providerConnections[index];
|
||||
@@ -655,37 +695,35 @@ export async function deleteProviderConnection(id) {
|
||||
const providerId = db.data.providerConnections[index].provider;
|
||||
|
||||
db.data.providerConnections.splice(index, 1);
|
||||
await safeWrite(db);
|
||||
|
||||
// Reorder to fill gaps
|
||||
await reorderProviderConnections(providerId);
|
||||
// Reorder to fill gaps, reuse same db instance to avoid double-read
|
||||
await reorderProviderConnections(providerId, db);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder provider connections to ensure unique, sequential priorities
|
||||
* Reorder provider connections to ensure unique, sequential priorities.
|
||||
* Accepts an existing db instance to avoid redundant getDb() calls and
|
||||
* prevent double-read race conditions within the same write operation.
|
||||
*/
|
||||
export async function reorderProviderConnections(providerId) {
|
||||
const db = await getDb();
|
||||
if (!db.data.providerConnections) return;
|
||||
export async function reorderProviderConnections(providerId, db) {
|
||||
const instance = db || (await getDb());
|
||||
if (!instance.data.providerConnections) return;
|
||||
|
||||
const providerConnections = db.data.providerConnections
|
||||
const providerConnections = instance.data.providerConnections
|
||||
.filter(c => c.provider === providerId)
|
||||
.sort((a, b) => {
|
||||
// Sort by priority first
|
||||
const pDiff = (a.priority || 0) - (b.priority || 0);
|
||||
if (pDiff !== 0) return pDiff;
|
||||
// Use updatedAt as tie-breaker (newer first)
|
||||
return new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0);
|
||||
});
|
||||
|
||||
// Re-assign sequential priorities
|
||||
providerConnections.forEach((conn, index) => {
|
||||
conn.priority = index + 1;
|
||||
});
|
||||
|
||||
await safeWrite(db);
|
||||
await safeWrite(instance);
|
||||
}
|
||||
|
||||
// ============ Model Aliases ============
|
||||
|
||||
@@ -216,6 +216,31 @@ export const CLINE_CONFIG = {
|
||||
refreshUrl: "https://api.cline.bot/api/v1/auth/refresh",
|
||||
};
|
||||
|
||||
// GitLab Duo OAuth Configuration (Authorization Code Flow with PKCE)
|
||||
// Supports both OAuth (PKCE) and Personal Access Token (PAT) modes
|
||||
export const GITLAB_CONFIG = {
|
||||
defaultBaseUrl: "https://gitlab.com",
|
||||
authorizeUrlPath: "/oauth/authorize",
|
||||
tokenUrlPath: "/oauth/token",
|
||||
userInfoUrlPath: "/api/v4/user",
|
||||
scope: "api read_user",
|
||||
codeChallengeMethod: "S256",
|
||||
};
|
||||
|
||||
// CodeBuddy (Tencent) OAuth Configuration (Browser OAuth Polling Flow)
|
||||
// Step 1: POST /v2/plugin/auth/state?platform=CLI → get { state, authUrl }
|
||||
// Step 2: Open authUrl in browser
|
||||
// Step 3: Poll POST /v2/plugin/auth/token with state until success
|
||||
export const CODEBUDDY_CONFIG = {
|
||||
baseUrl: "https://copilot.tencent.com",
|
||||
stateUrl: "https://copilot.tencent.com/v2/plugin/auth/state",
|
||||
tokenUrl: "https://copilot.tencent.com/v2/plugin/auth/token",
|
||||
refreshUrl: "https://copilot.tencent.com/v2/plugin/auth/token/refresh",
|
||||
userAgent: "CLI/2.63.2 CodeBuddy/2.63.2",
|
||||
platform: "CLI",
|
||||
pollInterval: 5000,
|
||||
};
|
||||
|
||||
// OAuth timeout (5 minutes)
|
||||
export const OAUTH_TIMEOUT = 300000;
|
||||
|
||||
@@ -234,4 +259,6 @@ export const PROVIDERS = {
|
||||
KIMI_CODING: "kimi-coding",
|
||||
KILOCODE: "kilocode",
|
||||
CLINE: "cline",
|
||||
GITLAB: "gitlab",
|
||||
CODEBUDDY: "codebuddy",
|
||||
};
|
||||
|
||||
+143
-5
@@ -20,6 +20,8 @@ import {
|
||||
KIMI_CODING_CONFIG,
|
||||
KILOCODE_CONFIG,
|
||||
CLINE_CONFIG,
|
||||
GITLAB_CONFIG,
|
||||
CODEBUDDY_CONFIG,
|
||||
} from "./constants/oauth";
|
||||
|
||||
// Provider configurations
|
||||
@@ -873,6 +875,140 @@ const PROVIDERS = {
|
||||
providerSpecificData: { firstName: tokens.firstName, lastName: tokens.lastName },
|
||||
}),
|
||||
},
|
||||
// GitLab Duo - Authorization Code Flow with PKCE
|
||||
// Supports two login modes via loginMode metadata: "oauth" (default) or "pat"
|
||||
gitlab: {
|
||||
config: GITLAB_CONFIG,
|
||||
flowType: "authorization_code_pkce",
|
||||
buildAuthUrl: (config, redirectUri, state, codeChallenge, meta = {}) => {
|
||||
const baseUrl = meta.baseUrl || config.defaultBaseUrl;
|
||||
const clientId = meta.clientId || "";
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: "code",
|
||||
state,
|
||||
scope: config.scope,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: config.codeChallengeMethod,
|
||||
});
|
||||
return `${baseUrl}${config.authorizeUrlPath}?${params.toString()}`;
|
||||
},
|
||||
exchangeToken: async (config, code, redirectUri, codeVerifier, state, meta = {}) => {
|
||||
const baseUrl = meta.baseUrl || config.defaultBaseUrl;
|
||||
const clientId = meta.clientId || "";
|
||||
const clientSecret = meta.clientSecret || "";
|
||||
const body = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: redirectUri,
|
||||
code_verifier: codeVerifier,
|
||||
});
|
||||
if (clientSecret) body.set("client_secret", clientSecret);
|
||||
const response = await fetch(`${baseUrl}${config.tokenUrlPath}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!response.ok) throw new Error(`GitLab token exchange failed: ${await response.text()}`);
|
||||
const tokens = await response.json();
|
||||
// Fetch user info
|
||||
const userRes = await fetch(`${baseUrl}${config.userInfoUrlPath}`, {
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||
});
|
||||
const user = userRes.ok ? await userRes.json() : {};
|
||||
return { ...tokens, _user: user, _baseUrl: baseUrl, _clientId: clientId };
|
||||
},
|
||||
mapTokens: (tokens) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
scope: tokens.scope,
|
||||
providerSpecificData: {
|
||||
username: tokens._user?.username || "",
|
||||
email: tokens._user?.email || tokens._user?.public_email || "",
|
||||
name: tokens._user?.name || "",
|
||||
baseUrl: tokens._baseUrl,
|
||||
clientId: tokens._clientId,
|
||||
authKind: "oauth",
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
// CodeBuddy (Tencent) - Browser OAuth Polling Flow
|
||||
// 1. POST stateUrl → get { state, authUrl }
|
||||
// 2. Open authUrl in browser
|
||||
// 3. Poll tokenUrl with state until success (code 0) or timeout
|
||||
codebuddy: {
|
||||
config: CODEBUDDY_CONFIG,
|
||||
flowType: "device_code",
|
||||
requestDeviceCode: async (config) => {
|
||||
const response = await fetch(`${config.stateUrl}?platform=${config.platform}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"User-Agent": config.userAgent,
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"X-Domain": "copilot.tencent.com",
|
||||
"X-No-Authorization": "true",
|
||||
"X-No-User-Id": "true",
|
||||
"X-Product": "SaaS",
|
||||
},
|
||||
body: "{}",
|
||||
});
|
||||
if (!response.ok) throw new Error(`CodeBuddy state request failed: ${await response.text()}`);
|
||||
const data = await response.json();
|
||||
if (data.code !== 0 || !data.data?.state || !data.data?.authUrl) {
|
||||
throw new Error(`CodeBuddy state error: ${data.msg || "missing state/authUrl"}`);
|
||||
}
|
||||
return {
|
||||
device_code: data.data.state,
|
||||
verification_uri: data.data.authUrl,
|
||||
user_code: "",
|
||||
interval: config.pollInterval / 1000,
|
||||
_isCodeBuddy: true,
|
||||
};
|
||||
},
|
||||
pollToken: async (config, deviceCode) => {
|
||||
const response = await fetch(config.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"User-Agent": config.userAgent,
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
"X-Domain": "copilot.tencent.com",
|
||||
"X-No-Authorization": "true",
|
||||
"X-No-User-Id": "true",
|
||||
"X-Product": "SaaS",
|
||||
},
|
||||
body: JSON.stringify({ state: deviceCode }),
|
||||
});
|
||||
if (!response.ok) return { ok: false, data: { error: "request_failed" } };
|
||||
const data = await response.json();
|
||||
// code 11217 = pending, code 0 = success
|
||||
if (data.code === 0 && data.data?.accessToken) {
|
||||
return {
|
||||
ok: true,
|
||||
data: {
|
||||
access_token: data.data.accessToken,
|
||||
refresh_token: data.data.refreshToken || "",
|
||||
token_type: data.data.tokenType || "Bearer",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (data.code === 11217) return { ok: true, data: { error: "authorization_pending" } };
|
||||
return { ok: false, data: { error: data.msg || "unknown_error" } };
|
||||
},
|
||||
mapTokens: (tokens) => ({
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token,
|
||||
expiresIn: 86400,
|
||||
providerSpecificData: {},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -895,8 +1031,9 @@ export function getProviderNames() {
|
||||
|
||||
/**
|
||||
* Generate auth data for a provider
|
||||
* @param {object} [meta] - Provider-specific metadata (e.g. gitlab clientId/baseUrl)
|
||||
*/
|
||||
export function generateAuthData(providerName, redirectUri) {
|
||||
export function generateAuthData(providerName, redirectUri, meta) {
|
||||
const provider = getProvider(providerName);
|
||||
const { codeVerifier, codeChallenge, state } = generatePKCE();
|
||||
|
||||
@@ -905,9 +1042,9 @@ export function generateAuthData(providerName, redirectUri) {
|
||||
// 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);
|
||||
authUrl = provider.buildAuthUrl(provider.config, redirectUri, state, codeChallenge, meta || {});
|
||||
} else {
|
||||
authUrl = provider.buildAuthUrl(provider.config, redirectUri, state);
|
||||
authUrl = provider.buildAuthUrl(provider.config, redirectUri, state, undefined, meta || {});
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -924,11 +1061,12 @@ export function generateAuthData(providerName, redirectUri) {
|
||||
|
||||
/**
|
||||
* Exchange code for tokens
|
||||
* @param {object} [meta] - Provider-specific metadata (e.g. gitlab clientId/baseUrl)
|
||||
*/
|
||||
export async function exchangeTokens(providerName, code, redirectUri, codeVerifier, state) {
|
||||
export async function exchangeTokens(providerName, code, redirectUri, codeVerifier, state, meta) {
|
||||
const provider = getProvider(providerName);
|
||||
|
||||
const tokens = await provider.exchangeToken(provider.config, code, redirectUri, codeVerifier, state);
|
||||
const tokens = await provider.exchangeToken(provider.config, code, redirectUri, codeVerifier, state, meta || {});
|
||||
|
||||
let extra = null;
|
||||
if (provider.postExchange) {
|
||||
|
||||
@@ -65,8 +65,8 @@ async function getObservabilityConfig() {
|
||||
const { getSettings } = await import("@/lib/localDb");
|
||||
const settings = await getSettings();
|
||||
const envEnabled = process.env.OBSERVABILITY_ENABLED !== "false";
|
||||
const enabled = typeof settings.observabilityEnabled === "boolean"
|
||||
? settings.observabilityEnabled
|
||||
const enabled = typeof settings.enableObservability === "boolean"
|
||||
? settings.enableObservability
|
||||
: envEnabled;
|
||||
|
||||
cachedConfig = {
|
||||
@@ -78,7 +78,7 @@ async function getObservabilityConfig() {
|
||||
};
|
||||
} catch {
|
||||
cachedConfig = {
|
||||
enabled: true,
|
||||
enabled: false,
|
||||
maxRecords: DEFAULT_MAX_RECORDS,
|
||||
batchSize: DEFAULT_BATCH_SIZE,
|
||||
flushIntervalMs: DEFAULT_FLUSH_INTERVAL_MS,
|
||||
|
||||
Reference in New Issue
Block a user