mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-04-26 13:31:00 +00:00
fix(cliproxyapi): address PR #914 review — types, SSRF, SQL injection
- Add proper TS interfaces to CliproxyapiSettingsTab (replace any/Record) - Add HTTP status checks and error handling on all fetches - Add client-side URL validation before saving proxy URL - Add error state display for version manager service - Document localhost SSRF exception in isPrivateHost - Add ALLOWED_COLUMNS whitelist to updateVersionManagerTool
This commit is contained in:
parent
8fc97a7f91
commit
90ed6163f5
9 changed files with 413 additions and 16 deletions
250
.omc/project-memory.json
Normal file
250
.omc/project-memory.json
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
{
|
||||
"version": "1.0.0",
|
||||
"lastScanned": 1775016362438,
|
||||
"projectRoot": "/home/openclaw/omniroute-src",
|
||||
"techStack": {
|
||||
"languages": [
|
||||
{
|
||||
"name": "JavaScript/TypeScript",
|
||||
"version": ">=18.0.0 <24.0.0",
|
||||
"confidence": "high",
|
||||
"markers": ["package.json"]
|
||||
},
|
||||
{
|
||||
"name": "TypeScript",
|
||||
"version": null,
|
||||
"confidence": "high",
|
||||
"markers": ["tsconfig.json"]
|
||||
}
|
||||
],
|
||||
"frameworks": [
|
||||
{
|
||||
"name": "express",
|
||||
"version": "5.2.1",
|
||||
"category": "backend"
|
||||
},
|
||||
{
|
||||
"name": "next",
|
||||
"version": "16.0.10",
|
||||
"category": "fullstack"
|
||||
},
|
||||
{
|
||||
"name": "react",
|
||||
"version": "19.2.4",
|
||||
"category": "frontend"
|
||||
},
|
||||
{
|
||||
"name": "react-dom",
|
||||
"version": "19.2.4",
|
||||
"category": "frontend"
|
||||
},
|
||||
{
|
||||
"name": "@playwright/test",
|
||||
"version": "1.58.2",
|
||||
"category": "testing"
|
||||
},
|
||||
{
|
||||
"name": "vitest",
|
||||
"version": "4.0.18",
|
||||
"category": "testing"
|
||||
}
|
||||
],
|
||||
"packageManager": "npm",
|
||||
"runtime": "Node.js 18.0.024.0.0"
|
||||
},
|
||||
"build": {
|
||||
"buildCommand": "npm run build",
|
||||
"testCommand": "npm test",
|
||||
"lintCommand": "npm run lint",
|
||||
"devCommand": "npm run dev",
|
||||
"scripts": {
|
||||
"dev": "node scripts/run-next.mjs dev",
|
||||
"build": "node scripts/build-next-isolated.mjs",
|
||||
"build:cli": "node scripts/prepublish.mjs",
|
||||
"start": "node scripts/run-next.mjs start",
|
||||
"lint": "eslint .",
|
||||
"electron:dev": "concurrently \"npm run dev\" \"wait-on http://localhost:20128 && cd electron && npm run dev\"",
|
||||
"electron:build": "npm run build && cd electron && npm run build",
|
||||
"electron:build:win": "npm run build && cd electron && npm run build:win",
|
||||
"electron:build:mac": "npm run build && cd electron && npm run build:mac",
|
||||
"electron:build:linux": "npm run build && cd electron && npm run build:linux",
|
||||
"test": "node --import tsx/esm --test tests/unit/*.test.mjs",
|
||||
"test:unit": "node --import tsx/esm --test tests/unit/*.test.mjs",
|
||||
"test:plan3": "node --import tsx/esm --test tests/unit/plan3-p0.test.mjs",
|
||||
"test:fixes": "node --import tsx/esm --test tests/unit/fixes-p1.test.mjs",
|
||||
"test:security": "node --import tsx/esm --test tests/unit/security-fase01.test.mjs",
|
||||
"check:cycles": "node scripts/check-cycles.mjs",
|
||||
"check:route-validation:t06": "node scripts/check-route-validation.mjs",
|
||||
"check:any-budget:t11": "node scripts/check-t11-any-budget.mjs",
|
||||
"check:docs-sync": "node scripts/check-docs-sync.mjs",
|
||||
"typecheck:core": "tsc --pretty false -p tsconfig.typecheck-core.json",
|
||||
"typecheck:noimplicit:core": "tsc --pretty false -p tsconfig.typecheck-noimplicit-core.json",
|
||||
"test:integration": "node --import tsx/esm --test tests/integration/*.test.mjs",
|
||||
"test:e2e": "node scripts/run-playwright-tests.mjs test tests/e2e/*.spec.ts",
|
||||
"test:protocols:e2e": "node scripts/run-protocol-clients-tests.mjs",
|
||||
"test:vitest": "vitest run open-sse/mcp-server/__tests__/*.test.ts open-sse/services/autoCombo/__tests__/*.test.ts",
|
||||
"test:ecosystem": "node scripts/run-ecosystem-tests.mjs",
|
||||
"test:coverage": "c8 --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 55 --lines 55 --functions 55 --branches 60 node --import tsx/esm --test tests/unit/*.test.mjs",
|
||||
"test:coverage:legacy": "c8 --exclude=open-sse --check-coverage --lines 50 --functions 50 --branches 50 node --import tsx/esm --test tests/unit/*.test.mjs",
|
||||
"coverage:report": "c8 report --exclude=tests/** --exclude=**/*.test.* --reporter=text --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov",
|
||||
"coverage:report:legacy": "c8 report --exclude=open-sse --reporter=text --reporter=text-summary",
|
||||
"test:all": "npm run test:unit && npm run test:vitest && npm run test:ecosystem && npm run test:e2e",
|
||||
"check": "npm run lint && npm run test",
|
||||
"prepublishOnly": "npm run build:cli",
|
||||
"postinstall": "node scripts/postinstall.mjs",
|
||||
"prepare": "husky",
|
||||
"system-info": "node scripts/system-info.mjs"
|
||||
}
|
||||
},
|
||||
"conventions": {
|
||||
"namingStyle": "camelCase",
|
||||
"importStyle": null,
|
||||
"testPattern": null,
|
||||
"fileOrganization": null
|
||||
},
|
||||
"structure": {
|
||||
"isMonorepo": true,
|
||||
"workspaces": ["open-sse"],
|
||||
"mainDirectories": ["bin", "docs", "public", "scripts", "src", "tests"],
|
||||
"gitBranches": {
|
||||
"defaultBranch": "main",
|
||||
"branchingStrategy": null
|
||||
}
|
||||
},
|
||||
"customNotes": [],
|
||||
"directoryMap": {
|
||||
"bin": {
|
||||
"path": "bin",
|
||||
"purpose": "Executable scripts",
|
||||
"fileCount": 3,
|
||||
"lastAccessed": 1775016362426,
|
||||
"keyFiles": ["mcp-server.mjs", "omniroute.mjs", "reset-password.mjs"]
|
||||
},
|
||||
"docs": {
|
||||
"path": "docs",
|
||||
"purpose": "Documentation",
|
||||
"fileCount": 14,
|
||||
"lastAccessed": 1775016362426,
|
||||
"keyFiles": [
|
||||
"A2A-SERVER.md",
|
||||
"API_REFERENCE.md",
|
||||
"ARCHITECTURE.md",
|
||||
"AUTO-COMBO.md",
|
||||
"CLI-TOOLS.md"
|
||||
]
|
||||
},
|
||||
"electron": {
|
||||
"path": "electron",
|
||||
"purpose": null,
|
||||
"fileCount": 5,
|
||||
"lastAccessed": 1775016362431,
|
||||
"keyFiles": ["README.md", "main.js", "package.json", "preload.js", "types.d.ts"]
|
||||
},
|
||||
"images": {
|
||||
"path": "images",
|
||||
"purpose": null,
|
||||
"fileCount": 1,
|
||||
"lastAccessed": 1775016362434,
|
||||
"keyFiles": ["omniroute.png"]
|
||||
},
|
||||
"logs": {
|
||||
"path": "logs",
|
||||
"purpose": null,
|
||||
"fileCount": 3,
|
||||
"lastAccessed": 1775016362434,
|
||||
"keyFiles": ["build_clean_tools.log", "build_debug.log", "build_force_clean.log"]
|
||||
},
|
||||
"open-sse": {
|
||||
"path": "open-sse",
|
||||
"purpose": null,
|
||||
"fileCount": 5,
|
||||
"lastAccessed": 1775016362434,
|
||||
"keyFiles": ["index.ts", "package.json", "tsconfig.json", "types.d.ts"]
|
||||
},
|
||||
"public": {
|
||||
"path": "public",
|
||||
"purpose": "Public files",
|
||||
"fileCount": 3,
|
||||
"lastAccessed": 1775016362435,
|
||||
"keyFiles": ["apple-touch-icon.svg", "favicon.svg", "icon-192.svg"]
|
||||
},
|
||||
"scripts": {
|
||||
"path": "scripts",
|
||||
"purpose": "Build/utility scripts",
|
||||
"fileCount": 23,
|
||||
"lastAccessed": 1775016362435,
|
||||
"keyFiles": [
|
||||
"bootstrap-env.mjs",
|
||||
"build-next-isolated.mjs",
|
||||
"check-cycles.mjs",
|
||||
"check-docs-sync.mjs",
|
||||
"check-route-validation.mjs"
|
||||
]
|
||||
},
|
||||
"src": {
|
||||
"path": "src",
|
||||
"purpose": "Source code",
|
||||
"fileCount": 4,
|
||||
"lastAccessed": 1775016362435,
|
||||
"keyFiles": ["instrumentation-node.ts", "instrumentation.ts", "proxy.ts", "server-init.ts"]
|
||||
},
|
||||
"tests": {
|
||||
"path": "tests",
|
||||
"purpose": "Test files",
|
||||
"fileCount": 0,
|
||||
"lastAccessed": 1775016362435,
|
||||
"keyFiles": []
|
||||
},
|
||||
"electron/assets": {
|
||||
"path": "electron/assets",
|
||||
"purpose": "Static assets",
|
||||
"fileCount": 4,
|
||||
"lastAccessed": 1775016362436,
|
||||
"keyFiles": ["icon.icns", "icon.ico", "icon.png"]
|
||||
},
|
||||
"open-sse/config": {
|
||||
"path": "open-sse/config",
|
||||
"purpose": "Configuration files",
|
||||
"fileCount": 17,
|
||||
"lastAccessed": 1775016362436,
|
||||
"keyFiles": ["audioRegistry.ts", "cliFingerprints.ts", "codexInstructions.ts"]
|
||||
},
|
||||
"open-sse/services": {
|
||||
"path": "open-sse/services",
|
||||
"purpose": "Business logic services",
|
||||
"fileCount": 35,
|
||||
"lastAccessed": 1775016362437,
|
||||
"keyFiles": ["accountFallback.ts", "accountSelector.ts", "apiKeyRotator.ts"]
|
||||
},
|
||||
"src/app": {
|
||||
"path": "src/app",
|
||||
"purpose": "Application code",
|
||||
"fileCount": 7,
|
||||
"lastAccessed": 1775016362438,
|
||||
"keyFiles": ["error.tsx", "global-error.tsx", "globals.css"]
|
||||
},
|
||||
"src/lib": {
|
||||
"path": "src/lib",
|
||||
"purpose": "Library code",
|
||||
"fileCount": 30,
|
||||
"lastAccessed": 1775016362438,
|
||||
"keyFiles": ["apiBridgeServer.ts", "apiKeyExposure.ts", "cacheControlSettings.ts"]
|
||||
},
|
||||
"src/middleware": {
|
||||
"path": "src/middleware",
|
||||
"purpose": "Middleware",
|
||||
"fileCount": 1,
|
||||
"lastAccessed": 1775016362438,
|
||||
"keyFiles": ["promptInjectionGuard.ts"]
|
||||
},
|
||||
"src/models": {
|
||||
"path": "src/models",
|
||||
"purpose": "Data models",
|
||||
"fileCount": 1,
|
||||
"lastAccessed": 1775016362438,
|
||||
"keyFiles": ["index.ts"]
|
||||
}
|
||||
},
|
||||
"hotPaths": [],
|
||||
"userDirectives": []
|
||||
}
|
||||
8
.omc/sessions/53c002c3-36a6-47c3-a52d-a8f756c264eb.json
Normal file
8
.omc/sessions/53c002c3-36a6-47c3-a52d-a8f756c264eb.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"session_id": "53c002c3-36a6-47c3-a52d-a8f756c264eb",
|
||||
"ended_at": "2026-04-01T04:06:04.924Z",
|
||||
"reason": "prompt_input_exit",
|
||||
"agents_spawned": 0,
|
||||
"agents_completed": 0,
|
||||
"modes_used": []
|
||||
}
|
||||
|
|
@ -1,34 +1,82 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card, Button, Input, Toggle } from "@/shared/components";
|
||||
|
||||
interface Settings {
|
||||
cliproxyapi_fallback_enabled?: boolean;
|
||||
cliproxyapi_url?: string;
|
||||
cliproxyapi_fallback_codes?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface VersionManagerEntry {
|
||||
tool: string;
|
||||
status: string;
|
||||
installedVersion: string | null;
|
||||
healthStatus: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
function isValidUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default function CliproxyapiSettingsTab() {
|
||||
const [settings, setSettings] = useState<Record<string, any>>({});
|
||||
const [settings, setSettings] = useState<Settings>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: string; text: string } | null>(null);
|
||||
const [toolState, setToolState] = useState<any>(null);
|
||||
const [toolState, setToolState] = useState<VersionManagerEntry | null>(null);
|
||||
const [toolStateError, setToolStateError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/settings")
|
||||
.then((r) => r.json())
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`Settings API returned ${r.status}`);
|
||||
return r.json();
|
||||
})
|
||||
.then((data) => {
|
||||
setSettings(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
.catch((err) => {
|
||||
console.error("Failed to load settings:", err);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
fetch("/api/version-manager/status")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
const entry = Array.isArray(data) ? data.find((t: any) => t.tool === "cliproxyapi") : null;
|
||||
setToolState(entry);
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`Version manager API returned ${r.status}`);
|
||||
return r.json();
|
||||
})
|
||||
.catch(() => {});
|
||||
.then((data) => {
|
||||
const entry = Array.isArray(data)
|
||||
? data.find((t: VersionManagerEntry) => t.tool === "cliproxyapi")
|
||||
: null;
|
||||
setToolState(entry ?? null);
|
||||
setToolStateError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to load version manager status:", err);
|
||||
setToolStateError("Unable to reach version manager service");
|
||||
setToolState(null);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateSetting = async (key: string, value: any) => {
|
||||
const updateSetting = useCallback(async (key: string, value: boolean | string) => {
|
||||
if (key === "cliproxyapi_url" && typeof value === "string" && value.trim() !== "") {
|
||||
if (!isValidUrl(value)) {
|
||||
setMessage({ type: "error", text: "Invalid URL format. Use http:// or https://" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
|
|
@ -37,16 +85,18 @@ export default function CliproxyapiSettingsTab() {
|
|||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ [key]: value }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||
setMessage({ type: "success", text: "Setting saved" });
|
||||
if (!res.ok) {
|
||||
throw new Error(`Server returned ${res.status}`);
|
||||
}
|
||||
await res.json();
|
||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||
setMessage({ type: "success", text: "Setting saved" });
|
||||
} catch {
|
||||
setMessage({ type: "error", text: "Failed to save setting" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const cpaEnabled = settings.cliproxyapi_fallback_enabled === true;
|
||||
const cpaUrl = settings.cliproxyapi_url || "http://127.0.0.1:8317";
|
||||
|
|
@ -142,6 +192,8 @@ export default function CliproxyapiSettingsTab() {
|
|||
</span>
|
||||
Loading...
|
||||
</div>
|
||||
) : toolStateError ? (
|
||||
<p className="text-sm text-text-muted">{toolStateError}</p>
|
||||
) : toolState ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 rounded-lg bg-bg-secondary">
|
||||
|
|
|
|||
22
src/lib/db/migrations/014_create_memories.sql
Normal file
22
src/lib/db/migrations/014_create_memories.sql
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
-- 014_create_memories.sql
|
||||
-- Memories table for persistent context storage.
|
||||
-- Stores structured conversation memories with support for different memory types.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS memories (
|
||||
id TEXT PRIMARY KEY,
|
||||
api_key_id TEXT NOT NULL,
|
||||
session_id TEXT,
|
||||
type TEXT NOT NULL CHECK(type IN ('factual', 'episodic', 'procedural', 'semantic')),
|
||||
key TEXT,
|
||||
content TEXT NOT NULL,
|
||||
metadata TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at TEXT
|
||||
);
|
||||
|
||||
-- Indexes for performance optimization
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_api_key ON memories(api_key_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_type ON memories(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_expires ON memories(expires_at);
|
||||
4
src/lib/db/migrations/014_create_memories_down.sql.bak
Normal file
4
src/lib/db/migrations/014_create_memories_down.sql.bak
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
-- 014_create_memories_down.sql
|
||||
-- DOWN Migration: Remove memories table (Rollback)
|
||||
|
||||
DROP TABLE IF EXISTS memories;
|
||||
37
src/lib/db/migrations/015_create_skills.sql
Normal file
37
src/lib/db/migrations/015_create_skills.sql
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
-- 015_create_skills.sql
|
||||
-- Skills table for tool/function capability injection.
|
||||
-- Stores skill definitions with schemas and execution tracking.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS skills (
|
||||
id TEXT PRIMARY KEY,
|
||||
api_key_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
version TEXT NOT NULL DEFAULT '1.0.0',
|
||||
description TEXT,
|
||||
schema TEXT NOT NULL,
|
||||
handler TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS skill_executions (
|
||||
id TEXT PRIMARY KEY,
|
||||
skill_id TEXT NOT NULL,
|
||||
api_key_id TEXT NOT NULL,
|
||||
session_id TEXT,
|
||||
input TEXT NOT NULL,
|
||||
output TEXT,
|
||||
status TEXT NOT NULL CHECK(status IN ('pending', 'running', 'success', 'error', 'timeout')),
|
||||
error_message TEXT,
|
||||
duration_ms INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_skills_api_key ON skills(api_key_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_skills_name ON skills(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_skill_executions_skill ON skill_executions(skill_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_skill_executions_api_key ON skill_executions(api_key_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_skill_executions_status ON skill_executions(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_skill_executions_created ON skill_executions(created_at);
|
||||
5
src/lib/db/migrations/015_create_skills_down.sql.bak
Normal file
5
src/lib/db/migrations/015_create_skills_down.sql.bak
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
-- 015_create_skills_down.sql
|
||||
-- Rollback skills and skill_executions tables
|
||||
|
||||
DROP TABLE IF EXISTS skill_executions;
|
||||
DROP TABLE IF EXISTS skills;
|
||||
|
|
@ -32,8 +32,9 @@ function toRecord(value: unknown): Record<string, unknown> {
|
|||
const BLOCKED_HOSTNAMES = ["metadata.google.internal", "169.254.169.254", "metadata.aws.internal"];
|
||||
|
||||
function isPrivateHost(hostname: string): boolean {
|
||||
if (BLOCKED_HOSTNAMES.includes(hostname)) return true;
|
||||
// CLIProxyAPI runs on localhost:8317 — allow loopback explicitly
|
||||
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") return false;
|
||||
if (BLOCKED_HOSTNAMES.includes(hostname)) return true;
|
||||
if (
|
||||
/^10\./.test(hostname) ||
|
||||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname) ||
|
||||
|
|
|
|||
|
|
@ -226,10 +226,28 @@ export async function updateVersionManagerTool(
|
|||
const existing = await getVersionManagerTool(tool);
|
||||
if (!existing) return null;
|
||||
|
||||
const ALLOWED_COLUMNS = new Set([
|
||||
"currentVersion",
|
||||
"installedVersion",
|
||||
"pinnedVersion",
|
||||
"binaryPath",
|
||||
"status",
|
||||
"pid",
|
||||
"port",
|
||||
"apiKey",
|
||||
"managementKey",
|
||||
"autoUpdate",
|
||||
"autoStart",
|
||||
"healthStatus",
|
||||
"configOverrides",
|
||||
"errorMessage",
|
||||
]);
|
||||
|
||||
const sets: string[] = ["updated_at = datetime('now')"];
|
||||
const params: Record<string, unknown> = { tool };
|
||||
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (!ALLOWED_COLUMNS.has(key)) continue;
|
||||
const dbKey = key.replace(/([A-Z])/g, "_$1").toLowerCase();
|
||||
|
||||
if (key === "configOverrides") {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue