Files
9router/src/lib/db/repos/connectionsRepo.js
T

284 lines
9.7 KiB
JavaScript

import { v4 as uuidv4 } from "uuid";
import { getAdapter } from "../driver.js";
import { parseJson, stringifyJson } from "../helpers/jsonCol.js";
const OPTIONAL_FIELDS = [
"displayName", "email", "globalPriority", "defaultModel",
"accessToken", "refreshToken", "expiresAt", "tokenType",
"scope", "projectId", "apiKey", "testStatus",
"lastTested", "lastError", "lastErrorAt", "rateLimitedUntil", "expiresIn", "errorCode",
"consecutiveUseCount", "idToken", "lastRefreshAt",
];
function rowToConn(row) {
if (!row) return null;
const extra = parseJson(row.data, {});
return {
...extra,
id: row.id,
provider: row.provider,
authType: row.authType,
name: row.name,
email: row.email,
ownerId: row.ownerId,
priority: row.priority,
isActive: row.isActive === 1 || row.isActive === true,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function connToRow(c) {
const { id, provider, authType, name, email, ownerId, priority, isActive, createdAt, updatedAt, ...rest } = c;
return {
id,
provider,
authType,
name: name ?? null,
email: email ?? null,
ownerId: ownerId ?? null,
priority: priority ?? null,
isActive: isActive === false ? 0 : 1,
data: stringifyJson(rest),
createdAt,
updatedAt,
};
}
function upsert(db, c) {
const r = connToRow(c);
db.run(
`INSERT INTO providerConnections(id, provider, authType, name, email, ownerId, priority, isActive, data, createdAt, updatedAt)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
provider=excluded.provider, authType=excluded.authType, name=excluded.name,
email=excluded.email, ownerId=excluded.ownerId, priority=excluded.priority, isActive=excluded.isActive,
data=excluded.data, updatedAt=excluded.updatedAt`,
[r.id, r.provider, r.authType, r.name, r.email, r.ownerId, r.priority, r.isActive, r.data, r.createdAt, r.updatedAt]
);
}
function deriveConnectionName(data, fallbackName) {
if (data.provider === "github") {
return data.providerSpecificData?.githubLogin
|| data.providerSpecificData?.githubEmail
|| data.email
|| data.providerSpecificData?.githubName
|| fallbackName;
}
return fallbackName;
}
function findDuplicateAccount(connections, data) {
const incomingCredential = data.accessToken || data.apiKey || data.refreshToken;
if (incomingCredential) {
const credentialMatch = connections.find((connection) => (
connection.accessToken === incomingCredential ||
connection.apiKey === incomingCredential ||
connection.refreshToken === incomingCredential
));
if (credentialMatch) return credentialMatch;
}
if (!data.email) return null;
const incomingUsername = data.providerSpecificData?.username;
const incomingWorkspace = data.providerSpecificData?.chatgptAccountId;
return connections.find((connection) => {
if (connection.email !== data.email) return false;
const existingWorkspace = connection.providerSpecificData?.chatgptAccountId;
if (incomingWorkspace && existingWorkspace) {
return incomingWorkspace === existingWorkspace;
}
if (incomingWorkspace || existingWorkspace) return false;
const existingUsername = connection.providerSpecificData?.username;
if (incomingUsername && existingUsername) {
return incomingUsername === existingUsername;
}
if (incomingUsername || existingUsername) return false;
return true;
});
}
function duplicateAccountError() {
const error = new Error("Account already exists in the system");
error.code = "PROVIDER_ACCOUNT_EXISTS";
error.status = 409;
return error;
}
export async function getProviderConnections(filter = {}) {
const db = await getAdapter();
const where = [];
const params = [];
if (filter.provider) { where.push("provider = ?"); params.push(filter.provider); }
// `undefined` is an explicit administrative, unscoped query. `null` scopes
// to legacy/global connections rather than exposing every user's accounts.
if (Object.prototype.hasOwnProperty.call(filter, "ownerId")) {
where.push("ownerId IS ?");
params.push(filter.ownerId);
}
if (filter.isActive !== undefined) { where.push("isActive = ?"); params.push(filter.isActive ? 1 : 0); }
const sql = `SELECT * FROM providerConnections${where.length ? ` WHERE ${where.join(" AND ")}` : ""}`;
const rows = db.all(sql, params);
const list = rows.map(rowToConn);
list.sort((a, b) => (a.priority || 999) - (b.priority || 999));
return list;
}
export async function getProviderConnectionById(id, ownerId = null) {
const db = await getAdapter();
const where = ["id = ?"];
const params = [id];
if (ownerId) {
where.push("ownerId = ?");
params.push(ownerId);
}
const row = db.get(`SELECT * FROM providerConnections WHERE ${where.join(" AND ")}`, params);
return rowToConn(row);
}
// Internal sync reorder — must be called INSIDE a transaction
function reorderInTx(db, providerId) {
const list = db.all(`SELECT * FROM providerConnections WHERE provider = ?`, [providerId]).map(rowToConn);
list.sort((a, b) => {
const pDiff = (a.priority || 0) - (b.priority || 0);
if (pDiff !== 0) return pDiff;
return new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0);
});
list.forEach((c, i) => {
db.run(`UPDATE providerConnections SET priority = ? WHERE id = ?`, [i + 1, c.id]);
});
}
export async function createProviderConnection(data) {
const db = await getAdapter();
const now = new Date().toISOString();
let result;
db.transaction(() => {
const allProviderConnections = db.all(`SELECT * FROM providerConnections WHERE provider = ?`, [data.provider]).map(rowToConn);
const all = data.ownerId
? allProviderConnections.filter((connection) => connection.ownerId === data.ownerId)
: allProviderConnections;
// Account identities are global: two dashboard users must not register the
// same provider account. API-key connections without an account identity
// or matching credential remain private and may use the same display name.
const existingAccount = findDuplicateAccount(allProviderConnections, data);
if (existingAccount) {
throw duplicateAccountError();
}
let connectionName = data.name || null;
if (!connectionName && (data.authType === "oauth" || data.authType === "access_token")) {
connectionName = deriveConnectionName(data, data.email || `Account ${all.length + 1}`);
}
let connectionPriority = data.priority;
if (!connectionPriority) {
connectionPriority = all.reduce((m, c) => Math.max(m, c.priority || 0), 0) + 1;
}
const conn = {
id: uuidv4(),
provider: data.provider,
authType: data.authType || "oauth",
name: connectionName,
ownerId: data.ownerId || null,
priority: connectionPriority,
isActive: data.isActive !== undefined ? data.isActive : true,
createdAt: now,
updatedAt: now,
};
for (const f of OPTIONAL_FIELDS) {
if (data[f] !== undefined && data[f] !== null) conn[f] = data[f];
}
if (data.providerSpecificData && Object.keys(data.providerSpecificData).length > 0) {
conn.providerSpecificData = data.providerSpecificData;
}
if (data.email !== undefined) conn.email = data.email;
upsert(db, conn);
reorderInTx(db, data.provider);
result = conn;
});
return result;
}
// Critical: OAuth refresh token race — atomic merge inside transaction
export async function updateProviderConnection(id, data) {
const db = await getAdapter();
let result;
db.transaction(() => {
const row = db.get(`SELECT * FROM providerConnections WHERE id = ?`, [id]);
if (!row) { result = null; return; }
const existing = rowToConn(row);
const merged = { ...existing, ...data, updatedAt: new Date().toISOString() };
upsert(db, merged);
if (data.priority !== undefined) reorderInTx(db, existing.provider);
result = merged;
});
return result;
}
export async function deleteProviderConnection(id) {
const db = await getAdapter();
let ok = false;
db.transaction(() => {
const row = db.get(`SELECT provider FROM providerConnections WHERE id = ?`, [id]);
if (!row) return;
db.run(`DELETE FROM providerConnections WHERE id = ?`, [id]);
reorderInTx(db, row.provider);
ok = true;
});
return ok;
}
export async function deleteProviderConnectionsByProvider(providerId) {
const db = await getAdapter();
const before = db.get(`SELECT COUNT(*) AS n FROM providerConnections WHERE provider = ?`, [providerId]);
db.run(`DELETE FROM providerConnections WHERE provider = ?`, [providerId]);
return before?.n || 0;
}
export async function reorderProviderConnections(providerId) {
const db = await getAdapter();
db.transaction(() => reorderInTx(db, providerId));
}
export async function cleanupProviderConnections() {
const db = await getAdapter();
const fieldsToCheck = [
"displayName", "email", "globalPriority", "defaultModel",
"accessToken", "refreshToken", "expiresAt", "tokenType",
"scope", "projectId", "apiKey", "testStatus",
"lastTested", "lastError", "lastErrorAt", "rateLimitedUntil", "expiresIn",
"consecutiveUseCount",
];
let cleaned = 0;
db.transaction(() => {
const rows = db.all(`SELECT * FROM providerConnections`);
for (const row of rows) {
const conn = rowToConn(row);
let dirty = false;
for (const f of fieldsToCheck) {
if (conn[f] === null || conn[f] === undefined) {
if (f in conn) { delete conn[f]; cleaned++; dirty = true; }
}
}
if (conn.providerSpecificData && Object.keys(conn.providerSpecificData).length === 0) {
delete conn.providerSpecificData;
cleaned++;
dirty = true;
}
if (dirty) upsert(db, conn);
}
});
return cleaned;
}