Просмотр исходного кода

feat(api): admin GET/PUT /api/v1/settings (masked secret) + client methods

Add registerSettingsRoutes with GET (returns settings with openai_api_key
replaced by openai_api_key_set bool) and PUT (field-by-field merge, omit
key to preserve existing secret). Wire into registerRoutes and CMakeLists.
Add two integration tests (mask check + field change + 403 for non-admin).
Update openapi.json, llms.txt, and webui client/types.
Fszontagh 2 месяцев назад
Родитель
Сommit
4ef3333fd1
9 измененных файлов с 174 добавлено и 1 удалено
  1. 7 0
      api/llms.txt
  2. 57 0
      api/openapi.json
  3. 1 0
      src/CMakeLists.txt
  4. 44 0
      src/handlers/settings.cpp
  5. 1 0
      src/server.cpp
  6. 1 0
      src/server.hpp
  7. 48 0
      tests/test_api_integration.cpp
  8. 6 1
      webui/src/api/client.ts
  9. 9 0
      webui/src/types/index.ts

+ 7 - 0
api/llms.txt

@@ -58,6 +58,13 @@ Requires a `kind=vector` collection. Vectors are stored with cosine-similarity i
 - `POST /api/v1/projects/{project}/collections/{name}/vectors` `{id?, text?, vector?, metadata?}` — Store a vector. Supply either `text` (the server embeds it using the collection's `embedding_model`) or a pre-computed `vector` array. `vector` dimension must match the collection's `vector_dimension`. `metadata` is an arbitrary JSON object stored alongside the vector.
 - `POST /api/v1/projects/{project}/collections/{name}/vectors` `{id?, text?, vector?, metadata?}` — Store a vector. Supply either `text` (the server embeds it using the collection's `embedding_model`) or a pre-computed `vector` array. `vector` dimension must match the collection's `vector_dimension`. `metadata` is an arbitrary JSON object stored alongside the vector.
 - `POST /api/v1/projects/{project}/collections/{name}/search` `{query_text?, query_vector?, top_k, min_score?, filters?}` — Cosine-similarity search. Supply either `query_text` or `query_vector`. `top_k` (required) limits results. `min_score` (0–1) filters low-confidence matches. `filters` apply the same `field:op:value` grammar to vector metadata.
 - `POST /api/v1/projects/{project}/collections/{name}/search` `{query_text?, query_vector?, top_k, min_score?, filters?}` — Cosine-similarity search. Supply either `query_text` or `query_vector`. `top_k` (required) limits results. `min_score` (0–1) filters low-confidence matches. `filters` apply the same `field:op:value` grammar to vector metadata.
 
 
+## Settings (admin)
+
+All settings endpoints require an admin key.
+
+- `GET  /api/v1/settings` — Return current settings as JSON. `openai_api_key` is masked: the field is absent and `openai_api_key_set` (bool) indicates whether a key is configured.
+- `PUT  /api/v1/settings` `{openai_api_base?, openai_api_key?, default_embedding_model?, cors_origins?, session_ttl_minutes?, webui_enabled?, default_project?}` — Merge the supplied fields into the current settings and persist. Omit `openai_api_key` (or pass an empty string) to keep the existing secret. Changes are hot-reloaded immediately.
+
 ## Stats
 ## Stats
 
 
 - `GET /api/v1/projects/{project}/stats` — Per-project stats (collections, document counts). Accessible to any key granted the project.
 - `GET /api/v1/projects/{project}/stats` — Per-project stats (collections, document counts). Accessible to any key granted the project.

+ 57 - 0
api/openapi.json

@@ -780,6 +780,63 @@
         }
         }
       }
       }
     },
     },
+    "/api/v1/settings": {
+      "get": {
+        "summary": "Get current settings (admin only); openai_api_key is masked to a boolean openai_api_key_set",
+        "operationId": "getSettings",
+        "responses": {
+          "200": {
+            "description": "Settings (with secret masked)",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "openai_api_base": { "type": "string" },
+                    "openai_api_key_set": { "type": "boolean" },
+                    "default_embedding_model": { "type": "string" },
+                    "cors_origins": { "type": "array", "items": { "type": "string" } },
+                    "session_ttl_minutes": { "type": "integer" },
+                    "webui_enabled": { "type": "boolean" },
+                    "default_project": { "type": "string" }
+                  }
+                }
+              }
+            }
+          },
+          "401": { "description": "Unauthorized" },
+          "403": { "description": "Forbidden — admin required" }
+        }
+      },
+      "put": {
+        "summary": "Update settings (admin only); omit openai_api_key to keep the existing secret",
+        "operationId": "updateSettings",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object",
+                "properties": {
+                  "openai_api_base": { "type": "string" },
+                  "openai_api_key": { "type": "string", "description": "Omit or pass empty string to preserve the existing key" },
+                  "default_embedding_model": { "type": "string" },
+                  "cors_origins": { "type": "array", "items": { "type": "string" } },
+                  "session_ttl_minutes": { "type": "integer", "minimum": 1 },
+                  "webui_enabled": { "type": "boolean" },
+                  "default_project": { "type": "string" }
+                }
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": { "description": "Settings updated", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" } } } } } },
+          "401": { "description": "Unauthorized" },
+          "403": { "description": "Forbidden — admin required" }
+        }
+      }
+    },
     "/api/v1/stats": {
     "/api/v1/stats": {
       "get": {
       "get": {
         "summary": "Server-wide stats (admin only)",
         "summary": "Server-wide stats (admin only)",

+ 1 - 0
src/CMakeLists.txt

@@ -20,6 +20,7 @@ add_library(vectorapi_core STATIC
     handlers/documents.cpp
     handlers/documents.cpp
     handlers/vectors.cpp
     handlers/vectors.cpp
     handlers/stats.cpp
     handlers/stats.cpp
+    handlers/settings.cpp
 )
 )
 target_include_directories(vectorapi_core PUBLIC
 target_include_directories(vectorapi_core PUBLIC
     ${CMAKE_CURRENT_SOURCE_DIR}
     ${CMAKE_CURRENT_SOURCE_DIR}

+ 44 - 0
src/handlers/settings.cpp

@@ -0,0 +1,44 @@
+#include "errors.hpp"
+#include "json_http.hpp"
+#include "server.hpp"
+namespace svapi {
+void registerSettingsRoutes(ApiServer& s) {
+    auto& svr = s.raw(); ServerDeps* d = &s.deps();
+
+    svr.Get("/api/v1/settings", [d](const httplib::Request& req, httplib::Response& res) {
+        requireAdmin(requireKey(*d, req));
+        auto snap = d->settings.snapshot();
+        nlohmann::json j = snap->toJson();
+        bool keySet = !snap->openaiApiKey.empty();
+        j.erase("openai_api_key");
+        j["openai_api_key_set"] = keySet;
+        sendJson(res, 200, j);
+    });
+
+    svr.Put("/api/v1/settings", [d](const httplib::Request& req, httplib::Response& res) {
+        requireAdmin(requireKey(*d, req));
+        auto body = bodyJson(req);
+        // Start from the current snapshot (preserve unspecified fields).
+        Settings updated = *d->settings.snapshot();
+        if (body.contains("openai_api_base"))
+            updated.openaiApiBase = body["openai_api_base"].get<std::string>();
+        // Only overwrite the key if a non-empty string is supplied.
+        if (body.contains("openai_api_key")) {
+            std::string k = body["openai_api_key"].get<std::string>();
+            if (!k.empty()) updated.openaiApiKey = k;
+        }
+        if (body.contains("default_embedding_model"))
+            updated.defaultEmbeddingModel = body["default_embedding_model"].get<std::string>();
+        if (body.contains("cors_origins") && body["cors_origins"].is_array())
+            updated.corsOrigins = body["cors_origins"].get<std::vector<std::string>>();
+        if (body.contains("session_ttl_minutes"))
+            updated.sessionTtlMinutes = body["session_ttl_minutes"].get<uint32_t>();
+        if (body.contains("webui_enabled"))
+            updated.webuiEnabled = body["webui_enabled"].get<bool>();
+        if (body.contains("default_project"))
+            updated.defaultProject = body["default_project"].get<std::string>();
+        d->settings.save(updated);
+        sendJson(res, 200, {{"ok", true}});
+    });
+}
+} // namespace svapi

+ 1 - 0
src/server.cpp

@@ -72,6 +72,7 @@ void ApiServer::registerRoutes() {
     registerDocumentRoutes(*this);
     registerDocumentRoutes(*this);
     registerVectorRoutes(*this);
     registerVectorRoutes(*this);
     registerStatsRoutes(*this);
     registerStatsRoutes(*this);
+    registerSettingsRoutes(*this);
 
 
     if (!d_.webuiDir.empty()) svr_.set_mount_point("/", d_.webuiDir);
     if (!d_.webuiDir.empty()) svr_.set_mount_point("/", d_.webuiDir);
 }
 }

+ 1 - 0
src/server.hpp

@@ -51,5 +51,6 @@ void registerCollectionRoutes(ApiServer&);
 void registerDocumentRoutes(ApiServer&);
 void registerDocumentRoutes(ApiServer&);
 void registerVectorRoutes(ApiServer&);
 void registerVectorRoutes(ApiServer&);
 void registerStatsRoutes(ApiServer&);
 void registerStatsRoutes(ApiServer&);
+void registerSettingsRoutes(ApiServer&);
 
 
 } // namespace svapi
 } // namespace svapi

+ 48 - 0
tests/test_api_integration.cpp

@@ -257,3 +257,51 @@ TEST_F(ApiFixture, CannotDeAdminLastAdminKey) {
     ASSERT_TRUE(check);
     ASSERT_TRUE(check);
     EXPECT_EQ(check->status, 200);
     EXPECT_EQ(check->status, 200);
 }
 }
+
+// W9 Step 1a: GET /api/v1/settings masks the secret
+TEST_F(ApiFixture, SettingsGetMasksSecretForAdmin) {
+    auto r = admin().Get("/api/v1/settings");
+    ASSERT_TRUE(r);
+    ASSERT_EQ(r->status, 200);
+    auto body = nlohmann::json::parse(r->body);
+    // Must have the masking bool but NOT the raw secret
+    EXPECT_TRUE(body.contains("openai_api_key_set"));
+    EXPECT_FALSE(body.contains("openai_api_key"));
+    // The fixture sets a non-empty key so openai_api_key_set must be true
+    EXPECT_TRUE(body["openai_api_key_set"].get<bool>());
+    // Other fields must be present
+    EXPECT_TRUE(body.contains("openai_api_base"));
+    EXPECT_TRUE(body.contains("default_embedding_model"));
+}
+
+// W9 Step 1a: PUT /api/v1/settings changes a field; non-admin gets 403
+TEST_F(ApiFixture, SettingsPutChangesField) {
+    // Change default_embedding_model
+    auto put = admin().Put("/api/v1/settings",
+        nlohmann::json{{"default_embedding_model", "text-embedding-3-large"}}.dump(), "application/json");
+    ASSERT_TRUE(put);
+    ASSERT_EQ(put->status, 200);
+    auto putBody = nlohmann::json::parse(put->body);
+    EXPECT_TRUE(putBody["ok"].get<bool>());
+
+    // GET and verify the change
+    auto get = admin().Get("/api/v1/settings");
+    ASSERT_TRUE(get);
+    ASSERT_EQ(get->status, 200);
+    auto getBody = nlohmann::json::parse(get->body);
+    EXPECT_EQ(getBody["default_embedding_model"].get<std::string>(), "text-embedding-3-large");
+
+    // A scoped (non-admin) key must get 403 on GET
+    auto mk = admin().Post("/api/v1/keys",
+        nlohmann::json{{"label","scoped"},{"projects",{project_}},{"admin",false}}.dump(), "application/json");
+    ASSERT_TRUE(mk); ASSERT_EQ(mk->status, 201);
+    std::string scopedKey = nlohmann::json::parse(mk->body)["key"];
+    auto denied = http(scopedKey).Get("/api/v1/settings");
+    ASSERT_TRUE(denied);
+    EXPECT_EQ(denied->status, 403);
+    admin().Delete(("/api/v1/keys/" + scopedKey).c_str());
+
+    // Restore original model so other tests are unaffected
+    admin().Put("/api/v1/settings",
+        nlohmann::json{{"default_embedding_model", "text-embedding-3-small"}}.dump(), "application/json");
+}

+ 6 - 1
webui/src/api/client.ts

@@ -1,6 +1,6 @@
 import type {
 import type {
   LoginResponse, CollectionMeta, ApiKeyPublic, ApiKeyCreated,
   LoginResponse, CollectionMeta, ApiKeyPublic, ApiKeyCreated,
-  SearchResult, ProjectStats, GlobalStats, FindResult,
+  SearchResult, ProjectStats, GlobalStats, FindResult, SettingsView,
 } from '@/types'
 } from '@/types'
 import { ApiError } from '@/types'
 import { ApiError } from '@/types'
 
 
@@ -71,4 +71,9 @@ export const api = {
   // stats
   // stats
   projectStats: (project: string) => request<ProjectStats>(`${P(project)}/stats`),
   projectStats: (project: string) => request<ProjectStats>(`${P(project)}/stats`),
   globalStats: () => request<GlobalStats>('/api/v1/stats'),
   globalStats: () => request<GlobalStats>('/api/v1/stats'),
+
+  // settings (admin)
+  getSettings: () => request<SettingsView>('/api/v1/settings'),
+  updateSettings: (patch: Partial<Omit<SettingsView, 'openai_api_key_set'> & { openai_api_key?: string }>) =>
+    request<{ ok: boolean }>('/api/v1/settings', { method: 'PUT', body: JSON.stringify(patch) }),
 }
 }

+ 9 - 0
webui/src/types/index.ts

@@ -16,6 +16,15 @@ export interface GlobalStats {
   memory_pressure_level: string; memory_pressure_percent: number; projects: number;
   memory_pressure_level: string; memory_pressure_percent: number; projects: number;
 }
 }
 export interface FindResult { documents: Record<string, unknown>[]; count: number }
 export interface FindResult { documents: Record<string, unknown>[]; count: number }
+export interface SettingsView {
+  openai_api_base: string
+  openai_api_key_set: boolean
+  default_embedding_model: string
+  cors_origins: string[]
+  session_ttl_minutes: number
+  webui_enabled: boolean
+  default_project: string
+}
 export class ApiError extends Error {
 export class ApiError extends Error {
   status: number
   status: number
   constructor(status: number, message: string) {
   constructor(status: number, message: string) {