From d2bee37e7663f3f8282e9ad4007bc139a5c336ca Mon Sep 17 00:00:00 2001 From: nyatoru Date: Mon, 23 Feb 2026 23:59:34 +0700 Subject: [PATCH] feat(api-manager): implement API key management with new endpoints and UI - Add GET/PATCH endpoints for retrieving and updating API key permissions - Move API key management from Endpoint page to dedicated API Manager page - Add allowed_models column to database schema for model-specific access - Implement caching layer for improved API key validation performance --- .../api-manager/ApiManagerPageClient.tsx | 874 ++++++++++++++++++ .../dashboard/api-manager/page.tsx | 5 + .../dashboard/endpoint/EndpointPageClient.tsx | 292 +----- src/app/api/keys/[id]/route.ts | 65 +- src/lib/db/apiKeys.ts | 321 ++++++- src/lib/localDb.ts | 3 + src/shared/components/Sidebar.tsx | 1 + 7 files changed, 1277 insertions(+), 284 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx create mode 100644 src/app/(dashboard)/dashboard/api-manager/page.tsx diff --git a/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx new file mode 100644 index 0000000000..2ee5c2e9b6 --- /dev/null +++ b/src/app/(dashboard)/dashboard/api-manager/ApiManagerPageClient.tsx @@ -0,0 +1,874 @@ +"use client"; + +import { useState, useEffect, useMemo, useCallback, memo } from "react"; +import { Card, Button, Input, Modal, CardSkeleton } from "@/shared/components"; +import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; + +// Constants for validation +const MAX_KEY_NAME_LENGTH = 100; +const MAX_SELECTED_MODELS = 500; + +// Debounce hook for search optimization +function useDebouncedValue(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} + +// Sanitize user input to prevent XSS +function sanitizeInput(input: string): string { + return input + .replace(/[<>]/g, "") + .replace(/"/g, "") + .replace(/'/g, "") + .trim() + .slice(0, MAX_KEY_NAME_LENGTH); +} + +// Validate key name +function validateKeyName(name: string): { valid: boolean; error?: string } { + if (!name || !name.trim()) { + return { valid: false, error: "Key name is required" }; + } + if (name.length > MAX_KEY_NAME_LENGTH) { + return { valid: false, error: `Key name must be ${MAX_KEY_NAME_LENGTH} characters or less` }; + } + // Only allow alphanumeric, spaces, hyphens, underscores + if (!/^[a-zA-Z0-9_\-\s]+$/.test(name)) { + return { + valid: false, + error: "Key name can only contain letters, numbers, spaces, hyphens, and underscores", + }; + } + return { valid: true }; +} + +interface ApiKey { + id: string; + name: string; + key: string; + allowedModels: string[] | null; + createdAt: string; +} + +interface Model { + id: string; + owned_by: string; +} + +export default function ApiManagerPageClient() { + const [keys, setKeys] = useState([]); + const [allModels, setAllModels] = useState([]); + const [loading, setLoading] = useState(true); + const [showAddModal, setShowAddModal] = useState(false); + const [newKeyName, setNewKeyName] = useState(""); + const [createdKey, setCreatedKey] = useState(null); + const [editingKey, setEditingKey] = useState(null); + const [showPermissionsModal, setShowPermissionsModal] = useState(false); + const [searchModel, setSearchModel] = useState(""); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { copied, copy } = useCopyToClipboard(); + + useEffect(() => { + fetchData(); + fetchModels(); + }, []); + + const fetchModels = async () => { + try { + const res = await fetch("/v1/models"); + if (res.ok) { + const data = await res.json(); + setAllModels(data.data || []); + } + } catch (error) { + console.log("Error fetching models:", error); + } + }; + + const fetchData = async () => { + try { + const res = await fetch("/api/keys"); + if (res.ok) { + const data = await res.json(); + setKeys(data.keys || []); + } + } catch (error) { + console.log("Error fetching keys:", error); + } finally { + setLoading(false); + } + }; + + const clearError = useCallback(() => setError(null), []); + + const handleCreateKey = async () => { + // Validate and sanitize input + const sanitizedName = sanitizeInput(newKeyName); + const validation = validateKeyName(sanitizedName); + + if (!validation.valid) { + setError(validation.error || "Invalid key name"); + return; + } + + setIsSubmitting(true); + clearError(); + + try { + const res = await fetch("/api/keys", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: sanitizedName }), + }); + const data = await res.json(); + + if (res.ok) { + setCreatedKey(data.key); + await fetchData(); + setNewKeyName(""); + setShowAddModal(false); + } else { + setError(data.error || "Failed to create key"); + } + } catch (error) { + console.error("Error creating key:", error); + setError("Failed to create key. Please try again."); + } finally { + setIsSubmitting(false); + } + }; + + const handleDeleteKey = async (id: string) => { + // Validate ID format to prevent injection + if (!id || typeof id !== "string" || !/^[a-zA-Z0-9_-]+$/.test(id)) { + setError("Invalid key ID"); + return; + } + + if (!confirm("Delete this API key?")) return; + + setIsSubmitting(true); + clearError(); + + try { + const res = await fetch(`/api/keys/${encodeURIComponent(id)}`, { method: "DELETE" }); + if (res.ok) { + setKeys((prev) => prev.filter((k) => k.id !== id)); + } else { + const data = await res.json(); + setError(data.error || "Failed to delete key"); + } + } catch (error) { + console.error("Error deleting key:", error); + setError("Failed to delete key. Please try again."); + } finally { + setIsSubmitting(false); + } + }; + + const handleOpenPermissions = (key: ApiKey) => { + if (!key || !key.id) return; + setEditingKey(key); + setShowPermissionsModal(true); + }; + + const handleUpdatePermissions = async (allowedModels: string[]) => { + if (!editingKey || !editingKey.id) return; + + // Validate models array + if (!Array.isArray(allowedModels)) { + setError("Invalid models selection"); + return; + } + + // Limit number of selected models to prevent abuse + if (allowedModels.length > MAX_SELECTED_MODELS) { + setError(`Cannot select more than ${MAX_SELECTED_MODELS} models`); + return; + } + + // Validate each model ID + const validModels = allowedModels.filter( + (id) => typeof id === "string" && id.length > 0 && id.length < 200 + ); + + setIsSubmitting(true); + clearError(); + + try { + const res = await fetch(`/api/keys/${encodeURIComponent(editingKey.id)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ allowedModels: validModels }), + }); + + if (res.ok) { + await fetchData(); + setShowPermissionsModal(false); + setEditingKey(null); + } else { + const data = await res.json(); + setError(data.error || "Failed to update permissions"); + } + } catch (error) { + console.error("Error updating permissions:", error); + setError("Failed to update permissions. Please try again."); + } finally { + setIsSubmitting(false); + } + }; + + // Debounced search for performance + const debouncedSearchModel = useDebouncedValue(searchModel, 150); + + // Group models by provider + const modelsByProvider = useMemo((): [string, any[]][] => { + const grouped: Record = {}; + for (const model of allModels) { + const provider = (model.owned_by as string) || "unknown"; + if (!grouped[provider]) grouped[provider] = []; + grouped[provider].push(model); + } + const entries = Object.entries(grouped) as [string, any[]][]; + return entries.sort((a, b) => a[0].localeCompare(b[0])); + }, [allModels]); + + // Filter models based on debounced search + const filteredModelsByProvider = useMemo((): [string, any[]][] => { + if (!debouncedSearchModel.trim()) return modelsByProvider; + + const search = debouncedSearchModel.toLowerCase(); + return modelsByProvider + .map(([provider, models]): [string, any[]] => [ + provider, + models.filter( + (m: any) => m.id.toLowerCase().includes(search) || provider.toLowerCase().includes(search) + ), + ]) + .filter(([, models]) => models.length > 0); + }, [modelsByProvider, debouncedSearchModel]); + + if (loading) { + return ( +
+ + +
+ ); + } + + return ( +
+ {/* Error Banner */} + {error && ( +
+ error +

{error}

+ +
+ )} + + {/* Header Card */} + +
+
+

API Key Management

+

+ Create and manage API keys for authenticating requests to your endpoint +

+
+ +
+
+ + {/* Keys List Card */} + +
+
+
+ vpn_key +
+
+

Registered Keys

+

+ {keys.length} {keys.length === 1 ? "key" : "keys"} registered +

+
+
+
+ +

+ Each key isolates usage tracking and can be revoked independently. Keys are masked after + creation for security. +

+ + {keys.length === 0 ? ( +
+
+ vpn_key +
+

No API keys yet

+

+ Create your first API key to authenticate requests to your endpoint +

+ +
+ ) : ( +
+ {/* Table Header */} +
+
Name
+
Key
+
Permissions
+
Created
+
Actions
+
+ + {/* Table Rows */} + {keys.map((key) => ( +
+
+ label + {key.name} +
+
+ {key.key} +
+
+ {Array.isArray(key.allowedModels) && key.allowedModels.length > 0 ? ( + + ) : ( + + )} +
+
+ {new Date(key.createdAt).toLocaleDateString()} +
+
+ + +
+
+ ))} +
+ )} +
+ + {/* Usage Tips Card */} + +
+
+ lightbulb +
+
+

Usage Tips

+
    +
  • + check + + Use API keys in the{" "} + Authorization{" "} + header as{" "} + Bearer YOUR_KEY + +
  • +
  • + check + Keys are only shown once during creation — store them securely +
  • +
  • + check + Create separate keys for different clients or environments +
  • +
  • + check + Restrict keys to specific models for better security and cost control +
  • +
+
+
+
+ + {/* Add Key Modal */} + { + setShowAddModal(false); + setNewKeyName(""); + }} + > +
+
+ + setNewKeyName(e.target.value)} + placeholder="e.g., Production Key, Development Key" + autoFocus + /> +

+ Choose a descriptive name to identify this key's purpose +

+
+
+ + +
+
+
+ + {/* Created Key Modal */} + setCreatedKey(null)}> +
+
+
+ + check_circle + +
+

+ Key created successfully! +

+

+ Copy and store this key now — it won't be shown again. +

+
+
+
+
+ + +
+ +
+
+ + {/* Permissions Modal */} + {editingKey && ( + { + setShowPermissionsModal(false); + setEditingKey(null); + }} + apiKey={editingKey} + modelsByProvider={filteredModelsByProvider} + allModels={allModels} + searchModel={searchModel} + onSearchChange={setSearchModel} + onSave={handleUpdatePermissions} + /> + )} +
+ ); +} + +// -- Permissions Modal Component (Memoized for Performance) ------------------------------------------ + +const PermissionsModal = memo(function PermissionsModal({ + isOpen, + onClose, + apiKey, + modelsByProvider, + allModels, + searchModel, + onSearchChange, + onSave, +}: { + isOpen: boolean; + onClose: () => void; + apiKey: any; + modelsByProvider: [string, any[]][]; + allModels: any[]; + searchModel: string; + onSearchChange: (v: string) => void; + onSave: (models: string[]) => void; +}) { + // Initialize state from props - component remounts when key prop changes + const initialModels = Array.isArray(apiKey?.allowedModels) ? apiKey.allowedModels : []; + const [selectedModels, setSelectedModels] = useState(initialModels); + const [allowAll, setAllowAll] = useState(initialModels.length === 0); + const [expandedProviders, setExpandedProviders] = useState>(() => { + // Expand all providers by default when in restrict mode with existing selections + if (initialModels.length > 0) { + return new Set(modelsByProvider.map(([p]) => p)); + } + return new Set(); + }); + + // Memoize callbacks to prevent child re-renders + const handleToggleModel = useCallback( + (modelId: string) => { + if (allowAll) return; + + setSelectedModels((prev) => { + if (prev.includes(modelId)) { + return prev.filter((m) => m !== modelId); + } + return [...prev, modelId]; + }); + }, + [allowAll] + ); + + const handleToggleProvider = useCallback( + (provider: string, models: any[]) => { + if (allowAll) return; + + const modelIds = models.map((m) => m.id); + setSelectedModels((prev) => { + const allSelected = modelIds.every((id) => prev.includes(id)); + if (allSelected) { + return prev.filter((m) => !modelIds.includes(m)); + } + return [...new Set([...prev, ...modelIds])]; + }); + }, + [allowAll] + ); + + const handleSelectAll = useCallback(() => { + setAllowAll(true); + setSelectedModels([]); + }, []); + + const handleRestrictMode = useCallback(() => { + setAllowAll(false); + // Expand all providers when entering restrict mode + const allProviders = new Set(modelsByProvider.map(([p]) => p)); + setExpandedProviders(allProviders); + }, [modelsByProvider]); + + const handleToggleExpand = useCallback((provider: string) => { + setExpandedProviders((prev) => { + const next = new Set(prev); + if (next.has(provider)) { + next.delete(provider); + } else { + next.add(provider); + } + return next; + }); + }, []); + + const handleSelectAllModels = useCallback(() => { + const allModelIds = allModels.map((m) => m.id); + setSelectedModels(allModelIds); + }, [allModels]); + + const handleDeselectAllModels = useCallback(() => { + setSelectedModels([]); + }, []); + + const handleSave = useCallback(() => { + onSave(allowAll ? [] : selectedModels); + }, [onSave, allowAll, selectedModels]); + + const handleClearSearch = useCallback(() => { + onSearchChange(""); + }, [onSearchChange]); + + const selectedCount = selectedModels.length; + const totalModels = allModels.length; + + return ( + +
+ {/* Access Mode Toggle */} +
+ + +
+ + {/* Info Banner */} +
+ + {allowAll ? "info" : "warning"} + +

+ {allowAll + ? "This key can access all available models." + : `This key can access ${selectedCount} of ${totalModels} models.`} +

+
+ + {/* Selected Models Summary (only in restrict mode) */} + {!allowAll && selectedCount > 0 && ( +
+
+ {selectedCount} selected +
+ + +
+
+
+ {selectedModels.map((modelId) => ( + + + {modelId} + + + + ))} +
+
+ )} + + {/* Search and Model Selection (only in restrict mode) */} + {!allowAll && ( + <> +
+ onSearchChange(e.target.value)} + placeholder="Search models by name or provider..." + icon="search" + /> + {searchModel && ( + + )} +
+ +
+ {modelsByProvider.length === 0 ? ( +
+ search_off +

No models found

+
+ ) : ( + modelsByProvider.map(([provider, models]) => { + const selectedInProvider = selectedModels.filter((m) => + models.some((model) => model.id === m) + ).length; + const allSelected = models.every((m) => selectedModels.includes(m.id)); + const someSelected = selectedInProvider > 0 && !allSelected; + + return ( +
+ + + {/* Expandable model list */} + {expandedProviders.has(provider) && ( +
+
+ {models.map((model) => { + const isSelected = selectedModels.includes(model.id); + return ( + + ); + })} +
+
+ )} +
+ ); + }) + )} +
+ + )} + + {/* Actions */} +
+ + +
+
+
+ ); +}); diff --git a/src/app/(dashboard)/dashboard/api-manager/page.tsx b/src/app/(dashboard)/dashboard/api-manager/page.tsx new file mode 100644 index 0000000000..e2a2943bf5 --- /dev/null +++ b/src/app/(dashboard)/dashboard/api-manager/page.tsx @@ -0,0 +1,5 @@ +import ApiManagerPageClient from "./ApiManagerPageClient"; + +export default function ApiManagerPage() { + return ; +} diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx index d2ee66b3e5..329c6d0cbe 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx @@ -1,22 +1,18 @@ "use client"; -import { useState, useEffect, useMemo, useCallback } from "react"; +import { useState, useEffect, useMemo } from "react"; import PropTypes from "prop-types"; -import Image from "next/image"; import { Card, Button, Input, Modal, CardSkeleton } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { AI_PROVIDERS, getProviderByAlias } from "@/shared/constants/providers"; +import Link from "next/link"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; const CLOUD_ACTION_TIMEOUT_MS = 15000; export default function APIPageClient({ machineId }) { - const [keys, setKeys] = useState([]); const [providerConnections, setProviderConnections] = useState([]); const [loading, setLoading] = useState(true); - const [showAddModal, setShowAddModal] = useState(false); - const [newKeyName, setNewKeyName] = useState(""); - const [createdKey, setCreatedKey] = useState(null); // Endpoints / models state const [allModels, setAllModels] = useState([]); @@ -133,16 +129,9 @@ export default function APIPageClient({ machineId }) { const fetchData = async () => { try { - const [keysRes, providersRes] = await Promise.all([ - fetch("/api/keys"), - fetch("/api/providers"), - ]); + const providersRes = await fetch("/api/providers"); - const [keysData, providersData] = await Promise.all([keysRes.json(), providersRes.json()]); - - if (keysRes.ok) { - setKeys(keysData.keys || []); - } + const providersData = await providersRes.json(); if (providersRes.ok) { setProviderConnections(providersData.connections || []); @@ -283,41 +272,6 @@ export default function APIPageClient({ machineId }) { } }; - const handleCreateKey = async () => { - if (!newKeyName.trim()) return; - - try { - const res = await fetch("/api/keys", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: newKeyName }), - }); - const data = await res.json(); - - if (res.ok) { - setCreatedKey(data.key); - await fetchData(); - setNewKeyName(""); - setShowAddModal(false); - } - } catch (error) { - console.log("Error creating key:", error); - } - }; - - const handleDeleteKey = async (id) => { - if (!confirm("Delete this API key?")) return; - - try { - const res = await fetch(`/api/keys/${id}`, { method: "DELETE" }); - if (res.ok) { - setKeys(keys.filter((k) => k.id !== id)); - } - } catch (error) { - console.log("Error deleting key:", error); - } - }; - const [baseUrl, setBaseUrl] = useState("/v1"); const cloudEndpointNew = `${CLOUD_URL}/v1`; @@ -442,93 +396,22 @@ export default function APIPageClient({ machineId }) { - {/* Registered Keys — collapsible section inside API Endpoint card */} -
- - - {expandedEndpoint === "keys" && ( -
-
-

- Each key isolates usage tracking and can be revoked independently. -

- -
- - {keys.length === 0 ? ( -
-
- vpn_key -
-

No API keys yet

-

- Create your first API key to get started -

- -
- ) : ( -
- {keys.map((key) => ( -
-
-

{key.name}

-
- {key.key} - -
-

- Created {new Date(key.createdAt).toLocaleDateString()} -

-
- -
- ))} -
- )} -
- )} + {/* Link to API Manager */} +
+
+ vpn_key +
+
+

API Key Management

+

+ Create and manage API keys for authenticating requests +

+
+ + +
@@ -538,8 +421,8 @@ export default function APIPageClient({ machineId }) {

Available Endpoints

- {Object.values(endpointData).reduce((acc, models) => acc + models.length, 0)}{" "} - models across{" "} + {Object.values(endpointData).reduce((acc, models) => acc + models.length, 0)} models + across{" "} { [ endpointData.chat, @@ -837,67 +720,6 @@ export default function APIPageClient({ machineId }) {

- {/* Add Key Modal */} - { - setShowAddModal(false); - setNewKeyName(""); - }} - > -
- setNewKeyName(e.target.value)} - placeholder="Production Key" - /> -
- - -
-
-
- - {/* Created Key Modal */} - setCreatedKey(null)}> -
-
-

- Save this key now! -

-

- This is the only time you will see this key. Store it securely. -

-
-
- - -
- -
-
- {/* Disable Cloud Modal */} 0 ? "text-red-500" : item.connected > 0 ? "text-green-500" : "text-text-muted"; - - return ( -
e.key === "Enter" && onClick?.()} - > -
-
- {imgError ? ( - - {item.provider.textIcon || item.provider.id.slice(0, 2).toUpperCase()} - - ) : ( - {item.provider.name} setImgError(true)} - /> - )} -
- -
-

{item.provider.name}

-

- {item.total === 0 - ? "Not configured" - : `${item.connected} active · ${item.errors} error`} -

-
- - #{item.total} -
-
- ); -} - -ProviderOverviewCard.propTypes = { - item: PropTypes.shape({ - id: PropTypes.string.isRequired, - provider: PropTypes.shape({ - id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - color: PropTypes.string, - textIcon: PropTypes.string, - }).isRequired, - total: PropTypes.number.isRequired, - connected: PropTypes.number.isRequired, - errors: PropTypes.number.isRequired, - }).isRequired, - onClick: PropTypes.func, -}; - // -- Sub-component: Provider Models Modal ------------------------------------------ function ProviderModelsModal({ provider, models, copy, copied, onClose }) { diff --git a/src/app/api/keys/[id]/route.ts b/src/app/api/keys/[id]/route.ts index 6a1b4e3d0f..d20b2083ff 100644 --- a/src/app/api/keys/[id]/route.ts +++ b/src/app/api/keys/[id]/route.ts @@ -1,8 +1,71 @@ import { NextResponse } from "next/server"; -import { deleteApiKey, isCloudEnabled } from "@/lib/localDb"; +import { + deleteApiKey, + getApiKeyById, + updateApiKeyPermissions, + isCloudEnabled, +} from "@/lib/localDb"; import { getConsistentMachineId } from "@/shared/utils/machineId"; import { syncToCloud } from "@/lib/cloudSync"; +// GET /api/keys/[id] - Get single API key +export async function GET(request, { params }) { + try { + const { id } = await params; + const key = await getApiKeyById(id); + + if (!key) { + return NextResponse.json({ error: "Key not found" }, { status: 404 }); + } + + // Mask the key value + return NextResponse.json({ + ...key, + key: key.key ? key.key.slice(0, 8) + "****" + key.key.slice(-4) : null, + }); + } catch (error) { + console.log("Error fetching key:", error); + return NextResponse.json({ error: "Failed to fetch key" }, { status: 500 }); + } +} + +// PATCH /api/keys/[id] - Update API key permissions +export async function PATCH(request, { params }) { + try { + const { id } = await params; + const body = await request.json(); + const { allowedModels } = body; + + // Validate allowedModels is an array + if (!Array.isArray(allowedModels)) { + return NextResponse.json({ error: "allowedModels must be an array" }, { status: 400 }); + } + + // Validate each model ID is a string + for (const model of allowedModels) { + if (typeof model !== "string") { + return NextResponse.json({ error: "Each model ID must be a string" }, { status: 400 }); + } + } + + const updated = await updateApiKeyPermissions(id, allowedModels); + if (!updated) { + return NextResponse.json({ error: "Key not found" }, { status: 404 }); + } + + // Auto sync to Cloud if enabled + await syncKeysToCloudIfEnabled(); + + return NextResponse.json({ + message: "Permissions updated successfully", + allowedModels, + }); + } catch (error) { + console.log("Error updating key permissions:", error); + return NextResponse.json({ error: "Failed to update permissions" }, { status: 500 }); + } +} + // DELETE /api/keys/[id] - Delete API key export async function DELETE(request, { params }) { try { diff --git a/src/lib/db/apiKeys.ts b/src/lib/db/apiKeys.ts index 5dc8271f42..20acee77d2 100644 --- a/src/lib/db/apiKeys.ts +++ b/src/lib/db/apiKeys.ts @@ -6,9 +6,156 @@ import { v4 as uuidv4 } from "uuid"; import { getDbInstance, rowToCamel } from "./core"; import { backupDbFile } from "./backup"; +// ──────────────── Performance Optimizations ──────────────── + +// Schema check memoization - only run once +let _schemaChecked = false; + +// LRU cache for API key validation (valid keys only) +const _keyValidationCache = new Map(); +const _keyMetadataCache = new Map(); +const CACHE_TTL = 60 * 1000; // 1 minute TTL +const MAX_CACHE_SIZE = 1000; + +// Compiled regex cache for wildcard patterns +const _regexCache = new Map(); + +// Cache for model permission checks +const _modelPermissionCache = new Map(); + +// Prepared statements cache +let _stmtGetAllKeys: any = null; +let _stmtGetKeyById: any = null; +let _stmtValidateKey: any = null; +let _stmtGetKeyMetadata: any = null; +let _stmtInsertKey: any = null; +let _stmtUpdatePermissions: any = null; +let _stmtDeleteKey: any = null; + +/** + * Clear all caches (called on key create/update/delete) + */ +function invalidateCaches() { + _keyValidationCache.clear(); + _keyMetadataCache.clear(); + _modelPermissionCache.clear(); +} + +/** + * LRU eviction for cache + */ +function evictIfNeeded(cache: Map) { + if (cache.size > MAX_CACHE_SIZE) { + // Remove oldest 20% of entries + const entriesToRemove = Math.floor(MAX_CACHE_SIZE * 0.2); + let i = 0; + for (const key of cache.keys()) { + if (i++ >= entriesToRemove) break; + cache.delete(key); + } + } +} + +/** + * Get or compile regex for wildcard pattern + */ +function getWildcardRegex(pattern: string): RegExp { + let regex = _regexCache.get(pattern); + if (!regex) { + const regexStr = pattern.replace(/\*/g, ".*"); + regex = new RegExp(`^${regexStr}$`); + _regexCache.set(pattern, regex); + // Prevent unbounded growth + if (_regexCache.size > 100) { + const firstKey = _regexCache.keys().next().value; + if (firstKey) _regexCache.delete(firstKey); + } + } + return regex; +} + +// Ensure the allowed_models column exists (memoized) +function ensureAllowedModelsColumn(db) { + if (_schemaChecked) return; + + try { + const columns = db.prepare("PRAGMA table_info(api_keys)").all(); + const columnNames = new Set(columns.map((column) => column.name)); + if (!columnNames.has("allowed_models")) { + db.exec("ALTER TABLE api_keys ADD COLUMN allowed_models TEXT"); + console.log("[DB] Added api_keys.allowed_models column"); + } + _schemaChecked = true; + } catch (error) { + console.warn("[DB] Failed to verify api_keys schema:", error.message); + } +} + +/** + * Initialize prepared statements (lazy initialization) + */ +function getPreparedStatements(db: any) { + if (!_stmtGetAllKeys) { + _stmtGetAllKeys = db.prepare("SELECT * FROM api_keys ORDER BY created_at"); + _stmtGetKeyById = db.prepare("SELECT * FROM api_keys WHERE id = ?"); + _stmtValidateKey = db.prepare("SELECT 1 FROM api_keys WHERE key = ?"); + _stmtGetKeyMetadata = db.prepare( + "SELECT id, name, machine_id, allowed_models FROM api_keys WHERE key = ?" + ); + _stmtInsertKey = db.prepare( + "INSERT INTO api_keys (id, name, key, machine_id, allowed_models, created_at) VALUES (?, ?, ?, ?, ?, ?)" + ); + _stmtUpdatePermissions = db.prepare("UPDATE api_keys SET allowed_models = ? WHERE id = ?"); + _stmtDeleteKey = db.prepare("DELETE FROM api_keys WHERE id = ?"); + } + return { + getAllKeys: _stmtGetAllKeys, + getKeyById: _stmtGetKeyById, + validateKey: _stmtValidateKey, + getKeyMetadata: _stmtGetKeyMetadata, + insertKey: _stmtInsertKey, + updatePermissions: _stmtUpdatePermissions, + deleteKey: _stmtDeleteKey, + }; +} + export async function getApiKeys() { const db = getDbInstance(); - return db.prepare("SELECT * FROM api_keys ORDER BY created_at").all().map(rowToCamel); + ensureAllowedModelsColumn(db); + const stmt = getPreparedStatements(db); + const rows = stmt.getAllKeys.all() as Record[]; + return rows.map((row) => { + const camelRow = rowToCamel(row) as Record; + // Parse allowed_models from JSON string to array + camelRow.allowedModels = parseAllowedModels(camelRow.allowedModels); + return camelRow; + }); +} + +export async function getApiKeyById(id: string) { + const db = getDbInstance(); + ensureAllowedModelsColumn(db); + const stmt = getPreparedStatements(db); + const row = stmt.getKeyById.get(id) as Record | undefined; + if (!row) return null; + const camelRow = rowToCamel(row) as Record; + camelRow.allowedModels = parseAllowedModels(camelRow.allowedModels); + return camelRow; +} + +/** + * Helper function to safely parse allowed_models JSON + */ +function parseAllowedModels(value: any): string[] { + if (!value || typeof value !== "string" || value.trim() === "") { + return []; + } + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } } export async function createApiKey(name, machineId) { @@ -17,6 +164,7 @@ export async function createApiKey(name, machineId) { } const db = getDbInstance(); + ensureAllowedModelsColumn(db); const now = new Date().toISOString(); const { generateApiKeyWithMachine } = await import("@/shared/utils/apiKey"); @@ -27,35 +175,182 @@ export async function createApiKey(name, machineId) { name: name, key: result.key, machineId: machineId, + allowedModels: [], // Empty array means all models allowed createdAt: now, }; - db.prepare( - "INSERT INTO api_keys (id, name, key, machine_id, created_at) VALUES (?, ?, ?, ?, ?)" - ).run(apiKey.id, apiKey.name, apiKey.key, apiKey.machineId, apiKey.createdAt); + const stmt = getPreparedStatements(db); + stmt.insertKey.run(apiKey.id, apiKey.name, apiKey.key, apiKey.machineId, "[]", apiKey.createdAt); backupDbFile("pre-write"); return apiKey; } -export async function deleteApiKey(id) { +export async function updateApiKeyPermissions(id, allowedModels) { const db = getDbInstance(); - const result = db.prepare("DELETE FROM api_keys WHERE id = ?").run(id); + ensureAllowedModelsColumn(db); + + // allowedModels should be an array of model IDs (strings) + // Empty array means all models are allowed + const modelsJson = JSON.stringify(allowedModels || []); + + const stmt = getPreparedStatements(db); + const result = stmt.updatePermissions.run(modelsJson, id); + if (result.changes === 0) return false; + + // Invalidate caches since permissions changed + invalidateCaches(); + backupDbFile("pre-write"); return true; } -export async function validateApiKey(key) { +export async function deleteApiKey(id) { const db = getDbInstance(); - const row = db.prepare("SELECT 1 FROM api_keys WHERE key = ?").get(key); - return !!row; + const stmt = getPreparedStatements(db); + const result = stmt.deleteKey.run(id); + + if (result.changes === 0) return false; + + // Invalidate caches since a key was removed + invalidateCaches(); + + backupDbFile("pre-write"); + return true; } -export async function getApiKeyMetadata(key) { - if (!key) return null; +/** + * Validate API key with caching for performance + * Cached valid keys reduce DB hits on every request + */ +export async function validateApiKey(key) { + if (!key || typeof key !== "string") return false; + + const now = Date.now(); + + // Check cache first + const cached = _keyValidationCache.get(key); + if (cached && now - cached.timestamp < CACHE_TTL) { + return cached.valid; + } + const db = getDbInstance(); - const row = db.prepare("SELECT id, name, machine_id FROM api_keys WHERE key = ?").get(key); + const stmt = getPreparedStatements(db); + const row = stmt.validateKey.get(key); + const valid = !!row; + + // Only cache valid keys to prevent cache pollution + if (valid) { + evictIfNeeded(_keyValidationCache); + _keyValidationCache.set(key, { valid: true, timestamp: now }); + } + + return valid; +} + +/** + * Get API key metadata with caching for performance + */ +export async function getApiKeyMetadata(key) { + if (!key || typeof key !== "string") return null; + + const now = Date.now(); + + // Check cache first + const cached = _keyMetadataCache.get(key); + if (cached && now - cached.timestamp < CACHE_TTL) { + return cached.metadata; + } + + const db = getDbInstance(); + ensureAllowedModelsColumn(db); + const stmt = getPreparedStatements(db); + const row = stmt.getKeyMetadata.get(key) as Record | undefined; + if (!row) return null; - return { id: row.id, name: row.name, machineId: row.machine_id }; + + const metadata = { + id: row.id, + name: row.name, + machineId: row.machine_id, + allowedModels: parseAllowedModels(row.allowed_models), + }; + + // Cache the result + evictIfNeeded(_keyMetadataCache); + _keyMetadataCache.set(key, { metadata, timestamp: now }); + + return metadata; +} + +/** + * Check if a model is allowed for a given API key + * @param {string} key - The API key + * @param {string} modelId - The model ID to check + * @returns {boolean} - true if allowed, false if not + */ +export async function isModelAllowedForKey(key, modelId) { + if (!key || !modelId) return true; // No key or model = allow (backward compatibility) + + // Create cache key + const cacheKey = `${key}:${modelId}`; + const now = Date.now(); + + // Check permission cache + const cached = _modelPermissionCache.get(cacheKey); + if (cached && now - cached.timestamp < CACHE_TTL) { + return cached.allowed; + } + + const metadata = await getApiKeyMetadata(key); + if (!metadata) return true; // Key not found = allow (backward compatibility) + + const { allowedModels } = metadata; + + // Empty array means all models allowed + if (!allowedModels || allowedModels.length === 0) { + return true; + } + + let allowed = false; + + // Check if model matches any allowed pattern + // Support exact match and prefix match (e.g., "openai/*" allows all OpenAI models) + for (const pattern of allowedModels) { + if (pattern === modelId) { + allowed = true; + break; + } + if (pattern.endsWith("/*")) { + const prefix = pattern.slice(0, -2); // Remove "/*" + if (modelId.startsWith(prefix + "/") || modelId.startsWith(prefix)) { + allowed = true; + break; + } + } + // Support wildcard patterns using cached regex + if (pattern.includes("*")) { + const regex = getWildcardRegex(pattern); + if (regex.test(modelId)) { + allowed = true; + break; + } + } + } + + // Cache the result + evictIfNeeded(_modelPermissionCache); + _modelPermissionCache.set(cacheKey, { allowed, timestamp: now }); + + return allowed; +} + +/** + * Clear all caches (exported for testing/debugging) + */ +export function clearApiKeyCaches() { + invalidateCaches(); + _modelPermissionCache.clear(); + _regexCache.clear(); } diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 3dcd48ae15..4d1bcaf412 100644 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -55,10 +55,13 @@ export { export { // API Keys getApiKeys, + getApiKeyById, createApiKey, deleteApiKey, validateApiKey, getApiKeyMetadata, + updateApiKeyPermissions, + isModelAllowedForKey, } from "./db/apiKeys"; export { diff --git a/src/shared/components/Sidebar.tsx b/src/shared/components/Sidebar.tsx index 5bbbe1eb46..d61268b10c 100644 --- a/src/shared/components/Sidebar.tsx +++ b/src/shared/components/Sidebar.tsx @@ -14,6 +14,7 @@ import CloudSyncStatus from "./CloudSyncStatus"; const navItems = [ { href: "/dashboard", label: "Home", icon: "home", exact: true }, { href: "/dashboard/endpoint", label: "Endpoint", icon: "api" }, + { href: "/dashboard/api-manager", label: "API Manager", icon: "vpn_key" }, { href: "/dashboard/providers", label: "Providers", icon: "dns" }, { href: "/dashboard/combos", label: "Combos", icon: "layers" }, { href: "/dashboard/logs", label: "Logs", icon: "description" },