mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
feat: restore legacy JSON config import/export (#1012)
Adds legacy 9router JSON config import/export with Zero-Trust security (password/requireLogin redacted). Integrated into release/v3.5.4.
This commit is contained in:
@@ -23,6 +23,7 @@ export default function SystemStorageTab() {
|
||||
const [purgeLogsLoading, setPurgeLogsLoading] = useState(false);
|
||||
const [purgeLogsStatus, setPurgeLogsStatus] = useState({ type: "", message: "" });
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const jsonInputRef = useRef<HTMLInputElement>(null);
|
||||
const locale = useLocale();
|
||||
const t = useTranslations("settings");
|
||||
const tc = useTranslations("common");
|
||||
@@ -128,28 +129,108 @@ export default function SystemStorageTab() {
|
||||
loadStorageHealth();
|
||||
}, []);
|
||||
|
||||
/** Triggers a browser file download from an existing Blob. */
|
||||
const triggerDownload = (blob: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
/** Fetches a URL, reads the response as a Blob and triggers a download. */
|
||||
const fetchAndDownload = async (
|
||||
apiUrl: string,
|
||||
fallbackFilename: string,
|
||||
errorMessage: string
|
||||
) => {
|
||||
const res = await fetch(apiUrl);
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error((data as { error?: string }).error || errorMessage);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const disposition = res.headers.get("Content-Disposition") || "";
|
||||
const filenameMatch = disposition.match(/filename="?([^"]+)"?/);
|
||||
triggerDownload(blob, filenameMatch?.[1] || fallbackFilename);
|
||||
};
|
||||
|
||||
const handleExportJson = async () => {
|
||||
setExportLoading(true);
|
||||
try {
|
||||
await fetchAndDownload(
|
||||
"/api/settings/export-json",
|
||||
`omniroute-legacy-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.json`,
|
||||
"JSON Export failed"
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Export JSON failed:", err);
|
||||
setImportStatus({
|
||||
type: "error",
|
||||
message: t("exportFailedWithError", { error: (err as Error).message }),
|
||||
});
|
||||
} finally {
|
||||
setExportLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImportJsonClick = () => {
|
||||
jsonInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleJsonSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (!file.name.endsWith(".json")) {
|
||||
setImportStatus({
|
||||
type: "error",
|
||||
message: "Invalid file type. Only .json allowed.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto import JSON
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
setImportLoading(true);
|
||||
const res = await fetch("/api/settings/import-json", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: e.target?.result as string,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setImportStatus({
|
||||
type: "success",
|
||||
message: data.message || "Legacy JSON imported successfully!",
|
||||
});
|
||||
await loadStorageHealth();
|
||||
if (backupsExpanded) await loadBackups();
|
||||
} else {
|
||||
setImportStatus({ type: "error", message: data.error || "Failed to import JSON" });
|
||||
}
|
||||
} catch (err) {
|
||||
setImportStatus({ type: "error", message: "Error during JSON import" });
|
||||
} finally {
|
||||
setImportLoading(false);
|
||||
if (jsonInputRef.current) jsonInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
setExportLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/db-backups/export");
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || t("exportFailed"));
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const disposition = res.headers.get("Content-Disposition") || "";
|
||||
const filenameMatch = disposition.match(/filename="(.+)"/);
|
||||
const filename = filenameMatch
|
||||
? filenameMatch[1]
|
||||
: `omniroute-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.sqlite`;
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
await fetchAndDownload(
|
||||
"/api/db-backups/export",
|
||||
`omniroute-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.sqlite`,
|
||||
t("exportFailed")
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Export failed:", err);
|
||||
setImportStatus({
|
||||
@@ -320,20 +401,11 @@ export default function SystemStorageTab() {
|
||||
onClick={async () => {
|
||||
setExportLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/db-backups/exportAll");
|
||||
if (!res.ok) throw new Error(t("exportFailed"));
|
||||
const blob = await res.blob();
|
||||
const cd = res.headers.get("Content-Disposition") || "";
|
||||
const filenameMatch = cd.match(/filename="?([^"]+)"?/);
|
||||
const filename = filenameMatch?.[1] || `omniroute-full-backup.tar.gz`;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
await fetchAndDownload(
|
||||
"/api/db-backups/exportAll",
|
||||
"omniroute-full-backup.tar.gz",
|
||||
t("exportFailed")
|
||||
);
|
||||
} catch (err) {
|
||||
setImportStatus({
|
||||
type: "error",
|
||||
@@ -363,6 +435,25 @@ export default function SystemStorageTab() {
|
||||
className="hidden"
|
||||
onChange={handleFileSelected}
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={handleExportJson} loading={exportLoading}>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
|
||||
data_object
|
||||
</span>
|
||||
Export JSON
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleImportJsonClick} loading={importLoading}>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1" aria-hidden="true">
|
||||
data_object
|
||||
</span>
|
||||
Import JSON
|
||||
</Button>
|
||||
<input
|
||||
ref={jsonInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
className="hidden"
|
||||
onChange={handleJsonSelected}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Import confirmation dialog */}
|
||||
|
||||
59
src/app/api/settings/export-json/route.ts
Normal file
59
src/app/api/settings/export-json/route.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
getSettings,
|
||||
getProviderConnections,
|
||||
getProviderNodes,
|
||||
getCombos,
|
||||
getApiKeys,
|
||||
} from "@/lib/localDb";
|
||||
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
|
||||
/**
|
||||
* GET /api/settings/export-json
|
||||
* Exports a legacy 9router compatible JSON backup.
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
if (await isAuthRequired()) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const rawSettings = await getSettings();
|
||||
|
||||
// REDACT sensitive security keys to maintain Zero-Trust posture
|
||||
// even if the admin shares their backup file.
|
||||
// Use destructuring (not delete) to avoid mutating a potentially cached object.
|
||||
const { password: _pw, requireLogin: _rl, ...safeSettings } = rawSettings;
|
||||
|
||||
const providerConnections = await getProviderConnections();
|
||||
const providerNodes = await getProviderNodes();
|
||||
const combos = await getCombos();
|
||||
const apiKeys = await getApiKeys();
|
||||
|
||||
const exportData = {
|
||||
settings: safeSettings,
|
||||
providerConnections,
|
||||
providerNodes,
|
||||
combos,
|
||||
apiKeys,
|
||||
// Metadata to identify export version
|
||||
_meta: {
|
||||
exportedAt: new Date().toISOString(),
|
||||
version: "omniroute-v3-legacy-export"
|
||||
}
|
||||
};
|
||||
|
||||
return new NextResponse(JSON.stringify(exportData, null, 2), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Disposition": `attachment; filename="omniroute-legacy-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.json"`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[API] Error exporting JSON backup:", error);
|
||||
return NextResponse.json({ error: "Failed to export JSON" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
80
src/app/api/settings/import-json/route.ts
Normal file
80
src/app/api/settings/import-json/route.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getDbInstance } from "@/lib/db/core";
|
||||
import { backupDbFile } from "@/lib/db/backup";
|
||||
import { isAuthRequired, isAuthenticated } from "@/shared/utils/apiAuth";
|
||||
import { runJsonMigration, type LegacyJsonData } from "@/lib/db/jsonMigration";
|
||||
|
||||
/**
|
||||
* POST /api/settings/import-json
|
||||
*
|
||||
* Imports a legacy 9router / OmniRoute JSON backup into the current SQLite
|
||||
* database. Accepts either multipart/form-data (file field) or a raw JSON body.
|
||||
*
|
||||
* 🔒 Auth-guarded.
|
||||
* 🔒 Zero-Trust: password and requireLogin keys are stripped before insertion.
|
||||
* 🔒 A pre-import backup is created automatically before any data is written.
|
||||
*/
|
||||
export async function POST(request: Request) {
|
||||
if (await isAuthRequired()) {
|
||||
if (!(await isAuthenticated(request))) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let rawText: string | null = null;
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
|
||||
if (contentType.includes("multipart/form-data")) {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file) return NextResponse.json({ error: "No json file provided" }, { status: 400 });
|
||||
rawText = await file.text();
|
||||
} else {
|
||||
rawText = await request.text();
|
||||
}
|
||||
|
||||
if (!rawText?.trim()) {
|
||||
return NextResponse.json({ error: "Empty request payload" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Parse with explicit 400 on malformed JSON (Gemini suggestion)
|
||||
let data: LegacyJsonData;
|
||||
try {
|
||||
data = JSON.parse(rawText) as LegacyJsonData;
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid JSON: the file could not be parsed. Please upload a valid .json backup." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 🔒 Zero-Trust: strip authentication config before migration
|
||||
if (data.settings) {
|
||||
const { password: _pw, requireLogin: _rl, ...safeSettings } = data.settings;
|
||||
data = { ...data, settings: safeSettings };
|
||||
}
|
||||
|
||||
const db = getDbInstance();
|
||||
|
||||
// Create a safety backup before writing anything
|
||||
backupDbFile("pre-json-import");
|
||||
|
||||
// Delegate the actual migration to the shared helper (avoids duplication with core.ts)
|
||||
const counts = runJsonMigration(db, data);
|
||||
|
||||
console.log(
|
||||
`[JSON Import] Imported ${counts.connections} connections, ${counts.nodes} nodes, ` +
|
||||
`${counts.combos} combos, ${counts.apiKeys} API keys`
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Legacy JSON database imported successfully",
|
||||
...counts,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[API] Error importing JSON backup:", err);
|
||||
return NextResponse.json({ error: (err as Error).message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
206
src/lib/db/jsonMigration.ts
Normal file
206
src/lib/db/jsonMigration.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* db/jsonMigration.ts — Shared helper to hydrate an SQLite database from a
|
||||
* legacy 9router / OmniRoute JSON backup object.
|
||||
*
|
||||
* Used by:
|
||||
* - db/core.ts (auto-migration at startup when db.json is found)
|
||||
* - api/settings/import-json/route.ts (on-demand import via dashboard)
|
||||
*
|
||||
* 🔒 Security: the caller is responsible for stripping sensitive keys
|
||||
* (password, requireLogin) from `data.settings` BEFORE passing the object
|
||||
* here, so this function never touches authentication configuration.
|
||||
*/
|
||||
|
||||
import type Database from "better-sqlite3";
|
||||
|
||||
type SqliteDatabase = InstanceType<typeof Database>;
|
||||
|
||||
export interface LegacyJsonData {
|
||||
providerConnections?: Record<string, unknown>[];
|
||||
providerNodes?: Record<string, unknown>[];
|
||||
combos?: Record<string, unknown>[];
|
||||
apiKeys?: Record<string, unknown>[];
|
||||
settings?: Record<string, unknown>;
|
||||
modelAliases?: Record<string, unknown>;
|
||||
mitmAlias?: Record<string, unknown>;
|
||||
pricing?: Record<string, unknown>;
|
||||
customModels?: Record<string, unknown>;
|
||||
proxyConfig?: {
|
||||
global?: unknown;
|
||||
providers?: unknown;
|
||||
combos?: unknown;
|
||||
keys?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a single SQLite transaction that upserts all entities from a legacy
|
||||
* JSON backup into the provided database instance.
|
||||
*
|
||||
* Returns counts of what was inserted/replaced for logging.
|
||||
*/
|
||||
export function runJsonMigration(
|
||||
db: SqliteDatabase,
|
||||
data: LegacyJsonData
|
||||
): { connections: number; nodes: number; combos: number; apiKeys: number } {
|
||||
const insertConn = db.prepare(`
|
||||
INSERT OR REPLACE INTO provider_connections (
|
||||
id, provider, auth_type, name, email, priority, is_active,
|
||||
access_token, refresh_token, expires_at, token_expires_at,
|
||||
scope, project_id, test_status, error_code, last_error,
|
||||
last_error_at, last_error_type, last_error_source, backoff_level,
|
||||
rate_limited_until, health_check_interval, last_health_check_at,
|
||||
last_tested, api_key, id_token, provider_specific_data,
|
||||
expires_in, display_name, global_priority, default_model,
|
||||
token_type, consecutive_use_count, rate_limit_protection, last_used_at, created_at, updated_at
|
||||
) VALUES (
|
||||
@id, @provider, @authType, @name, @email, @priority, @isActive,
|
||||
@accessToken, @refreshToken, @expiresAt, @tokenExpiresAt,
|
||||
@scope, @projectId, @testStatus, @errorCode, @lastError,
|
||||
@lastErrorAt, @lastErrorType, @lastErrorSource, @backoffLevel,
|
||||
@rateLimitedUntil, @healthCheckInterval, @lastHealthCheckAt,
|
||||
@lastTested, @apiKey, @idToken, @providerSpecificData,
|
||||
@expiresIn, @displayName, @globalPriority, @defaultModel,
|
||||
@tokenType, @consecutiveUseCount, @rateLimitProtection, @lastUsedAt, @createdAt, @updatedAt
|
||||
)
|
||||
`);
|
||||
|
||||
const insertNode = db.prepare(`
|
||||
INSERT OR REPLACE INTO provider_nodes (id, type, name, prefix, api_type, base_url, created_at, updated_at)
|
||||
VALUES (@id, @type, @name, @prefix, @apiType, @baseUrl, @createdAt, @updatedAt)
|
||||
`);
|
||||
|
||||
const insertKv = db.prepare(
|
||||
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)"
|
||||
);
|
||||
|
||||
const insertCombo = db.prepare(`
|
||||
INSERT OR REPLACE INTO combos (id, name, data, created_at, updated_at)
|
||||
VALUES (@id, @name, @data, @createdAt, @updatedAt)
|
||||
`);
|
||||
|
||||
const insertKey = db.prepare(`
|
||||
INSERT OR REPLACE INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at)
|
||||
VALUES (@id, @name, @key, @machineId, @allowedModels, @noLog, @createdAt)
|
||||
`);
|
||||
|
||||
const migrate = db.transaction(() => {
|
||||
// 1. Provider Connections
|
||||
for (const conn of data.providerConnections ?? []) {
|
||||
insertConn.run({
|
||||
id: conn.id,
|
||||
provider: conn.provider,
|
||||
authType: conn.authType ?? "oauth",
|
||||
name: conn.name ?? null,
|
||||
email: conn.email ?? null,
|
||||
priority: conn.priority ?? 0,
|
||||
isActive: conn.isActive === false ? 0 : 1,
|
||||
accessToken: conn.accessToken ?? null,
|
||||
refreshToken: conn.refreshToken ?? null,
|
||||
expiresAt: conn.expiresAt ?? null,
|
||||
tokenExpiresAt: conn.tokenExpiresAt ?? null,
|
||||
scope: conn.scope ?? null,
|
||||
projectId: conn.projectId ?? null,
|
||||
testStatus: conn.testStatus ?? null,
|
||||
errorCode: conn.errorCode ?? null,
|
||||
lastError: conn.lastError ?? null,
|
||||
lastErrorAt: conn.lastErrorAt ?? null,
|
||||
lastErrorType: conn.lastErrorType ?? null,
|
||||
lastErrorSource: conn.lastErrorSource ?? null,
|
||||
backoffLevel: conn.backoffLevel ?? 0,
|
||||
rateLimitedUntil: conn.rateLimitedUntil ?? null,
|
||||
healthCheckInterval: conn.healthCheckInterval ?? null,
|
||||
lastHealthCheckAt: conn.lastHealthCheckAt ?? null,
|
||||
lastTested: conn.lastTested ?? null,
|
||||
apiKey: conn.apiKey ?? null,
|
||||
idToken: conn.idToken ?? null,
|
||||
providerSpecificData: conn.providerSpecificData
|
||||
? JSON.stringify(conn.providerSpecificData)
|
||||
: null,
|
||||
expiresIn: conn.expiresIn ?? null,
|
||||
displayName: conn.displayName ?? null,
|
||||
globalPriority: conn.globalPriority ?? null,
|
||||
defaultModel: conn.defaultModel ?? null,
|
||||
tokenType: conn.tokenType ?? null,
|
||||
consecutiveUseCount: conn.consecutiveUseCount ?? 0,
|
||||
lastUsedAt: conn.lastUsedAt ?? null,
|
||||
rateLimitProtection:
|
||||
conn.rateLimitProtection === true || conn.rateLimitProtection === 1 ? 1 : 0,
|
||||
createdAt: conn.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: conn.updatedAt ?? new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Provider Nodes
|
||||
for (const node of data.providerNodes ?? []) {
|
||||
insertNode.run({
|
||||
id: node.id,
|
||||
type: node.type,
|
||||
name: node.name,
|
||||
prefix: node.prefix ?? null,
|
||||
apiType: node.apiType ?? null,
|
||||
baseUrl: node.baseUrl ?? null,
|
||||
createdAt: node.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: node.updatedAt ?? new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Key-Value Settings (caller must have stripped password / requireLogin)
|
||||
for (const [key, value] of Object.entries(data.settings ?? {})) {
|
||||
insertKv.run("settings", key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
// 4. Legacy key-value namespaces
|
||||
for (const [alias, model] of Object.entries(data.modelAliases ?? {})) {
|
||||
insertKv.run("modelAliases", alias, JSON.stringify(model));
|
||||
}
|
||||
for (const [toolName, mappings] of Object.entries(data.mitmAlias ?? {})) {
|
||||
insertKv.run("mitmAlias", toolName, JSON.stringify(mappings));
|
||||
}
|
||||
for (const [provider, models] of Object.entries(data.pricing ?? {})) {
|
||||
insertKv.run("pricing", provider, JSON.stringify(models));
|
||||
}
|
||||
for (const [providerId, models] of Object.entries(data.customModels ?? {})) {
|
||||
insertKv.run("customModels", providerId, JSON.stringify(models));
|
||||
}
|
||||
if (data.proxyConfig) {
|
||||
insertKv.run("proxyConfig", "global", JSON.stringify(data.proxyConfig.global ?? null));
|
||||
insertKv.run("proxyConfig", "providers", JSON.stringify(data.proxyConfig.providers ?? {}));
|
||||
insertKv.run("proxyConfig", "combos", JSON.stringify(data.proxyConfig.combos ?? {}));
|
||||
insertKv.run("proxyConfig", "keys", JSON.stringify(data.proxyConfig.keys ?? {}));
|
||||
}
|
||||
|
||||
// 5. Combos
|
||||
for (const combo of data.combos ?? []) {
|
||||
insertCombo.run({
|
||||
id: combo.id,
|
||||
name: combo.name,
|
||||
data: JSON.stringify(combo),
|
||||
createdAt: combo.createdAt ?? new Date().toISOString(),
|
||||
updatedAt: combo.updatedAt ?? new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 6. API Keys
|
||||
for (const apiKey of data.apiKeys ?? []) {
|
||||
insertKey.run({
|
||||
id: apiKey.id,
|
||||
name: apiKey.name,
|
||||
key: apiKey.key,
|
||||
machineId: apiKey.machineId ?? null,
|
||||
allowedModels: JSON.stringify(apiKey.allowedModels ?? []),
|
||||
noLog: apiKey.noLog ? 1 : 0,
|
||||
createdAt: apiKey.createdAt ?? new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
migrate();
|
||||
|
||||
return {
|
||||
connections: (data.providerConnections ?? []).length,
|
||||
nodes: (data.providerNodes ?? []).length,
|
||||
combos: (data.combos ?? []).length,
|
||||
apiKeys: (data.apiKeys ?? []).length,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user