|
@@ -0,0 +1,1976 @@
|
|
|
|
|
+# smartbotic-vectorapi Backend — Projects + Multi-Key Rework (Tasks 5+)
|
|
|
|
|
+
|
|
|
|
|
+> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. This file supersedes Tasks 5–17 of `2026-05-31-smartbotic-vectorapi-backend.md`. Tasks 1–4 (skeleton, config, errors+json_http, filters) are already implemented and unchanged. Implement the tasks here in order. Steps use `- [ ]`.
|
|
|
|
|
+
|
|
|
|
|
+**Goal:** Rework the backend for DB v2.3 **multi-project namespaces** (project as a URL path segment) and **multi-key authorization** (API keys with MySQL-style per-project grants + admin), with project & key management via the REST API (and later the web UI).
|
|
|
|
|
+
|
|
|
|
|
+**Why this exists:** After Tasks 1–4 the DB shipped v2.3.0 (project namespaces). See spec **Addendum A** in `docs/superpowers/specs/2026-05-31-smartbotic-vectorapi-design.md`. The client (`libsmartbotic-db-client` 2.3.0) addresses projects via `Config::project` or per-call `<project>:<collection>` qualified names (qualified wins), and exposes `listProjects()`/`createProject(name)`/`dropProject(name)`.
|
|
|
|
|
+
|
|
|
|
|
+**Architecture:** Multi-tenancy is threaded at the gateway boundary: handlers work in terms of `(project, collection)` and a single `qualify()` prepends the namespace before the gRPC call. Authorization (key→projects grants) is entirely our layer. Global service collections (`_vectorapi_keys`, `_vectorapi_settings`) are stored unqualified (DB `default` project); each project gets its own registry `P:_vectorapi_collections`.
|
|
|
|
|
+
|
|
|
|
|
+**Tech stack / conventions:** unchanged from the base plan — C++20, `vectorapi_core` static lib, GoogleTest, flat `src/` headers with quoted includes, namespace `svapi`, integration tests skip cleanly when the DB is down (`SVAPI_REQUIRE_DB`). The DB **is currently running** (v2.3.0 on `localhost:9004`), so integration tests should actually run. Build: `cmake -B build -G Ninja -DBUILD_WEBUI=OFF -S . && cmake --build build -j"$(nproc)"`.
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Canonical interfaces (implement exactly; later tasks depend on these names)
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+// db_gateway.hpp
|
|
|
|
|
+namespace svapi {
|
|
|
|
|
+std::string qualify(const std::string& project, const std::string& collection); // ""/"default" -> collection; else project+":"+collection
|
|
|
|
|
+class DbGateway {
|
|
|
|
|
+public:
|
|
|
|
|
+ explicit DbGateway(std::string address, uint32_t timeoutMs = 5000);
|
|
|
|
|
+ bool connect();
|
|
|
|
|
+ bool healthy();
|
|
|
|
|
+ smartbotic::database::Client& client();
|
|
|
|
|
+ std::vector<std::string> listProjects();
|
|
|
|
|
+ bool ensureProject(const std::string& name); // idempotent createProject; true on success
|
|
|
|
|
+ bool dropProject(const std::string& name); // false if name=="default"
|
|
|
|
|
+};
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// settings.hpp (NO api_key field anymore)
|
|
|
|
|
+struct Settings {
|
|
|
|
|
+ std::string openaiApiBase = "https://api.openai.com";
|
|
|
|
|
+ std::string openaiApiKey;
|
|
|
|
|
+ std::string defaultEmbeddingModel = "text-embedding-3-small";
|
|
|
|
|
+ std::vector<std::string> corsOrigins = {"*"};
|
|
|
|
|
+ uint32_t sessionTtlMinutes = 720;
|
|
|
|
|
+ bool webuiEnabled = true;
|
|
|
|
|
+ std::string defaultProject = "default";
|
|
|
|
|
+ static Settings fromJson(const nlohmann::json&); nlohmann::json toJson() const;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// apikey.hpp
|
|
|
|
|
+struct ApiKey {
|
|
|
|
|
+ std::string key, label;
|
|
|
|
|
+ std::vector<std::string> projects; // may contain "*"
|
|
|
|
|
+ bool admin = false;
|
|
|
|
|
+ uint64_t createdAt = 0;
|
|
|
|
|
+ static ApiKey fromJson(const nlohmann::json&);
|
|
|
|
|
+ nlohmann::json toJson() const; // full (includes key) — for DB storage
|
|
|
|
|
+ nlohmann::json toPublicJson() const; // masked: label, projects, admin, created_at, key_prefix
|
|
|
|
|
+ bool canAccess(const std::string& project) const; // admin || projects⊇{"*"} || projects⊇{project}
|
|
|
|
|
+};
|
|
|
|
|
+std::string generateApiKey(); // 48 lowercase hex (OpenSSL RAND_bytes)
|
|
|
|
|
+
|
|
|
|
|
+// key_store.hpp
|
|
|
|
|
+class KeyStore {
|
|
|
|
|
+public:
|
|
|
|
|
+ KeyStore(DbGateway& db, std::string collection = "_vectorapi_keys");
|
|
|
|
|
+ void bootstrap(const std::string& envAdminKey); // ensure (encrypted) coll; if no keys, create one admin key (log once); env seeds value
|
|
|
|
|
+ std::optional<ApiKey> lookup(const std::string& key) const; // from snapshot
|
|
|
|
|
+ std::vector<ApiKey> list() const; // from snapshot
|
|
|
|
|
+ ApiKey create(const std::string& label, std::vector<std::string> projects, bool admin);
|
|
|
|
|
+ bool update(const std::string& key, const std::optional<std::string>& label,
|
|
|
|
|
+ const std::optional<std::vector<std::string>>& projects, const std::optional<bool>& admin);
|
|
|
|
|
+ bool remove(const std::string& key);
|
|
|
|
|
+ void startWatch();
|
|
|
|
|
+private:
|
|
|
|
|
+ /* DbGateway& db_, coll_, mutex, shared_ptr<const vector<ApiKey>> snapshot, sub_ */
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// settings_store.hpp (global settings; same shape as base plan Task 7 but Settings has no api_key)
|
|
|
|
|
+class SettingsStore {
|
|
|
|
|
+public:
|
|
|
|
|
+ SettingsStore(DbGateway& db, std::string collection = "_vectorapi_settings", std::string docId = "current");
|
|
|
|
|
+ void bootstrap(const std::string& envOpenAiKey); // ensure (encrypted) coll; seed openai key from env if empty; publish snapshot
|
|
|
|
|
+ std::shared_ptr<const Settings> snapshot() const;
|
|
|
|
|
+ void reloadFromDb(); bool save(const Settings&); void startWatch();
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// collection_registry.hpp (now project-scoped)
|
|
|
|
|
+struct CollectionMeta { std::string name, kind; uint32_t vectorDimension=0; std::string embeddingModel; uint64_t createdAt=0;
|
|
|
|
|
+ static CollectionMeta fromJson(const nlohmann::json&); nlohmann::json toJson() const; };
|
|
|
|
|
+class CollectionRegistry {
|
|
|
|
|
+public:
|
|
|
|
|
+ explicit CollectionRegistry(DbGateway& db);
|
|
|
|
|
+ void ensure(const std::string& project); // ensure qualify(project,"_vectorapi_collections")
|
|
|
|
|
+ bool add(const std::string& project, const CollectionMeta&);
|
|
|
|
|
+ std::optional<CollectionMeta> get(const std::string& project, const std::string& name);
|
|
|
|
|
+ std::vector<CollectionMeta> list(const std::string& project);
|
|
|
|
|
+ bool remove(const std::string& project, const std::string& name);
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// auth.hpp
|
|
|
|
|
+std::optional<std::string> bearerToken(const std::string& authHeader);
|
|
|
|
|
+std::optional<std::string> cookieValue(const std::string& cookieHeader, const std::string& name);
|
|
|
|
|
+std::string randomToken();
|
|
|
|
|
+class SessionStore { // token -> (apiKey value, expiry)
|
|
|
|
|
+public:
|
|
|
|
|
+ explicit SessionStore(std::chrono::minutes ttl);
|
|
|
|
|
+ std::string create(const std::string& apiKeyValue, std::chrono::system_clock::time_point now);
|
|
|
|
|
+ std::optional<std::string> keyFor(const std::string& token, std::chrono::system_clock::time_point now);
|
|
|
|
|
+ void remove(const std::string& token); void setTtl(std::chrono::minutes);
|
|
|
|
|
+};
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 5: DbGateway + project helpers
|
|
|
|
|
+
|
|
|
|
|
+**Files:** Create `src/db_gateway.hpp`, `src/db_gateway.cpp`; add `db_gateway.cpp` to `vectorapi_core`. Create `tests/db_test_util.hpp` and `tests/test_db_gateway.cpp`.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: `tests/db_test_util.hpp`** (connect-or-skip helper used by all DB tests)
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+#pragma once
|
|
|
|
|
+#include "db_gateway.hpp"
|
|
|
|
|
+#include <gtest/gtest.h>
|
|
|
|
|
+#include <cstdlib>
|
|
|
|
|
+#include <memory>
|
|
|
|
|
+#include <string>
|
|
|
|
|
+
|
|
|
|
|
+namespace svapi::testutil {
|
|
|
|
|
+inline std::string testDbAddress() {
|
|
|
|
|
+ const char* env = std::getenv("SVAPI_TEST_DB");
|
|
|
|
|
+ return env ? env : "localhost:9004";
|
|
|
|
|
+}
|
|
|
|
|
+inline std::unique_ptr<DbGateway> connectOrNull() {
|
|
|
|
|
+ auto g = std::make_unique<DbGateway>(testDbAddress());
|
|
|
|
|
+ if (!g->connect() || !g->healthy()) return nullptr;
|
|
|
|
|
+ return g;
|
|
|
|
|
+}
|
|
|
|
|
+inline std::string tmpName(const std::string& base) { return "svapitest_" + base; } // project/coll test name (no leading _, valid project chars)
|
|
|
|
|
+} // namespace svapi::testutil
|
|
|
|
|
+
|
|
|
|
|
+#define SVAPI_REQUIRE_DB(var) \
|
|
|
|
|
+ auto var = ::svapi::testutil::connectOrNull(); \
|
|
|
|
|
+ if (!var) GTEST_SKIP() << "smartbotic-database not reachable at " \
|
|
|
|
|
+ << ::svapi::testutil::testDbAddress()
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Failing test `tests/test_db_gateway.cpp`**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+#include "db_test_util.hpp"
|
|
|
|
|
+using namespace svapi;
|
|
|
|
|
+
|
|
|
|
|
+TEST(Qualify, Rules) {
|
|
|
|
|
+ EXPECT_EQ(qualify("", "docs"), "docs");
|
|
|
|
|
+ EXPECT_EQ(qualify("default", "docs"), "docs");
|
|
|
|
|
+ EXPECT_EQ(qualify("acme", "docs"), "acme:docs");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+TEST(DbGateway, HealthyAndProjects) {
|
|
|
|
|
+ SVAPI_REQUIRE_DB(db);
|
|
|
|
|
+ EXPECT_TRUE(db->healthy());
|
|
|
|
|
+ auto before = db->listProjects();
|
|
|
|
|
+ EXPECT_NE(std::find(before.begin(), before.end(), "default"), before.end());
|
|
|
|
|
+
|
|
|
|
|
+ const std::string proj = testutil::tmpName("proj_gw");
|
|
|
|
|
+ EXPECT_TRUE(db->ensureProject(proj));
|
|
|
|
|
+ auto after = db->listProjects();
|
|
|
|
|
+ EXPECT_NE(std::find(after.begin(), after.end(), proj), after.end());
|
|
|
|
|
+
|
|
|
|
|
+ // a doc lands in the project namespace
|
|
|
|
|
+ auto id = db->client().insert(qualify(proj, "things"), nlohmann::json{{"a", 1}});
|
|
|
|
|
+ ASSERT_FALSE(id.empty());
|
|
|
|
|
+ EXPECT_TRUE(db->client().get(qualify(proj, "things"), id).has_value());
|
|
|
|
|
+
|
|
|
|
|
+ db->dropProject(proj);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+TEST(DbGateway, CannotDropDefault) {
|
|
|
|
|
+ SVAPI_REQUIRE_DB(db);
|
|
|
|
|
+ EXPECT_FALSE(db->dropProject("default"));
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Add target to `tests/CMakeLists.txt`:
|
|
|
|
|
+```cmake
|
|
|
|
|
+add_executable(test_db_gateway test_db_gateway.cpp)
|
|
|
|
|
+target_link_libraries(test_db_gateway PRIVATE vectorapi_core GTest::gtest_main)
|
|
|
|
|
+gtest_discover_tests(test_db_gateway)
|
|
|
|
|
+```
|
|
|
|
|
+(`#include <algorithm>` in the test for `std::find`.)
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: `src/db_gateway.hpp`** — exactly the interface in "Canonical interfaces" above, with `#include <smartbotic/database/client.hpp>` and doc comments.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: `src/db_gateway.cpp`**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+#include "db_gateway.hpp"
|
|
|
|
|
+
|
|
|
|
|
+namespace svapi {
|
|
|
|
|
+
|
|
|
|
|
+std::string qualify(const std::string& project, const std::string& collection) {
|
|
|
|
|
+ if (project.empty() || project == "default") return collection;
|
|
|
|
|
+ return project + ":" + collection;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+namespace {
|
|
|
|
|
+smartbotic::database::Client::Config makeConfig(std::string address, uint32_t timeoutMs) {
|
|
|
|
|
+ smartbotic::database::Client::Config cfg;
|
|
|
|
|
+ cfg.address = std::move(address);
|
|
|
|
|
+ cfg.timeoutMs = timeoutMs;
|
|
|
|
|
+ return cfg; // leave cfg.project = "default"; we qualify per-call
|
|
|
|
|
+}
|
|
|
|
|
+} // namespace
|
|
|
|
|
+
|
|
|
|
|
+DbGateway::DbGateway(std::string address, uint32_t timeoutMs)
|
|
|
|
|
+ : client_(makeConfig(std::move(address), timeoutMs)) {}
|
|
|
|
|
+
|
|
|
|
|
+bool DbGateway::connect() { return client_.connect(); }
|
|
|
|
|
+bool DbGateway::healthy() { return client_.healthCheck(); }
|
|
|
|
|
+smartbotic::database::Client& DbGateway::client() { return client_; }
|
|
|
|
|
+
|
|
|
|
|
+std::vector<std::string> DbGateway::listProjects() { return client_.listProjects(); }
|
|
|
|
|
+
|
|
|
|
|
+bool DbGateway::ensureProject(const std::string& name) {
|
|
|
|
|
+ try { client_.createProject(name); return true; } // idempotent server-side
|
|
|
|
|
+ catch (const std::exception&) { return false; }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+bool DbGateway::dropProject(const std::string& name) {
|
|
|
|
|
+ if (name == "default") return false;
|
|
|
|
|
+ try { client_.dropProject(name); return true; }
|
|
|
|
|
+ catch (const std::exception&) { return false; }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+} // namespace svapi
|
|
|
|
|
+```
|
|
|
|
|
+(Header declares a private `smartbotic::database::Client client_;`.)
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5:** add `db_gateway.cpp` to `vectorapi_core`; build; `ctest --test-dir build -R "Qualify|DbGateway" --output-on-failure`. Expect Qualify PASS always; DbGateway PASS (DB up).
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: Commit** `feat(db): DbGateway with project namespacing (qualify/listProjects/ensureProject/dropProject)`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 6: Settings + ApiKey model (pure logic)
|
|
|
|
|
+
|
|
|
|
|
+**Files:** Create `src/settings.{hpp,cpp}`, `src/apikey.{hpp,cpp}`; add both `.cpp` to `vectorapi_core`. Test `tests/test_settings.cpp`.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Failing test `tests/test_settings.cpp`**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+#include <gtest/gtest.h>
|
|
|
|
|
+#include "settings.hpp"
|
|
|
|
|
+#include "apikey.hpp"
|
|
|
|
|
+#include <cctype>
|
|
|
|
|
+using namespace svapi;
|
|
|
|
|
+
|
|
|
|
|
+TEST(Settings, DefaultsAndRoundTrip) {
|
|
|
|
|
+ Settings s = Settings::fromJson(nlohmann::json::object());
|
|
|
|
|
+ EXPECT_EQ(s.openaiApiBase, "https://api.openai.com");
|
|
|
|
|
+ EXPECT_EQ(s.defaultEmbeddingModel, "text-embedding-3-small");
|
|
|
|
|
+ EXPECT_EQ(s.sessionTtlMinutes, 720u);
|
|
|
|
|
+ EXPECT_TRUE(s.webuiEnabled);
|
|
|
|
|
+ EXPECT_EQ(s.defaultProject, "default");
|
|
|
|
|
+ EXPECT_EQ(Settings::fromJson(s.toJson()).toJson(), s.toJson());
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+TEST(ApiKey, JsonRoundTripAndPublicMasks) {
|
|
|
|
|
+ ApiKey k = ApiKey::fromJson(nlohmann::json::parse(
|
|
|
|
|
+ R"({"key":"abcdef0123456789","label":"n8n","projects":["p1","p2"],"admin":false,"created_at":7})"));
|
|
|
|
|
+ EXPECT_EQ(k.key, "abcdef0123456789");
|
|
|
|
|
+ EXPECT_EQ(k.projects.size(), 2u);
|
|
|
|
|
+ EXPECT_FALSE(k.admin);
|
|
|
|
|
+ EXPECT_EQ(k.createdAt, 7u);
|
|
|
|
|
+ EXPECT_EQ(ApiKey::fromJson(k.toJson()).toJson(), k.toJson());
|
|
|
|
|
+ auto pub = k.toPublicJson();
|
|
|
|
|
+ EXPECT_FALSE(pub.contains("key")); // secret not exposed
|
|
|
|
|
+ EXPECT_EQ(pub["label"], "n8n");
|
|
|
|
|
+ EXPECT_EQ(pub["projects"].size(), 2u);
|
|
|
|
|
+ EXPECT_TRUE(pub.contains("key_prefix")); // short hint only
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+TEST(ApiKey, CanAccess) {
|
|
|
|
|
+ ApiKey adminK; adminK.admin = true;
|
|
|
|
|
+ EXPECT_TRUE(adminK.canAccess("anything"));
|
|
|
|
|
+
|
|
|
|
|
+ ApiKey star; star.projects = {"*"};
|
|
|
|
|
+ EXPECT_TRUE(star.canAccess("anything"));
|
|
|
|
|
+
|
|
|
|
|
+ ApiKey scoped; scoped.projects = {"p1", "p2"};
|
|
|
|
|
+ EXPECT_TRUE(scoped.canAccess("p1"));
|
|
|
|
|
+ EXPECT_FALSE(scoped.canAccess("p3"));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+TEST(ApiKey, GenerateIsHexAndUnique) {
|
|
|
|
|
+ std::string a = generateApiKey(), b = generateApiKey();
|
|
|
|
|
+ EXPECT_GE(a.size(), 32u);
|
|
|
|
|
+ EXPECT_NE(a, b);
|
|
|
|
|
+ for (char c : a) EXPECT_TRUE(std::isxdigit(static_cast<unsigned char>(c)));
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+Add `test_settings` target (link `vectorapi_core GTest::gtest_main`, `gtest_discover_tests`).
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2:** Build → fails (no settings.hpp/apikey.hpp).
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: `src/settings.hpp`** — the `Settings` struct from Canonical interfaces.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: `src/settings.cpp`**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+#include "settings.hpp"
|
|
|
|
|
+namespace svapi {
|
|
|
|
|
+Settings Settings::fromJson(const nlohmann::json& j) {
|
|
|
|
|
+ Settings s;
|
|
|
|
|
+ s.openaiApiBase = j.value("openai_api_base", s.openaiApiBase);
|
|
|
|
|
+ s.openaiApiKey = j.value("openai_api_key", s.openaiApiKey);
|
|
|
|
|
+ s.defaultEmbeddingModel = j.value("default_embedding_model", s.defaultEmbeddingModel);
|
|
|
|
|
+ if (j.contains("cors_origins") && j["cors_origins"].is_array())
|
|
|
|
|
+ s.corsOrigins = j["cors_origins"].get<std::vector<std::string>>();
|
|
|
|
|
+ s.sessionTtlMinutes = j.value("session_ttl_minutes", s.sessionTtlMinutes);
|
|
|
|
|
+ s.webuiEnabled = j.value("webui_enabled", s.webuiEnabled);
|
|
|
|
|
+ s.defaultProject = j.value("default_project", s.defaultProject);
|
|
|
|
|
+ return s;
|
|
|
|
|
+}
|
|
|
|
|
+nlohmann::json Settings::toJson() const {
|
|
|
|
|
+ return {{"openai_api_base", openaiApiBase}, {"openai_api_key", openaiApiKey},
|
|
|
|
|
+ {"default_embedding_model", defaultEmbeddingModel}, {"cors_origins", corsOrigins},
|
|
|
|
|
+ {"session_ttl_minutes", sessionTtlMinutes}, {"webui_enabled", webuiEnabled},
|
|
|
|
|
+ {"default_project", defaultProject}};
|
|
|
|
|
+}
|
|
|
|
|
+} // namespace svapi
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: `src/apikey.hpp`** — the `ApiKey` struct + `generateApiKey()` from Canonical interfaces.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: `src/apikey.cpp`**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+#include "apikey.hpp"
|
|
|
|
|
+#include <openssl/rand.h>
|
|
|
|
|
+#include <algorithm>
|
|
|
|
|
+#include <array>
|
|
|
|
|
+#include <stdexcept>
|
|
|
|
|
+
|
|
|
|
|
+namespace svapi {
|
|
|
|
|
+
|
|
|
|
|
+ApiKey ApiKey::fromJson(const nlohmann::json& j) {
|
|
|
|
|
+ ApiKey k;
|
|
|
|
|
+ k.key = j.value("key", "");
|
|
|
|
|
+ k.label = j.value("label", "");
|
|
|
|
|
+ if (j.contains("projects") && j["projects"].is_array())
|
|
|
|
|
+ k.projects = j["projects"].get<std::vector<std::string>>();
|
|
|
|
|
+ k.admin = j.value("admin", false);
|
|
|
|
|
+ k.createdAt = j.value("created_at", 0ull);
|
|
|
|
|
+ return k;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+nlohmann::json ApiKey::toJson() const {
|
|
|
|
|
+ return {{"key", key}, {"label", label}, {"projects", projects},
|
|
|
|
|
+ {"admin", admin}, {"created_at", createdAt}};
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+nlohmann::json ApiKey::toPublicJson() const {
|
|
|
|
|
+ return {{"label", label}, {"projects", projects}, {"admin", admin},
|
|
|
|
|
+ {"created_at", createdAt},
|
|
|
|
|
+ {"key_prefix", key.substr(0, std::min<size_t>(8, key.size()))}};
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+bool ApiKey::canAccess(const std::string& project) const {
|
|
|
|
|
+ if (admin) return true;
|
|
|
|
|
+ for (const auto& p : projects) if (p == "*" || p == project) return true;
|
|
|
|
|
+ return false;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+std::string generateApiKey() {
|
|
|
|
|
+ std::array<unsigned char, 24> buf{};
|
|
|
|
|
+ if (RAND_bytes(buf.data(), static_cast<int>(buf.size())) != 1)
|
|
|
|
|
+ throw std::runtime_error("RAND_bytes failed");
|
|
|
|
|
+ static const char* hex = "0123456789abcdef";
|
|
|
|
|
+ std::string out; out.reserve(48);
|
|
|
|
|
+ for (unsigned char b : buf) { out.push_back(hex[b >> 4]); out.push_back(hex[b & 0xF]); }
|
|
|
|
|
+ return out;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+} // namespace svapi
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 7:** add `settings.cpp apikey.cpp` to `vectorapi_core`; build; `ctest -R "Settings|ApiKey"`. All PASS.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 8: Commit** `feat(settings): global Settings + ApiKey model with per-project grants`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 7: KeyStore + SettingsStore (DB-backed, bootstrap, hot reload)
|
|
|
|
|
+
|
|
|
|
|
+**Files:** Create `src/key_store.{hpp,cpp}`, `src/settings_store.{hpp,cpp}`; add both `.cpp`. Test `tests/test_stores.cpp` (integration).
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Failing test `tests/test_stores.cpp`**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+#include "db_test_util.hpp"
|
|
|
|
|
+#include "key_store.hpp"
|
|
|
|
|
+#include "settings_store.hpp"
|
|
|
|
|
+using namespace svapi;
|
|
|
|
|
+
|
|
|
|
|
+TEST(KeyStore, BootstrapCreatesAdminKeyOnce) {
|
|
|
|
|
+ SVAPI_REQUIRE_DB(db);
|
|
|
|
|
+ const std::string coll = "_svapitest_keys_a";
|
|
|
|
|
+ db->client().dropCollection(coll);
|
|
|
|
|
+
|
|
|
|
|
+ KeyStore ks(*db, coll);
|
|
|
|
|
+ ks.bootstrap(/*envAdminKey*/ "");
|
|
|
|
|
+ auto all = ks.list();
|
|
|
|
|
+ ASSERT_EQ(all.size(), 1u);
|
|
|
|
|
+ EXPECT_TRUE(all[0].admin);
|
|
|
|
|
+ ASSERT_FALSE(all[0].projects.empty());
|
|
|
|
|
+ EXPECT_EQ(all[0].projects[0], "*");
|
|
|
|
|
+ std::string adminKey = all[0].key;
|
|
|
|
|
+ EXPECT_TRUE(ks.lookup(adminKey).has_value());
|
|
|
|
|
+ EXPECT_FALSE(ks.lookup("nope").has_value());
|
|
|
|
|
+
|
|
|
|
|
+ // Re-bootstrap: no second key created.
|
|
|
|
|
+ KeyStore ks2(*db, coll);
|
|
|
|
|
+ ks2.bootstrap("");
|
|
|
|
|
+ EXPECT_EQ(ks2.list().size(), 1u);
|
|
|
|
|
+
|
|
|
|
|
+ db->client().dropCollection(coll);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+TEST(KeyStore, EnvSeedsAdminKeyValue) {
|
|
|
|
|
+ SVAPI_REQUIRE_DB(db);
|
|
|
|
|
+ const std::string coll = "_svapitest_keys_b";
|
|
|
|
|
+ db->client().dropCollection(coll);
|
|
|
|
|
+ KeyStore ks(*db, coll);
|
|
|
|
|
+ ks.bootstrap("seed-admin-123");
|
|
|
|
|
+ ASSERT_EQ(ks.list().size(), 1u);
|
|
|
|
|
+ EXPECT_EQ(ks.list()[0].key, "seed-admin-123");
|
|
|
|
|
+ db->client().dropCollection(coll);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+TEST(KeyStore, CreateUpdateRemove) {
|
|
|
|
|
+ SVAPI_REQUIRE_DB(db);
|
|
|
|
|
+ const std::string coll = "_svapitest_keys_c";
|
|
|
|
|
+ db->client().dropCollection(coll);
|
|
|
|
|
+ KeyStore ks(*db, coll); ks.bootstrap("");
|
|
|
|
|
+
|
|
|
|
|
+ ApiKey made = ks.create("n8n", {"p1"}, /*admin*/ false);
|
|
|
|
|
+ EXPECT_FALSE(made.key.empty());
|
|
|
|
|
+ ASSERT_TRUE(ks.lookup(made.key).has_value());
|
|
|
|
|
+ EXPECT_TRUE(ks.lookup(made.key)->canAccess("p1"));
|
|
|
|
|
+ EXPECT_FALSE(ks.lookup(made.key)->canAccess("p2"));
|
|
|
|
|
+
|
|
|
|
|
+ EXPECT_TRUE(ks.update(made.key, std::nullopt, std::vector<std::string>{"p1", "p2"}, std::nullopt));
|
|
|
|
|
+ EXPECT_TRUE(ks.lookup(made.key)->canAccess("p2"));
|
|
|
|
|
+
|
|
|
|
|
+ EXPECT_TRUE(ks.remove(made.key));
|
|
|
|
|
+ EXPECT_FALSE(ks.lookup(made.key).has_value());
|
|
|
|
|
+ db->client().dropCollection(coll);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+TEST(SettingsStore, BootstrapAndSave) {
|
|
|
|
|
+ SVAPI_REQUIRE_DB(db);
|
|
|
|
|
+ const std::string coll = "_svapitest_settings";
|
|
|
|
|
+ db->client().dropCollection(coll);
|
|
|
|
|
+ SettingsStore ss(*db, coll, "current");
|
|
|
|
|
+ ss.bootstrap(/*envOpenAiKey*/ "sk-env");
|
|
|
|
|
+ EXPECT_EQ(ss.snapshot()->openaiApiKey, "sk-env");
|
|
|
|
|
+ Settings s = *ss.snapshot(); s.defaultEmbeddingModel = "text-embedding-3-large";
|
|
|
|
|
+ EXPECT_TRUE(ss.save(s));
|
|
|
|
|
+ EXPECT_EQ(ss.snapshot()->defaultEmbeddingModel, "text-embedding-3-large");
|
|
|
|
|
+ db->client().dropCollection(coll);
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+Add `test_stores` target.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2:** Build → fails.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: `src/key_store.hpp`** — the `KeyStore` interface from Canonical interfaces, with members: `DbGateway& db_; std::string coll_; mutable std::mutex m_; std::shared_ptr<const std::vector<ApiKey>> snap_; std::shared_ptr<void> sub_;` and a private `void reload();`.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: `src/key_store.cpp`**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+#include "key_store.hpp"
|
|
|
|
|
+#include <spdlog/spdlog.h>
|
|
|
|
|
+#include <ctime>
|
|
|
|
|
+
|
|
|
|
|
+namespace svapi {
|
|
|
|
|
+
|
|
|
|
|
+KeyStore::KeyStore(DbGateway& db, std::string collection)
|
|
|
|
|
+ : db_(db), coll_(std::move(collection)), snap_(std::make_shared<const std::vector<ApiKey>>()) {}
|
|
|
|
|
+
|
|
|
|
|
+void KeyStore::reload() {
|
|
|
|
|
+ smartbotic::database::Client::QueryOptions opts; opts.limit = 100000;
|
|
|
|
|
+ auto docs = db_.client().find(coll_, opts);
|
|
|
|
|
+ auto v = std::make_shared<std::vector<ApiKey>>();
|
|
|
|
|
+ for (const auto& d : docs) v->push_back(ApiKey::fromJson(d));
|
|
|
|
|
+ std::lock_guard<std::mutex> lk(m_);
|
|
|
|
|
+ snap_ = v;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void KeyStore::bootstrap(const std::string& envAdminKey) {
|
|
|
|
|
+ if (!db_.client().getCollectionInfo(coll_).has_value())
|
|
|
|
|
+ db_.client().createCollection(coll_, 0, /*encrypted*/ true, 0, 0);
|
|
|
|
|
+ reload();
|
|
|
|
|
+ if (snap_->empty()) {
|
|
|
|
|
+ ApiKey k;
|
|
|
|
|
+ k.key = envAdminKey.empty() ? generateApiKey() : envAdminKey;
|
|
|
|
|
+ k.label = "bootstrap-admin";
|
|
|
|
|
+ k.projects = {"*"};
|
|
|
|
|
+ k.admin = true;
|
|
|
|
|
+ k.createdAt = static_cast<uint64_t>(std::time(nullptr));
|
|
|
|
|
+ db_.client().upsert(coll_, k.toJson(), k.key);
|
|
|
|
|
+ reload();
|
|
|
|
|
+ if (envAdminKey.empty())
|
|
|
|
|
+ spdlog::warn("Generated initial admin API key: {} — store it now; shown only once.", k.key);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+std::optional<ApiKey> KeyStore::lookup(const std::string& key) const {
|
|
|
|
|
+ std::lock_guard<std::mutex> lk(m_);
|
|
|
|
|
+ for (const auto& k : *snap_) if (k.key == key) return k;
|
|
|
|
|
+ return std::nullopt;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+std::vector<ApiKey> KeyStore::list() const {
|
|
|
|
|
+ std::lock_guard<std::mutex> lk(m_);
|
|
|
|
|
+ return *snap_;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+ApiKey KeyStore::create(const std::string& label, std::vector<std::string> projects, bool admin) {
|
|
|
|
|
+ ApiKey k;
|
|
|
|
|
+ k.key = generateApiKey(); k.label = label; k.projects = std::move(projects);
|
|
|
|
|
+ k.admin = admin; k.createdAt = static_cast<uint64_t>(std::time(nullptr));
|
|
|
|
|
+ db_.client().upsert(coll_, k.toJson(), k.key);
|
|
|
|
|
+ reload();
|
|
|
|
|
+ return k;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+bool KeyStore::update(const std::string& key, const std::optional<std::string>& label,
|
|
|
|
|
+ const std::optional<std::vector<std::string>>& projects,
|
|
|
|
|
+ const std::optional<bool>& admin) {
|
|
|
|
|
+ auto doc = db_.client().get(coll_, key);
|
|
|
|
|
+ if (!doc) return false;
|
|
|
|
|
+ ApiKey k = ApiKey::fromJson(*doc);
|
|
|
|
|
+ if (label) k.label = *label;
|
|
|
|
|
+ if (projects) k.projects = *projects;
|
|
|
|
|
+ if (admin) k.admin = *admin;
|
|
|
|
|
+ db_.client().upsert(coll_, k.toJson(), key);
|
|
|
|
|
+ reload();
|
|
|
|
|
+ return true;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+bool KeyStore::remove(const std::string& key) {
|
|
|
|
|
+ bool ok = db_.client().remove(coll_, key);
|
|
|
|
|
+ reload();
|
|
|
|
|
+ return ok;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void KeyStore::startWatch() {
|
|
|
|
|
+ sub_ = db_.client().subscribe({coll_}, [this](const std::string&, const std::string&,
|
|
|
|
|
+ const std::string&, const std::optional<nlohmann::json>&) {
|
|
|
|
|
+ try { reload(); } catch (const std::exception& e) { spdlog::warn("key reload failed: {}", e.what()); }
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+} // namespace svapi
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: `src/settings_store.hpp` + `src/settings_store.cpp`** — identical in shape to the base plan's Task 7 SettingsStore, except (a) `Settings` has no `api_key`, and (b) `bootstrap(const std::string& envOpenAiKey)` only ensures the collection, seeds `openaiApiKey` from env when empty, persists if the doc is absent, and publishes the snapshot. (No key resolution — keys live in `KeyStore`.) Use a `std::mutex`-guarded `std::shared_ptr<const Settings>`; `startWatch()` subscribes to the settings collection and calls `reloadFromDb()`.
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+// settings_store.cpp
|
|
|
|
|
+#include "settings_store.hpp"
|
|
|
|
|
+#include <spdlog/spdlog.h>
|
|
|
|
|
+namespace svapi {
|
|
|
|
|
+SettingsStore::SettingsStore(DbGateway& db, std::string collection, std::string docId)
|
|
|
|
|
+ : db_(db), coll_(std::move(collection)), docId_(std::move(docId)),
|
|
|
|
|
+ snap_(std::make_shared<const Settings>()) {}
|
|
|
|
|
+void SettingsStore::bootstrap(const std::string& envOpenAiKey) {
|
|
|
|
|
+ if (!db_.client().getCollectionInfo(coll_).has_value())
|
|
|
|
|
+ db_.client().createCollection(coll_, 0, /*encrypted*/ true, 0, 0);
|
|
|
|
|
+ Settings s;
|
|
|
|
|
+ if (auto doc = db_.client().get(coll_, docId_)) s = Settings::fromJson(*doc);
|
|
|
|
|
+ if (s.openaiApiKey.empty() && !envOpenAiKey.empty()) s.openaiApiKey = envOpenAiKey;
|
|
|
|
|
+ if (!db_.client().exists(coll_, docId_)) db_.client().upsert(coll_, s.toJson(), docId_);
|
|
|
|
|
+ setSnapshot(s);
|
|
|
|
|
+}
|
|
|
|
|
+void SettingsStore::setSnapshot(Settings s) {
|
|
|
|
|
+ auto p = std::make_shared<const Settings>(std::move(s));
|
|
|
|
|
+ std::lock_guard<std::mutex> lk(m_); snap_ = p;
|
|
|
|
|
+}
|
|
|
|
|
+std::shared_ptr<const Settings> SettingsStore::snapshot() const {
|
|
|
|
|
+ std::lock_guard<std::mutex> lk(m_); return snap_;
|
|
|
|
|
+}
|
|
|
|
|
+void SettingsStore::reloadFromDb() {
|
|
|
|
|
+ if (auto doc = db_.client().get(coll_, docId_)) setSnapshot(Settings::fromJson(*doc));
|
|
|
|
|
+}
|
|
|
|
|
+bool SettingsStore::save(const Settings& s) { db_.client().upsert(coll_, s.toJson(), docId_); reloadFromDb(); return true; }
|
|
|
|
|
+void SettingsStore::startWatch() {
|
|
|
|
|
+ sub_ = db_.client().subscribe({coll_}, [this](const std::string&, const std::string&,
|
|
|
|
|
+ const std::string&, const std::optional<nlohmann::json>&) {
|
|
|
|
|
+ try { reloadFromDb(); } catch (const std::exception& e) { spdlog::warn("settings reload failed: {}", e.what()); }
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+} // namespace svapi
|
|
|
|
|
+```
|
|
|
|
|
+(Header `settings_store.hpp` declares the members `DbGateway& db_; std::string coll_, docId_; mutable std::mutex m_; std::shared_ptr<const Settings> snap_; std::shared_ptr<void> sub_;` and private `void setSnapshot(Settings);`.)
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6:** add `key_store.cpp settings_store.cpp` to `vectorapi_core`; build; `ctest -R "KeyStore|SettingsStore"`. All PASS (DB up).
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 7: Commit** `feat(auth): DB-backed KeyStore (bootstrap admin key, grants) + global SettingsStore`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 8: CollectionRegistry (project-scoped)
|
|
|
|
|
+
|
|
|
|
|
+**Files:** Create `src/collection_registry.{hpp,cpp}`; add `.cpp`. Test `tests/test_registry.cpp` (integration + a pure meta test).
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Failing test `tests/test_registry.cpp`**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+#include "db_test_util.hpp"
|
|
|
|
|
+#include "collection_registry.hpp"
|
|
|
|
|
+using namespace svapi;
|
|
|
|
|
+
|
|
|
|
|
+TEST(CollectionMeta, JsonRoundTrip) {
|
|
|
|
|
+ CollectionMeta m{"docs", "vector", 1536, "text-embedding-3-small", 123};
|
|
|
|
|
+ CollectionMeta r = CollectionMeta::fromJson(m.toJson());
|
|
|
|
|
+ EXPECT_EQ(r.name, "docs"); EXPECT_EQ(r.kind, "vector");
|
|
|
|
|
+ EXPECT_EQ(r.vectorDimension, 1536u); EXPECT_EQ(r.embeddingModel, "text-embedding-3-small");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+TEST(CollectionRegistry, IsolatedPerProject) {
|
|
|
|
|
+ SVAPI_REQUIRE_DB(db);
|
|
|
|
|
+ const std::string pa = testutil::tmpName("rega"), pb = testutil::tmpName("regb");
|
|
|
|
|
+ db->ensureProject(pa); db->ensureProject(pb);
|
|
|
|
|
+ CollectionRegistry reg(*db);
|
|
|
|
|
+ reg.ensure(pa); reg.ensure(pb);
|
|
|
|
|
+
|
|
|
|
|
+ EXPECT_TRUE(reg.add(pa, {"users", "json", 0, "", 0}));
|
|
|
|
|
+ EXPECT_TRUE(reg.get(pa, "users").has_value());
|
|
|
|
|
+ EXPECT_FALSE(reg.get(pb, "users").has_value()); // isolation across projects
|
|
|
|
|
+ EXPECT_EQ(reg.list(pa).size(), 1u);
|
|
|
|
|
+ EXPECT_EQ(reg.list(pb).size(), 0u);
|
|
|
|
|
+ EXPECT_TRUE(reg.remove(pa, "users"));
|
|
|
|
|
+ EXPECT_FALSE(reg.get(pa, "users").has_value());
|
|
|
|
|
+
|
|
|
|
|
+ db->dropProject(pa); db->dropProject(pb);
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+Add `test_registry` target.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2:** Build → fails.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: `src/collection_registry.hpp`** — the interface from Canonical interfaces; private `std::string coll(const std::string& project) const { return qualify(project, "_vectorapi_collections"); }` and `DbGateway& db_;`. Include `db_gateway.hpp` for `qualify`.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: `src/collection_registry.cpp`**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+#include "collection_registry.hpp"
|
|
|
|
|
+#include <ctime>
|
|
|
|
|
+
|
|
|
|
|
+namespace svapi {
|
|
|
|
|
+
|
|
|
|
|
+CollectionMeta CollectionMeta::fromJson(const nlohmann::json& j) {
|
|
|
|
|
+ CollectionMeta m;
|
|
|
|
|
+ m.name = j.value("name", "");
|
|
|
|
|
+ m.kind = j.value("kind", "json");
|
|
|
|
|
+ m.vectorDimension = j.value("vector_dimension", 0u);
|
|
|
|
|
+ m.embeddingModel = j.value("embedding_model", "");
|
|
|
|
|
+ m.createdAt = j.value("created_at", 0ull);
|
|
|
|
|
+ return m;
|
|
|
|
|
+}
|
|
|
|
|
+nlohmann::json CollectionMeta::toJson() const {
|
|
|
|
|
+ return {{"name", name}, {"kind", kind}, {"vector_dimension", vectorDimension},
|
|
|
|
|
+ {"embedding_model", embeddingModel}, {"created_at", createdAt}};
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+CollectionRegistry::CollectionRegistry(DbGateway& db) : db_(db) {}
|
|
|
|
|
+
|
|
|
|
|
+void CollectionRegistry::ensure(const std::string& project) {
|
|
|
|
|
+ const std::string c = coll(project);
|
|
|
|
|
+ if (!db_.client().getCollectionInfo(c).has_value())
|
|
|
|
|
+ db_.client().createCollection(c, 0, false, 0, 0);
|
|
|
|
|
+}
|
|
|
|
|
+bool CollectionRegistry::add(const std::string& project, const CollectionMeta& meta) {
|
|
|
|
|
+ CollectionMeta m = meta;
|
|
|
|
|
+ if (m.createdAt == 0) m.createdAt = static_cast<uint64_t>(std::time(nullptr));
|
|
|
|
|
+ auto [id, isNew] = db_.client().upsert(coll(project), m.toJson(), m.name);
|
|
|
|
|
+ return !id.empty();
|
|
|
|
|
+}
|
|
|
|
|
+std::optional<CollectionMeta> CollectionRegistry::get(const std::string& project, const std::string& name) {
|
|
|
|
|
+ if (auto doc = db_.client().get(coll(project), name)) return CollectionMeta::fromJson(*doc);
|
|
|
|
|
+ return std::nullopt;
|
|
|
|
|
+}
|
|
|
|
|
+std::vector<CollectionMeta> CollectionRegistry::list(const std::string& project) {
|
|
|
|
|
+ std::vector<CollectionMeta> out;
|
|
|
|
|
+ smartbotic::database::Client::QueryOptions opts; opts.limit = 100000;
|
|
|
|
|
+ for (const auto& doc : db_.client().find(coll(project), opts)) out.push_back(CollectionMeta::fromJson(doc));
|
|
|
|
|
+ return out;
|
|
|
|
|
+}
|
|
|
|
|
+bool CollectionRegistry::remove(const std::string& project, const std::string& name) {
|
|
|
|
|
+ return db_.client().remove(coll(project), name);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+} // namespace svapi
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5:** add `collection_registry.cpp` to `vectorapi_core`; build; `ctest -R "CollectionMeta|CollectionRegistry"`. PASS.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: Commit** `feat(registry): project-scoped collection registry`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 9: Embeddings (OpenAI client + parse)
|
|
|
|
|
+
|
|
|
|
|
+Unchanged from the base plan. **Files:** `src/embeddings.{hpp,cpp}` (add `.cpp` to `vectorapi_core`), `tests/test_embeddings.cpp`.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Failing test `tests/test_embeddings.cpp`**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+#include <gtest/gtest.h>
|
|
|
|
|
+#include "embeddings.hpp"
|
|
|
|
|
+#include "errors.hpp"
|
|
|
|
|
+#include <httplib.h>
|
|
|
|
|
+#include <thread>
|
|
|
|
|
+using namespace svapi;
|
|
|
|
|
+
|
|
|
|
|
+TEST(Embeddings, ParsesWellFormedResponse) {
|
|
|
|
|
+ auto v = parseEmbeddingResponse(nlohmann::json::parse(R"({"data":[{"embedding":[0.5,1.0,1.5]}]})"));
|
|
|
|
|
+ ASSERT_EQ(v.size(), 3u); EXPECT_FLOAT_EQ(v[0], 0.5f); EXPECT_FLOAT_EQ(v[2], 1.5f);
|
|
|
|
|
+}
|
|
|
|
|
+TEST(Embeddings, MalformedThrows) {
|
|
|
|
|
+ EXPECT_THROW(parseEmbeddingResponse(nlohmann::json::parse(R"({"data":[]})")), ApiError);
|
|
|
|
|
+ EXPECT_THROW(parseEmbeddingResponse(nlohmann::json::object()), ApiError);
|
|
|
|
|
+}
|
|
|
|
|
+TEST(Embeddings, ClientHitsMockServer) {
|
|
|
|
|
+ httplib::Server mock;
|
|
|
|
|
+ mock.Post("/v1/embeddings", [](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ auto j = nlohmann::json::parse(req.body);
|
|
|
|
|
+ EXPECT_EQ(j["model"], "m"); EXPECT_EQ(j["input"], "hello");
|
|
|
|
|
+ res.set_content(R"({"data":[{"embedding":[1,2,3,4]}]})", "application/json");
|
|
|
|
|
+ });
|
|
|
|
|
+ int port = mock.bind_to_any_port("127.0.0.1");
|
|
|
|
|
+ std::thread t([&]{ mock.listen_after_bind(); });
|
|
|
|
|
+ EmbeddingClient cli("http://127.0.0.1:" + std::to_string(port), "k");
|
|
|
|
|
+ auto v = cli.embed("m", "hello");
|
|
|
|
|
+ EXPECT_EQ(v.size(), 4u); EXPECT_FLOAT_EQ(v[3], 4.0f);
|
|
|
|
|
+ mock.stop(); t.join();
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+Add `test_embeddings` target.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2:** Build → fails.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: `src/embeddings.hpp`**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+#pragma once
|
|
|
|
|
+#include <string>
|
|
|
|
|
+#include <vector>
|
|
|
|
|
+#include <nlohmann/json.hpp>
|
|
|
|
|
+namespace svapi {
|
|
|
|
|
+std::vector<float> parseEmbeddingResponse(const nlohmann::json& body);
|
|
|
|
|
+class EmbeddingClient {
|
|
|
|
|
+public:
|
|
|
|
|
+ EmbeddingClient(std::string apiBase, std::string apiKey);
|
|
|
|
|
+ std::vector<float> embed(const std::string& model, const std::string& text);
|
|
|
|
|
+private:
|
|
|
|
|
+ std::string apiBase_, apiKey_;
|
|
|
|
|
+};
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: `src/embeddings.cpp`**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+#include "embeddings.hpp"
|
|
|
|
|
+#include "errors.hpp"
|
|
|
|
|
+#include <httplib.h>
|
|
|
|
|
+namespace svapi {
|
|
|
|
|
+std::vector<float> parseEmbeddingResponse(const nlohmann::json& body) {
|
|
|
|
|
+ if (!body.contains("data") || !body["data"].is_array() || body["data"].empty())
|
|
|
|
|
+ throw ApiError(ErrCode::Unprocessable, "embedding_failed", "embedding response missing data[]");
|
|
|
|
|
+ const auto& emb = body["data"][0].value("embedding", nlohmann::json());
|
|
|
|
|
+ if (!emb.is_array() || emb.empty())
|
|
|
|
|
+ throw ApiError(ErrCode::Unprocessable, "embedding_failed", "embedding response missing embedding[]");
|
|
|
|
|
+ std::vector<float> out; out.reserve(emb.size());
|
|
|
|
|
+ for (const auto& v : emb) out.push_back(v.get<float>());
|
|
|
|
|
+ return out;
|
|
|
|
|
+}
|
|
|
|
|
+EmbeddingClient::EmbeddingClient(std::string apiBase, std::string apiKey)
|
|
|
|
|
+ : apiBase_(std::move(apiBase)), apiKey_(std::move(apiKey)) {}
|
|
|
|
|
+std::vector<float> EmbeddingClient::embed(const std::string& model, const std::string& text) {
|
|
|
|
|
+ httplib::Client cli(apiBase_);
|
|
|
|
|
+ cli.set_connection_timeout(10); cli.set_read_timeout(30);
|
|
|
|
|
+ cli.enable_server_certificate_verification(true);
|
|
|
|
|
+ if (!apiKey_.empty()) cli.set_bearer_token_auth(apiKey_);
|
|
|
|
|
+ nlohmann::json req{{"model", model}, {"input", text}};
|
|
|
|
|
+ auto res = cli.Post("/v1/embeddings", req.dump(), "application/json");
|
|
|
|
|
+ if (!res) throw ApiError(ErrCode::Unavailable, "embedding_unreachable", "could not reach embedding endpoint");
|
|
|
|
|
+ if (res->status != 200)
|
|
|
|
|
+ throw ApiError(ErrCode::Unprocessable, "embedding_failed",
|
|
|
|
|
+ "embedding endpoint returned HTTP " + std::to_string(res->status));
|
|
|
|
|
+ return parseEmbeddingResponse(nlohmann::json::parse(res->body));
|
|
|
|
|
+}
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5:** add `embeddings.cpp`; build; `ctest -R Embeddings`. PASS.
|
|
|
|
|
+- [ ] **Step 6: Commit** `feat(embeddings): OpenAI /v1/embeddings client + response parsing`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 10: Auth (SessionStore token→key, bearer/cookie)
|
|
|
|
|
+
|
|
|
|
|
+**Files:** `src/auth.{hpp,cpp}` (add `.cpp`); `tests/test_auth.cpp`.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Failing test `tests/test_auth.cpp`**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+#include <gtest/gtest.h>
|
|
|
|
|
+#include "auth.hpp"
|
|
|
|
|
+using namespace svapi;
|
|
|
|
|
+using clk = std::chrono::system_clock;
|
|
|
|
|
+
|
|
|
|
|
+TEST(Auth, ParsesBearer) {
|
|
|
|
|
+ EXPECT_EQ(bearerToken("Bearer abc").value(), "abc");
|
|
|
|
|
+ EXPECT_FALSE(bearerToken("Basic x").has_value());
|
|
|
|
|
+}
|
|
|
|
|
+TEST(Auth, ParsesCookie) {
|
|
|
|
|
+ EXPECT_EQ(cookieValue("a=1; svapi_session=tok; b=2", "svapi_session").value(), "tok");
|
|
|
|
|
+ EXPECT_FALSE(cookieValue("a=1", "svapi_session").has_value());
|
|
|
|
|
+}
|
|
|
|
|
+TEST(Auth, SessionMapsToKey) {
|
|
|
|
|
+ SessionStore s(std::chrono::minutes(10));
|
|
|
|
|
+ auto now = clk::now();
|
|
|
|
|
+ std::string tok = s.create("APIKEY-XYZ", now);
|
|
|
|
|
+ ASSERT_TRUE(s.keyFor(tok, now).has_value());
|
|
|
|
|
+ EXPECT_EQ(s.keyFor(tok, now).value(), "APIKEY-XYZ");
|
|
|
|
|
+ EXPECT_FALSE(s.keyFor("bad", now).has_value());
|
|
|
|
|
+ EXPECT_FALSE(s.keyFor(tok, now + std::chrono::minutes(11)).has_value()); // expired
|
|
|
|
|
+}
|
|
|
|
|
+TEST(Auth, RemoveInvalidates) {
|
|
|
|
|
+ SessionStore s(std::chrono::minutes(10));
|
|
|
|
|
+ auto now = clk::now(); std::string tok = s.create("K", now);
|
|
|
|
|
+ s.remove(tok);
|
|
|
|
|
+ EXPECT_FALSE(s.keyFor(tok, now).has_value());
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+Add `test_auth` target.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2:** Build → fails.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: `src/auth.hpp`** — the `auth.hpp` interface from Canonical interfaces. `SessionStore` stores `token -> {apiKeyValue, expiry}`.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: `src/auth.cpp`**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+#include "auth.hpp"
|
|
|
|
|
+#include <openssl/rand.h>
|
|
|
|
|
+#include <array>
|
|
|
|
|
+#include <stdexcept>
|
|
|
|
|
+namespace svapi {
|
|
|
|
|
+
|
|
|
|
|
+std::optional<std::string> bearerToken(const std::string& h) {
|
|
|
|
|
+ constexpr const char* P = "Bearer "; constexpr size_t L = 7;
|
|
|
|
|
+ if (h.size() <= L || h.compare(0, L, P) != 0) return std::nullopt;
|
|
|
|
|
+ return h.substr(L);
|
|
|
|
|
+}
|
|
|
|
|
+std::optional<std::string> cookieValue(const std::string& header, const std::string& name) {
|
|
|
|
|
+ size_t pos = 0;
|
|
|
|
|
+ while (pos < header.size()) {
|
|
|
|
|
+ size_t semi = header.find(';', pos);
|
|
|
|
|
+ std::string pair = header.substr(pos, semi == std::string::npos ? std::string::npos : semi - pos);
|
|
|
|
|
+ size_t s = pair.find_first_not_of(" \t"); if (s != std::string::npos) pair = pair.substr(s);
|
|
|
|
|
+ size_t eq = pair.find('=');
|
|
|
|
|
+ if (eq != std::string::npos && pair.substr(0, eq) == name) return pair.substr(eq + 1);
|
|
|
|
|
+ if (semi == std::string::npos) break;
|
|
|
|
|
+ pos = semi + 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ return std::nullopt;
|
|
|
|
|
+}
|
|
|
|
|
+std::string randomToken() {
|
|
|
|
|
+ std::array<unsigned char, 16> buf{};
|
|
|
|
|
+ if (RAND_bytes(buf.data(), (int)buf.size()) != 1) throw std::runtime_error("RAND_bytes failed");
|
|
|
|
|
+ static const char* hex = "0123456789abcdef"; std::string o; o.reserve(32);
|
|
|
|
|
+ for (unsigned char b : buf) { o.push_back(hex[b >> 4]); o.push_back(hex[b & 0xF]); }
|
|
|
|
|
+ return o;
|
|
|
|
|
+}
|
|
|
|
|
+SessionStore::SessionStore(std::chrono::minutes ttl) : ttl_(ttl) {}
|
|
|
|
|
+std::string SessionStore::create(const std::string& apiKeyValue, std::chrono::system_clock::time_point now) {
|
|
|
|
|
+ std::string tok = randomToken();
|
|
|
|
|
+ std::lock_guard<std::mutex> lk(m_);
|
|
|
|
|
+ sessions_[tok] = Entry{apiKeyValue, now + ttl_};
|
|
|
|
|
+ return tok;
|
|
|
|
|
+}
|
|
|
|
|
+std::optional<std::string> SessionStore::keyFor(const std::string& token, std::chrono::system_clock::time_point now) {
|
|
|
|
|
+ std::lock_guard<std::mutex> lk(m_);
|
|
|
|
|
+ auto it = sessions_.find(token);
|
|
|
|
|
+ if (it == sessions_.end()) return std::nullopt;
|
|
|
|
|
+ if (now >= it->second.expiry) { sessions_.erase(it); return std::nullopt; }
|
|
|
|
|
+ return it->second.key;
|
|
|
|
|
+}
|
|
|
|
|
+void SessionStore::remove(const std::string& token) { std::lock_guard<std::mutex> lk(m_); sessions_.erase(token); }
|
|
|
|
|
+void SessionStore::setTtl(std::chrono::minutes ttl) { std::lock_guard<std::mutex> lk(m_); ttl_ = ttl; }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+(Header: `struct Entry { std::string key; std::chrono::system_clock::time_point expiry; };` and `std::unordered_map<std::string, Entry> sessions_; std::chrono::minutes ttl_; std::mutex m_;`.)
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5:** add `auth.cpp`; build; `ctest -R Auth`. PASS.
|
|
|
|
|
+- [ ] **Step 6: Commit** `feat(auth): session store mapping cookie->key + bearer/cookie parsing`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 11: ApiServer — project-aware auth, CORS, meta, login (+ integration fixture)
|
|
|
|
|
+
|
|
|
|
|
+**Files:** Create `src/server.{hpp,cpp}`, `src/handlers/meta.cpp`, `src/handlers/webui_auth.cpp`; stub the other handler files; extend `errors` with `Forbidden`. Create `api/openapi.json`+`api/llms.txt` stubs, `tests/api_fixture.hpp`, `tests/test_api_integration.cpp`. Add all to CMake.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Extend `ErrCode` with `Forbidden` (403)**
|
|
|
|
|
+
|
|
|
|
|
+In `src/errors.hpp` add `Forbidden` to the enum (after `Unauthorized`). In `src/errors.cpp` add `case ErrCode::Forbidden: return 403;`. Append to `tests/test_errors.cpp`:
|
|
|
|
|
+```cpp
|
|
|
|
|
+TEST(Errors, ForbiddenIs403) { EXPECT_EQ(httpStatus(svapi::ErrCode::Forbidden), 403); }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: `api/openapi.json` + `api/llms.txt` stubs**
|
|
|
|
|
+
|
|
|
|
|
+`api/openapi.json`: `{ "openapi": "3.1.0", "info": { "title": "smartbotic-vectorapi", "version": "0.1.0" }, "paths": {} }`
|
|
|
|
|
+`api/llms.txt`: `# smartbotic-vectorapi\n\n> REST API fronting smartbotic-database (multi-project, multi-key).\n`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: `src/server.hpp`**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+#pragma once
|
|
|
|
|
+#include "apikey.hpp"
|
|
|
|
|
+#include "auth.hpp"
|
|
|
|
|
+#include "collection_registry.hpp"
|
|
|
|
|
+#include "db_gateway.hpp"
|
|
|
|
|
+#include "key_store.hpp"
|
|
|
|
|
+#include "settings_store.hpp"
|
|
|
|
|
+#include <httplib.h>
|
|
|
|
|
+#include <optional>
|
|
|
|
|
+#include <string>
|
|
|
|
|
+
|
|
|
|
|
+namespace svapi {
|
|
|
|
|
+
|
|
|
|
|
+struct ServerDeps {
|
|
|
|
|
+ DbGateway& db;
|
|
|
|
|
+ SettingsStore& settings;
|
|
|
|
|
+ KeyStore& keys;
|
|
|
|
|
+ CollectionRegistry& registry;
|
|
|
|
|
+ SessionStore& sessions;
|
|
|
|
|
+ std::string webuiDir; // static SPA root ("" disables)
|
|
|
|
|
+ std::string shareDir; // openapi.json + llms.txt dir
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+class ApiServer {
|
|
|
|
|
+public:
|
|
|
|
|
+ explicit ApiServer(ServerDeps deps);
|
|
|
|
|
+ void registerRoutes();
|
|
|
|
|
+ int bindToAnyPort(const std::string& host);
|
|
|
|
|
+ void listenAfterBind();
|
|
|
|
|
+ bool listen(const std::string& host, uint16_t port);
|
|
|
|
|
+ void stop();
|
|
|
|
|
+ httplib::Server& raw() { return svr_; }
|
|
|
|
|
+ ServerDeps& deps() { return d_; }
|
|
|
|
|
+private:
|
|
|
|
|
+ ServerDeps d_;
|
|
|
|
|
+ httplib::Server svr_;
|
|
|
|
|
+};
|
|
|
|
|
+
|
|
|
|
|
+// Auth helpers (defined in server.cpp).
|
|
|
|
|
+std::optional<ApiKey> resolveKey(ServerDeps& d, const httplib::Request& req);
|
|
|
|
|
+ApiKey requireKey(ServerDeps& d, const httplib::Request& req); // throws Unauthorized
|
|
|
|
|
+void requireAdmin(const ApiKey& k); // throws Forbidden
|
|
|
|
|
+void requireProjectAccess(const ApiKey& k, const std::string& project); // throws Forbidden
|
|
|
|
|
+
|
|
|
|
|
+// Route registration (handlers/*.cpp).
|
|
|
|
|
+void registerMetaRoutes(ApiServer&);
|
|
|
|
|
+void registerWebuiAuthRoutes(ApiServer&);
|
|
|
|
|
+void registerProjectRoutes(ApiServer&);
|
|
|
|
|
+void registerKeyRoutes(ApiServer&);
|
|
|
|
|
+void registerCollectionRoutes(ApiServer&);
|
|
|
|
|
+void registerDocumentRoutes(ApiServer&);
|
|
|
|
|
+void registerVectorRoutes(ApiServer&);
|
|
|
|
|
+void registerStatsRoutes(ApiServer&);
|
|
|
|
|
+
|
|
|
|
|
+} // namespace svapi
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: `src/server.cpp`**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+#include "server.hpp"
|
|
|
|
|
+#include "errors.hpp"
|
|
|
|
|
+#include "json_http.hpp"
|
|
|
|
|
+#include <chrono>
|
|
|
|
|
+
|
|
|
|
|
+namespace svapi {
|
|
|
|
|
+
|
|
|
|
|
+ApiServer::ApiServer(ServerDeps deps) : d_(std::move(deps)) {}
|
|
|
|
|
+
|
|
|
|
|
+std::optional<ApiKey> resolveKey(ServerDeps& d, const httplib::Request& req) {
|
|
|
|
|
+ if (auto it = req.headers.find("Authorization"); it != req.headers.end())
|
|
|
|
|
+ if (auto tok = bearerToken(it->second))
|
|
|
|
|
+ if (auto k = d.keys.lookup(*tok)) return k;
|
|
|
|
|
+ if (auto it = req.headers.find("Cookie"); it != req.headers.end())
|
|
|
|
|
+ if (auto c = cookieValue(it->second, "svapi_session"))
|
|
|
|
|
+ if (auto keyVal = d.sessions.keyFor(*c, std::chrono::system_clock::now()))
|
|
|
|
|
+ if (auto k = d.keys.lookup(*keyVal)) return k;
|
|
|
|
|
+ return std::nullopt;
|
|
|
|
|
+}
|
|
|
|
|
+ApiKey requireKey(ServerDeps& d, const httplib::Request& req) {
|
|
|
|
|
+ if (auto k = resolveKey(d, req)) return *k;
|
|
|
|
|
+ throw ApiError(ErrCode::Unauthorized, "unauthorized", "missing or invalid credentials");
|
|
|
|
|
+}
|
|
|
|
|
+void requireAdmin(const ApiKey& k) {
|
|
|
|
|
+ if (!k.admin) throw ApiError(ErrCode::Forbidden, "forbidden", "admin privilege required");
|
|
|
|
|
+}
|
|
|
|
|
+void requireProjectAccess(const ApiKey& k, const std::string& project) {
|
|
|
|
|
+ if (!k.canAccess(project))
|
|
|
|
|
+ throw ApiError(ErrCode::Forbidden, "forbidden", "key not granted project '" + project + "'");
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+void ApiServer::registerRoutes() {
|
|
|
|
|
+ svr_.set_exception_handler([](const httplib::Request&, httplib::Response& res, std::exception_ptr ep) {
|
|
|
|
|
+ try { std::rethrow_exception(ep); }
|
|
|
|
|
+ catch (const ApiError& e) { sendJson(res, httpStatus(e.code), errorBody(e.slug, e.what())); }
|
|
|
|
|
+ catch (const std::exception& e) { sendJson(res, 500, errorBody("internal", e.what())); }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // Authenticate /api/* (authorization is per-handler).
|
|
|
|
|
+ svr_.set_pre_routing_handler([this](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ if (req.method != "OPTIONS" && req.path.rfind("/api/", 0) == 0) {
|
|
|
|
|
+ if (!resolveKey(d_, req)) {
|
|
|
|
|
+ sendJson(res, 401, errorBody("unauthorized", "missing or invalid credentials"));
|
|
|
|
|
+ return httplib::Server::HandlerResponse::Handled;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return httplib::Server::HandlerResponse::Unhandled;
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ svr_.set_post_routing_handler([this](const httplib::Request&, httplib::Response& res) {
|
|
|
|
|
+ auto snap = d_.settings.snapshot();
|
|
|
|
|
+ const std::string origin = snap->corsOrigins.empty() ? "*" : snap->corsOrigins.front();
|
|
|
|
|
+ res.set_header("Access-Control-Allow-Origin", origin);
|
|
|
|
|
+ res.set_header("Access-Control-Allow-Headers", "Authorization, Content-Type");
|
|
|
|
|
+ res.set_header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS");
|
|
|
|
|
+ });
|
|
|
|
|
+ svr_.Options(R"(.*)", [](const httplib::Request&, httplib::Response& res) { res.status = 204; });
|
|
|
|
|
+
|
|
|
|
|
+ registerMetaRoutes(*this);
|
|
|
|
|
+ registerWebuiAuthRoutes(*this);
|
|
|
|
|
+ registerProjectRoutes(*this);
|
|
|
|
|
+ registerKeyRoutes(*this);
|
|
|
|
|
+ registerCollectionRoutes(*this);
|
|
|
|
|
+ registerDocumentRoutes(*this);
|
|
|
|
|
+ registerVectorRoutes(*this);
|
|
|
|
|
+ registerStatsRoutes(*this);
|
|
|
|
|
+
|
|
|
|
|
+ if (!d_.webuiDir.empty()) svr_.set_mount_point("/", d_.webuiDir);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+int ApiServer::bindToAnyPort(const std::string& host) { return svr_.bind_to_any_port(host); }
|
|
|
|
|
+void ApiServer::listenAfterBind() { svr_.listen_after_bind(); }
|
|
|
|
|
+bool ApiServer::listen(const std::string& host, uint16_t port) { return svr_.listen(host, port); }
|
|
|
|
|
+void ApiServer::stop() { svr_.stop(); }
|
|
|
|
|
+
|
|
|
|
|
+} // namespace svapi
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5: `src/handlers/meta.cpp`** — identical to the base plan's Task 11 meta handler: `GET /healthz` → `{status:ok}`; `GET /readyz` → 200/503 from `db.healthy()`; `GET /openapi.json` and `GET /llms.txt` read files from `deps().shareDir` (404 if empty). (Capture `DbGateway* db = &s.deps().db;` and `std::string share = s.deps().shareDir;`.)
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: `src/handlers/webui_auth.cpp`**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+#include "auth.hpp"
|
|
|
|
|
+#include "errors.hpp"
|
|
|
|
|
+#include "json_http.hpp"
|
|
|
|
|
+#include "server.hpp"
|
|
|
|
|
+#include <chrono>
|
|
|
|
|
+namespace svapi {
|
|
|
|
|
+void registerWebuiAuthRoutes(ApiServer& s) {
|
|
|
|
|
+ auto& svr = s.raw(); ServerDeps* d = &s.deps();
|
|
|
|
|
+ svr.Post("/ui/login", [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ auto body = bodyJson(req);
|
|
|
|
|
+ std::string key = body.value("key", "");
|
|
|
|
|
+ auto found = d->keys.lookup(key);
|
|
|
|
|
+ if (!found) throw ApiError(ErrCode::Unauthorized, "unauthorized", "invalid key");
|
|
|
|
|
+ auto snap = d->settings.snapshot();
|
|
|
|
|
+ d->sessions.setTtl(std::chrono::minutes(snap->sessionTtlMinutes));
|
|
|
|
|
+ std::string tok = d->sessions.create(key, std::chrono::system_clock::now());
|
|
|
|
|
+ res.set_header("Set-Cookie", "svapi_session=" + tok +
|
|
|
|
|
+ "; HttpOnly; SameSite=Strict; Path=/; Max-Age=" + std::to_string(snap->sessionTtlMinutes * 60));
|
|
|
|
|
+ sendJson(res, 200, {{"ok", true}, {"admin", found->admin}, {"projects", found->projects}});
|
|
|
|
|
+ });
|
|
|
|
|
+ svr.Post("/ui/logout", [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ if (auto it = req.headers.find("Cookie"); it != req.headers.end())
|
|
|
|
|
+ if (auto c = cookieValue(it->second, "svapi_session")) d->sessions.remove(*c);
|
|
|
|
|
+ res.set_header("Set-Cookie", "svapi_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0");
|
|
|
|
|
+ sendJson(res, 200, {{"ok", true}});
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 7: Stub the not-yet-implemented handler files** (replaced in Tasks 12–16). Create with empty bodies, exact names:
|
|
|
|
|
+ - `src/handlers/projects.cpp` → `void registerProjectRoutes(ApiServer&) {}`
|
|
|
|
|
+ - `src/handlers/keys.cpp` → `void registerKeyRoutes(ApiServer&) {}`
|
|
|
|
|
+ - `src/handlers/collections.cpp` → `void registerCollectionRoutes(ApiServer&) {}`
|
|
|
|
|
+ - `src/handlers/documents.cpp` → `void registerDocumentRoutes(ApiServer&) {}`
|
|
|
|
|
+ - `src/handlers/vectors.cpp` → `void registerVectorRoutes(ApiServer&) {}`
|
|
|
|
|
+ - `src/handlers/stats.cpp` → `void registerStatsRoutes(ApiServer&) {}`
|
|
|
|
|
+ Each: `#include "server.hpp"` then `namespace svapi { void registerXxxRoutes(ApiServer&) {} }`.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 8: `tests/api_fixture.hpp`**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+#pragma once
|
|
|
|
|
+#include "db_test_util.hpp"
|
|
|
|
|
+#include "server.hpp"
|
|
|
|
|
+#include <gtest/gtest.h>
|
|
|
|
|
+#include <httplib.h>
|
|
|
|
|
+#include <chrono>
|
|
|
|
|
+#include <memory>
|
|
|
|
|
+#include <thread>
|
|
|
|
|
+#include <vector>
|
|
|
|
|
+
|
|
|
|
|
+namespace svapi::testutil {
|
|
|
|
|
+
|
|
|
|
|
+class ApiFixture : public ::testing::Test {
|
|
|
|
|
+protected:
|
|
|
|
|
+ std::unique_ptr<DbGateway> db_;
|
|
|
|
|
+ std::unique_ptr<CollectionRegistry> registry_;
|
|
|
|
|
+ std::unique_ptr<SettingsStore> settings_;
|
|
|
|
|
+ std::unique_ptr<KeyStore> keys_;
|
|
|
|
|
+ std::unique_ptr<SessionStore> sessions_;
|
|
|
|
|
+ std::unique_ptr<ApiServer> server_;
|
|
|
|
|
+ std::thread serverThread_;
|
|
|
|
|
+ int port_ = 0;
|
|
|
|
|
+ std::string adminKey_;
|
|
|
|
|
+ std::string project_; // a project the admin owns; tests create collections under it
|
|
|
|
|
+
|
|
|
|
|
+ httplib::Server mockOpenAi_;
|
|
|
|
|
+ std::thread mockThread_;
|
|
|
|
|
+ int mockPort_ = 0, mockDim_ = 4;
|
|
|
|
|
+ std::string settingsColl_ = "_svapitest_api_settings";
|
|
|
|
|
+ std::string keysColl_ = "_svapitest_api_keys";
|
|
|
|
|
+
|
|
|
|
|
+ void SetUp() override {
|
|
|
|
|
+ db_ = connectOrNull();
|
|
|
|
|
+ if (!db_) GTEST_SKIP() << "smartbotic-database not reachable";
|
|
|
|
|
+ project_ = tmpName("apiproj");
|
|
|
|
|
+ db_->client().dropCollection(settingsColl_);
|
|
|
|
|
+ db_->client().dropCollection(keysColl_);
|
|
|
|
|
+ db_->dropProject(project_);
|
|
|
|
|
+ db_->ensureProject(project_);
|
|
|
|
|
+
|
|
|
|
|
+ registry_ = std::make_unique<CollectionRegistry>(*db_);
|
|
|
|
|
+ registry_->ensure(project_);
|
|
|
|
|
+ settings_ = std::make_unique<SettingsStore>(*db_, settingsColl_, "current");
|
|
|
|
|
+ settings_->bootstrap("");
|
|
|
|
|
+ keys_ = std::make_unique<KeyStore>(*db_, keysColl_);
|
|
|
|
|
+ keys_->bootstrap("");
|
|
|
|
|
+ adminKey_ = keys_->list().at(0).key;
|
|
|
|
|
+ sessions_ = std::make_unique<SessionStore>(std::chrono::minutes(60));
|
|
|
|
|
+
|
|
|
|
|
+ mockOpenAi_.Post("/v1/embeddings", [this](const httplib::Request&, httplib::Response& res) {
|
|
|
|
|
+ nlohmann::json emb = nlohmann::json::array();
|
|
|
|
|
+ for (int i = 0; i < mockDim_; ++i) emb.push_back(0.1f * (i + 1));
|
|
|
|
|
+ res.set_content(nlohmann::json{{"data", {{{"embedding", emb}}}}}.dump(), "application/json");
|
|
|
|
|
+ });
|
|
|
|
|
+ mockPort_ = mockOpenAi_.bind_to_any_port("127.0.0.1");
|
|
|
|
|
+ mockThread_ = std::thread([this]{ mockOpenAi_.listen_after_bind(); });
|
|
|
|
|
+ Settings s = *settings_->snapshot();
|
|
|
|
|
+ s.openaiApiBase = "http://127.0.0.1:" + std::to_string(mockPort_);
|
|
|
|
|
+ s.openaiApiKey = "test";
|
|
|
|
|
+ settings_->save(s);
|
|
|
|
|
+
|
|
|
|
|
+ ServerDeps deps{*db_, *settings_, *keys_, *registry_, *sessions_, "", apiDocsDir()};
|
|
|
|
|
+ server_ = std::make_unique<ApiServer>(std::move(deps));
|
|
|
|
|
+ server_->registerRoutes();
|
|
|
|
|
+ port_ = server_->bindToAnyPort("127.0.0.1");
|
|
|
|
|
+ serverThread_ = std::thread([this]{ server_->listenAfterBind(); });
|
|
|
|
|
+ httplib::Client probe("127.0.0.1", port_);
|
|
|
|
|
+ for (int i = 0; i < 200; ++i) { if (probe.Get("/healthz")) break;
|
|
|
|
|
+ std::this_thread::sleep_for(std::chrono::milliseconds(10)); }
|
|
|
|
|
+ }
|
|
|
|
|
+ void TearDown() override {
|
|
|
|
|
+ if (server_) server_->stop();
|
|
|
|
|
+ if (serverThread_.joinable()) serverThread_.join();
|
|
|
|
|
+ mockOpenAi_.stop();
|
|
|
|
|
+ if (mockThread_.joinable()) mockThread_.join();
|
|
|
|
|
+ if (db_) { db_->client().dropCollection(settingsColl_);
|
|
|
|
|
+ db_->client().dropCollection(keysColl_);
|
|
|
|
|
+ db_->dropProject(project_); }
|
|
|
|
|
+ }
|
|
|
|
|
+ httplib::Client http(const std::string& bearer) {
|
|
|
|
|
+ httplib::Client c("127.0.0.1", port_);
|
|
|
|
|
+ c.set_default_headers({{"Authorization", "Bearer " + bearer}});
|
|
|
|
|
+ return c;
|
|
|
|
|
+ }
|
|
|
|
|
+ httplib::Client admin() { return http(adminKey_); }
|
|
|
|
|
+ httplib::Client noAuth() { return httplib::Client("127.0.0.1", port_); }
|
|
|
|
|
+ static std::string apiDocsDir() { return SVAPI_API_DOCS_DIR; }
|
|
|
|
|
+};
|
|
|
|
|
+} // namespace svapi::testutil
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 9: `tests/test_api_integration.cpp`** (meta + auth; extended in later tasks)
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+#include "api_fixture.hpp"
|
|
|
|
|
+using svapi::testutil::ApiFixture;
|
|
|
|
|
+
|
|
|
|
|
+TEST_F(ApiFixture, HealthzPublic) { auto r = noAuth().Get("/healthz"); ASSERT_TRUE(r); EXPECT_EQ(r->status, 200); }
|
|
|
|
|
+TEST_F(ApiFixture, ReadyzOk) { auto r = noAuth().Get("/readyz"); ASSERT_TRUE(r); EXPECT_EQ(r->status, 200); }
|
|
|
|
|
+TEST_F(ApiFixture, MetaDocsPublic) {
|
|
|
|
|
+ EXPECT_EQ(noAuth().Get("/openapi.json")->status, 200);
|
|
|
|
|
+ EXPECT_EQ(noAuth().Get("/llms.txt")->status, 200);
|
|
|
|
|
+}
|
|
|
|
|
+TEST_F(ApiFixture, ApiRequiresAuth) {
|
|
|
|
|
+ auto r = noAuth().Get("/api/v1/projects");
|
|
|
|
|
+ ASSERT_TRUE(r); EXPECT_EQ(r->status, 401);
|
|
|
|
|
+}
|
|
|
|
|
+TEST_F(ApiFixture, LoginIssuesCookie) {
|
|
|
|
|
+ auto login = noAuth().Post("/ui/login", nlohmann::json{{"key", adminKey_}}.dump(), "application/json");
|
|
|
|
|
+ ASSERT_TRUE(login); EXPECT_EQ(login->status, 200);
|
|
|
|
|
+ ASSERT_TRUE(login->has_header("Set-Cookie"));
|
|
|
|
|
+ std::string cookie = login->get_header_value("Set-Cookie");
|
|
|
|
|
+ cookie = cookie.substr(0, cookie.find(';'));
|
|
|
|
|
+ httplib::Client c("127.0.0.1", port_); c.set_default_headers({{"Cookie", cookie}});
|
|
|
|
|
+ auto r = c.Get("/api/v1/projects");
|
|
|
|
|
+ ASSERT_TRUE(r); EXPECT_NE(r->status, 401);
|
|
|
|
|
+}
|
|
|
|
|
+TEST_F(ApiFixture, BadKeyRejected) {
|
|
|
|
|
+ auto r = http("not-a-real-key").Get("/api/v1/projects");
|
|
|
|
|
+ ASSERT_TRUE(r); EXPECT_EQ(r->status, 401);
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 10: CMake** — add to `vectorapi_core`: `server.cpp handlers/meta.cpp handlers/webui_auth.cpp handlers/projects.cpp handlers/keys.cpp handlers/collections.cpp handlers/documents.cpp handlers/vectors.cpp handlers/stats.cpp`. Add the `test_api_integration` target with `target_compile_definitions(test_api_integration PRIVATE SVAPI_API_DOCS_DIR="${CMAKE_SOURCE_DIR}/api")`.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 11:** build; `ctest -R "Errors|ApiFixture"`. All PASS (the `LoginIssuesCookie`/`ApiRequiresAuth` final GETs hit the projects-list route which is currently a stub → 404, but `EXPECT_NE(..,401)` holds; becomes 200 in Task 12).
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 12: Commit** `feat(server): project-aware auth (authn pre-routing + per-handler authz), CORS, meta, login`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 12: Project & key management handlers
|
|
|
|
|
+
|
|
|
|
|
+**Files:** Replace `src/handlers/projects.cpp` and `src/handlers/keys.cpp`. Append tests to `tests/test_api_integration.cpp`.
|
|
|
|
|
+
|
|
|
|
|
+Routes — Projects: `GET /api/v1/projects` (any key; admin→all, else only granted+existing), `POST /api/v1/projects` `{name}` (admin), `GET /api/v1/projects/{project}` (project access), `DELETE /api/v1/projects/{project}` (admin). Keys (all admin): `GET /api/v1/keys`, `POST /api/v1/keys`, `PATCH /api/v1/keys/{key}`, `DELETE /api/v1/keys/{key}`.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Append failing tests**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+TEST_F(ApiFixture, ProjectsListCreateGetDelete) {
|
|
|
|
|
+ auto c = admin();
|
|
|
|
|
+ auto list0 = c.Get("/api/v1/projects");
|
|
|
|
|
+ ASSERT_EQ(list0->status, 200);
|
|
|
|
|
+ EXPECT_TRUE(nlohmann::json::parse(list0->body).contains("projects"));
|
|
|
|
|
+
|
|
|
|
|
+ std::string np = "svapitest_created";
|
|
|
|
|
+ db_->dropProject(np); // clean
|
|
|
|
|
+ auto cr = c.Post("/api/v1/projects", nlohmann::json{{"name", np}}.dump(), "application/json");
|
|
|
|
|
+ ASSERT_TRUE(cr); EXPECT_EQ(cr->status, 201);
|
|
|
|
|
+ auto got = c.Get(("/api/v1/projects/" + np).c_str());
|
|
|
|
|
+ ASSERT_EQ(got->status, 200);
|
|
|
|
|
+ auto del = c.Delete(("/api/v1/projects/" + np).c_str());
|
|
|
|
|
+ EXPECT_EQ(del->status, 200);
|
|
|
|
|
+ db_->dropProject(np);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+TEST_F(ApiFixture, KeysCrudAndScopedAuthz) {
|
|
|
|
|
+ auto c = admin();
|
|
|
|
|
+ // create a non-admin key scoped to project_
|
|
|
|
|
+ auto mk = c.Post("/api/v1/keys",
|
|
|
|
|
+ nlohmann::json{{"label","n8n"},{"projects",{project_}},{"admin",false}}.dump(), "application/json");
|
|
|
|
|
+ ASSERT_TRUE(mk); ASSERT_EQ(mk->status, 201);
|
|
|
|
|
+ std::string scopedKey = nlohmann::json::parse(mk->body)["key"];
|
|
|
|
|
+ ASSERT_FALSE(scopedKey.empty());
|
|
|
|
|
+
|
|
|
|
|
+ // list masks the secret
|
|
|
|
|
+ auto list = c.Get("/api/v1/keys");
|
|
|
|
|
+ ASSERT_EQ(list->status, 200);
|
|
|
|
|
+ for (auto& k : nlohmann::json::parse(list->body)["keys"]) EXPECT_FALSE(k.contains("key"));
|
|
|
|
|
+
|
|
|
|
|
+ // scoped key can access its project, but not management or another project
|
|
|
|
|
+ EXPECT_EQ(http(scopedKey).Get(("/api/v1/projects/" + project_).c_str())->status, 200);
|
|
|
|
|
+ EXPECT_EQ(http(scopedKey).Get("/api/v1/keys")->status, 403); // not admin
|
|
|
|
|
+ EXPECT_EQ(http(scopedKey).Get("/api/v1/projects/svapitest_other")->status, 403); // not granted
|
|
|
|
|
+
|
|
|
|
|
+ EXPECT_EQ(c.Delete(("/api/v1/keys/" + scopedKey).c_str())->status, 200);
|
|
|
|
|
+ EXPECT_EQ(http(scopedKey).Get(("/api/v1/projects/" + project_).c_str())->status, 401); // revoked
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+TEST_F(ApiFixture, NonAdminCannotCreateProject) {
|
|
|
|
|
+ auto mk = admin().Post("/api/v1/keys",
|
|
|
|
|
+ nlohmann::json{{"label","x"},{"projects",{project_}},{"admin",false}}.dump(), "application/json");
|
|
|
|
|
+ std::string scoped = nlohmann::json::parse(mk->body)["key"];
|
|
|
|
|
+ auto r = http(scoped).Post("/api/v1/projects", nlohmann::json{{"name","svapitest_nope"}}.dump(), "application/json");
|
|
|
|
|
+ ASSERT_TRUE(r); EXPECT_EQ(r->status, 403);
|
|
|
|
|
+ admin().Delete(("/api/v1/keys/" + scoped).c_str());
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2:** Build → `ProjectsList...`/`KeysCrud...` fail (stub routes 404).
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: `src/handlers/projects.cpp`**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+#include "errors.hpp"
|
|
|
|
|
+#include "json_http.hpp"
|
|
|
|
|
+#include "server.hpp"
|
|
|
|
|
+#include <algorithm>
|
|
|
|
|
+namespace svapi {
|
|
|
|
|
+void registerProjectRoutes(ApiServer& s) {
|
|
|
|
|
+ auto& svr = s.raw(); ServerDeps* d = &s.deps();
|
|
|
|
|
+
|
|
|
|
|
+ svr.Get("/api/v1/projects", [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ ApiKey k = requireKey(*d, req);
|
|
|
|
|
+ auto all = d->db.listProjects();
|
|
|
|
|
+ nlohmann::json arr = nlohmann::json::array();
|
|
|
|
|
+ for (const auto& p : all) if (k.canAccess(p)) arr.push_back(p);
|
|
|
|
|
+ sendJson(res, 200, {{"projects", arr}});
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ svr.Post("/api/v1/projects", [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ ApiKey k = requireKey(*d, req); requireAdmin(k);
|
|
|
|
|
+ auto body = bodyJson(req);
|
|
|
|
|
+ std::string name = body.value("name", "");
|
|
|
|
|
+ if (name.empty()) throw ApiError(ErrCode::Unprocessable, "validation", "name is required");
|
|
|
|
|
+ if (!d->db.ensureProject(name))
|
|
|
|
|
+ throw ApiError(ErrCode::Unprocessable, "bad_project", "could not create project (check name rules)");
|
|
|
|
|
+ d->registry.ensure(name);
|
|
|
|
|
+ sendJson(res, 201, {{"name", name}});
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ svr.Get(R"(/api/v1/projects/([^/]+))", [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ ApiKey k = requireKey(*d, req);
|
|
|
|
|
+ std::string project = req.matches[1];
|
|
|
|
|
+ requireProjectAccess(k, project);
|
|
|
|
|
+ auto all = d->db.listProjects();
|
|
|
|
|
+ if (std::find(all.begin(), all.end(), project) == all.end())
|
|
|
|
|
+ throw ApiError(ErrCode::NotFound, "not_found", "no such project");
|
|
|
|
|
+ sendJson(res, 200, {{"name", project}, {"collections", d->registry.list(project).size()}});
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ svr.Delete(R"(/api/v1/projects/([^/]+))", [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ ApiKey k = requireKey(*d, req); requireAdmin(k);
|
|
|
|
|
+ std::string project = req.matches[1];
|
|
|
|
|
+ if (project == "default") throw ApiError(ErrCode::Unprocessable, "protected", "cannot drop the default project");
|
|
|
|
|
+ if (!d->db.dropProject(project)) throw ApiError(ErrCode::Unavailable, "db_error", "drop failed");
|
|
|
|
|
+ sendJson(res, 200, {{"deleted", project}});
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: `src/handlers/keys.cpp`**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+#include "errors.hpp"
|
|
|
|
|
+#include "json_http.hpp"
|
|
|
|
|
+#include "server.hpp"
|
|
|
|
|
+namespace svapi {
|
|
|
|
|
+void registerKeyRoutes(ApiServer& s) {
|
|
|
|
|
+ auto& svr = s.raw(); ServerDeps* d = &s.deps();
|
|
|
|
|
+
|
|
|
|
|
+ svr.Get("/api/v1/keys", [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ requireAdmin(requireKey(*d, req));
|
|
|
|
|
+ nlohmann::json arr = nlohmann::json::array();
|
|
|
|
|
+ for (const auto& k : d->keys.list()) arr.push_back(k.toPublicJson());
|
|
|
|
|
+ sendJson(res, 200, {{"keys", arr}});
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ svr.Post("/api/v1/keys", [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ requireAdmin(requireKey(*d, req));
|
|
|
|
|
+ auto body = bodyJson(req);
|
|
|
|
|
+ std::string label = body.value("label", "");
|
|
|
|
|
+ std::vector<std::string> projects;
|
|
|
|
|
+ if (body.contains("projects") && body["projects"].is_array())
|
|
|
|
|
+ projects = body["projects"].get<std::vector<std::string>>();
|
|
|
|
|
+ bool admin = body.value("admin", false);
|
|
|
|
|
+ ApiKey made = d->keys.create(label, projects, admin);
|
|
|
|
|
+ nlohmann::json out = made.toPublicJson();
|
|
|
|
|
+ out["key"] = made.key; // returned ONCE, in full, on creation
|
|
|
|
|
+ sendJson(res, 201, out);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ svr.Patch(R"(/api/v1/keys/([^/]+))", [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ requireAdmin(requireKey(*d, req));
|
|
|
|
|
+ std::string key = req.matches[1];
|
|
|
|
|
+ auto body = bodyJson(req);
|
|
|
|
|
+ std::optional<std::string> label;
|
|
|
|
|
+ std::optional<std::vector<std::string>> projects;
|
|
|
|
|
+ std::optional<bool> admin;
|
|
|
|
|
+ if (body.contains("label")) label = body["label"].get<std::string>();
|
|
|
|
|
+ if (body.contains("projects")) projects = body["projects"].get<std::vector<std::string>>();
|
|
|
|
|
+ if (body.contains("admin")) admin = body["admin"].get<bool>();
|
|
|
|
|
+ if (!d->keys.update(key, label, projects, admin))
|
|
|
|
|
+ throw ApiError(ErrCode::NotFound, "not_found", "no such key");
|
|
|
|
|
+ sendJson(res, 200, {{"updated", true}});
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ svr.Delete(R"(/api/v1/keys/([^/]+))", [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ requireAdmin(requireKey(*d, req));
|
|
|
|
|
+ std::string key = req.matches[1];
|
|
|
|
|
+ if (!d->keys.remove(key)) throw ApiError(ErrCode::NotFound, "not_found", "no such key");
|
|
|
|
|
+ sendJson(res, 200, {{"deleted", true}});
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5:** build; `ctest -R "ApiFixture"`. New project/key tests PASS.
|
|
|
|
|
+- [ ] **Step 6: Commit** `feat(api): project + key management endpoints with admin/grant enforcement`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 13: Collection handlers (project-scoped)
|
|
|
|
|
+
|
|
|
|
|
+**Files:** Replace `src/handlers/collections.cpp`. Append tests.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Append failing tests**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+TEST_F(ApiFixture, CollectionCrudUnderProject) {
|
|
|
|
|
+ auto c = admin();
|
|
|
|
|
+ std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
|
|
+ auto created = c.Post(base.c_str(), nlohmann::json{{"name","users"},{"kind","json"}}.dump(), "application/json");
|
|
|
|
|
+ ASSERT_TRUE(created); EXPECT_EQ(created->status, 201);
|
|
|
|
|
+ auto list = c.Get(base.c_str());
|
|
|
|
|
+ ASSERT_EQ(list->status, 200);
|
|
|
|
|
+ bool found=false; for (auto& e : nlohmann::json::parse(list->body)["collections"]) if (e["name"]=="users") found=true;
|
|
|
|
|
+ EXPECT_TRUE(found);
|
|
|
|
|
+ EXPECT_EQ(c.Get((base + "/users").c_str())->status, 200);
|
|
|
|
|
+ EXPECT_EQ(c.Delete((base + "/users").c_str())->status, 200);
|
|
|
|
|
+ EXPECT_EQ(c.Get((base + "/users").c_str())->status, 404);
|
|
|
|
|
+}
|
|
|
|
|
+TEST_F(ApiFixture, VectorCollectionNeedsDimension) {
|
|
|
|
|
+ auto c = admin();
|
|
|
|
|
+ std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
|
|
+ auto bad = c.Post(base.c_str(), nlohmann::json{{"name","novec"},{"kind","vector"}}.dump(), "application/json");
|
|
|
|
|
+ ASSERT_TRUE(bad); EXPECT_EQ(bad->status, 422);
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2:** Build → fails (stub).
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Replace `src/handlers/collections.cpp`**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+#include "collection_registry.hpp"
|
|
|
|
|
+#include "errors.hpp"
|
|
|
|
|
+#include "json_http.hpp"
|
|
|
|
|
+#include "server.hpp"
|
|
|
|
|
+namespace svapi {
|
|
|
|
|
+void registerCollectionRoutes(ApiServer& s) {
|
|
|
|
|
+ auto& svr = s.raw(); ServerDeps* d = &s.deps();
|
|
|
|
|
+
|
|
|
|
|
+ svr.Post(R"(/api/v1/projects/([^/]+)/collections)", [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ ApiKey k = requireKey(*d, req); std::string project = req.matches[1];
|
|
|
|
|
+ requireProjectAccess(k, project);
|
|
|
|
|
+ d->db.ensureProject(project); d->registry.ensure(project);
|
|
|
|
|
+ auto body = bodyJson(req);
|
|
|
|
|
+ std::string name = body.value("name", ""), kind = body.value("kind", "json");
|
|
|
|
|
+ if (name.empty()) throw ApiError(ErrCode::Unprocessable, "validation", "name is required");
|
|
|
|
|
+ if (name.rfind('_', 0) == 0) throw ApiError(ErrCode::Unprocessable, "validation", "name may not start with '_'");
|
|
|
|
|
+ if (kind != "json" && kind != "vector") throw ApiError(ErrCode::Unprocessable, "validation", "kind must be 'json' or 'vector'");
|
|
|
|
|
+ uint32_t dim = 0; std::string model;
|
|
|
|
|
+ if (kind == "vector") {
|
|
|
|
|
+ if (!body.contains("vector_dimension")) throw ApiError(ErrCode::Unprocessable, "validation", "vector_dimension required for vector collections");
|
|
|
|
|
+ dim = body.value("vector_dimension", 0u);
|
|
|
|
|
+ if (dim == 0) throw ApiError(ErrCode::Unprocessable, "validation", "vector_dimension must be > 0");
|
|
|
|
|
+ model = body.value("embedding_model", "");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (d->registry.get(project, name)) throw ApiError(ErrCode::Unprocessable, "exists", "collection already exists");
|
|
|
|
|
+ if (!d->db.client().createCollection(qualify(project, name), 0, false, 0, dim))
|
|
|
|
|
+ throw ApiError(ErrCode::Unavailable, "db_error", "failed to create collection");
|
|
|
|
|
+ d->registry.add(project, {name, kind, dim, model, 0});
|
|
|
|
|
+ sendJson(res, 201, {{"name", name}, {"kind", kind}, {"vector_dimension", dim}});
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ svr.Get(R"(/api/v1/projects/([^/]+)/collections)", [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ ApiKey k = requireKey(*d, req); std::string project = req.matches[1];
|
|
|
|
|
+ requireProjectAccess(k, project);
|
|
|
|
|
+ nlohmann::json out = nlohmann::json::array();
|
|
|
|
|
+ for (const auto& m : d->registry.list(project)) {
|
|
|
|
|
+ nlohmann::json e = m.toJson();
|
|
|
|
|
+ if (auto info = d->db.client().getCollectionInfo(qualify(project, m.name))) {
|
|
|
|
|
+ e["document_count"] = info->documentCount; e["size_bytes"] = info->sizeBytes;
|
|
|
|
|
+ }
|
|
|
|
|
+ out.push_back(e);
|
|
|
|
|
+ }
|
|
|
|
|
+ sendJson(res, 200, {{"collections", out}});
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ svr.Get(R"(/api/v1/projects/([^/]+)/collections/([^/]+))", [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ ApiKey k = requireKey(*d, req); std::string project = req.matches[1], name = req.matches[2];
|
|
|
|
|
+ requireProjectAccess(k, project);
|
|
|
|
|
+ auto m = d->registry.get(project, name);
|
|
|
|
|
+ if (!m) throw ApiError(ErrCode::NotFound, "not_found", "no such collection");
|
|
|
|
|
+ nlohmann::json e = m->toJson();
|
|
|
|
|
+ if (auto info = d->db.client().getCollectionInfo(qualify(project, name))) {
|
|
|
|
|
+ e["document_count"] = info->documentCount; e["size_bytes"] = info->sizeBytes;
|
|
|
|
|
+ }
|
|
|
|
|
+ sendJson(res, 200, e);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ svr.Delete(R"(/api/v1/projects/([^/]+)/collections/([^/]+))", [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ ApiKey k = requireKey(*d, req); std::string project = req.matches[1], name = req.matches[2];
|
|
|
|
|
+ requireProjectAccess(k, project);
|
|
|
|
|
+ if (!d->registry.get(project, name)) throw ApiError(ErrCode::NotFound, "not_found", "no such collection");
|
|
|
|
|
+ d->db.client().dropCollection(qualify(project, name));
|
|
|
|
|
+ d->registry.remove(project, name);
|
|
|
|
|
+ sendJson(res, 200, {{"deleted", name}});
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4:** build; `ctest -R ApiFixture`. Collection tests PASS.
|
|
|
|
|
+- [ ] **Step 5: Commit** `feat(api): project-scoped collection CRUD`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 14: Document handlers (project-scoped JSON CRUD + find)
|
|
|
|
|
+
|
|
|
|
|
+**Files:** Replace `src/handlers/documents.cpp`. Append tests.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Append failing tests**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+TEST_F(ApiFixture, DocumentCrudAndFind) {
|
|
|
|
|
+ auto c = admin();
|
|
|
|
|
+ std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
|
|
+ ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","docs"},{"kind","json"}}.dump(),"application/json")->status, 201);
|
|
|
|
|
+ std::string docs = base + "/docs/documents";
|
|
|
|
|
+
|
|
|
|
|
+ auto ins = c.Post(docs.c_str(), nlohmann::json{{"data",{{"name","Alice"},{"age",30}}}}.dump(), "application/json");
|
|
|
|
|
+ ASSERT_EQ(ins->status, 201);
|
|
|
|
|
+ std::string id = nlohmann::json::parse(ins->body)["id"];
|
|
|
|
|
+ EXPECT_EQ(c.Get((docs + "/" + id).c_str())->status, 200);
|
|
|
|
|
+ EXPECT_EQ(c.Patch((docs + "/" + id).c_str(), nlohmann::json{{"age",31}}.dump(), "application/json")->status, 200);
|
|
|
|
|
+ auto found = c.Get((docs + "?filter=age:gte:31&limit=10").c_str());
|
|
|
|
|
+ ASSERT_EQ(found->status, 200);
|
|
|
|
|
+ EXPECT_GE(nlohmann::json::parse(found->body)["count"].get<int>(), 1);
|
|
|
|
|
+ EXPECT_EQ(c.Delete((docs + "/" + id).c_str())->status, 200);
|
|
|
|
|
+ EXPECT_EQ(c.Get((docs + "/" + id).c_str())->status, 404);
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2:** Build → fails (stub).
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Replace `src/handlers/documents.cpp`**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+#include "collection_registry.hpp"
|
|
|
|
|
+#include "errors.hpp"
|
|
|
|
|
+#include "filters.hpp"
|
|
|
|
|
+#include "json_http.hpp"
|
|
|
|
|
+#include "server.hpp"
|
|
|
|
|
+namespace svapi {
|
|
|
|
|
+namespace {
|
|
|
|
|
+// Authorize + verify the collection exists; returns the qualified collection name.
|
|
|
|
|
+std::string scoped(ServerDeps* d, const httplib::Request& req, int projIdx, int collIdx) {
|
|
|
|
|
+ ApiKey k = requireKey(*d, req);
|
|
|
|
|
+ std::string project = req.matches[projIdx], name = req.matches[collIdx];
|
|
|
|
|
+ requireProjectAccess(k, project);
|
|
|
|
|
+ if (!d->registry.get(project, name)) throw ApiError(ErrCode::NotFound, "not_found", "no such collection");
|
|
|
|
|
+ return qualify(project, name);
|
|
|
|
|
+}
|
|
|
|
|
+}
|
|
|
|
|
+void registerDocumentRoutes(ApiServer& s) {
|
|
|
|
|
+ auto& svr = s.raw(); ServerDeps* d = &s.deps();
|
|
|
|
|
+
|
|
|
|
|
+ svr.Post(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/documents)", [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ std::string c = scoped(d, req, 1, 2);
|
|
|
|
|
+ auto body = bodyJson(req);
|
|
|
|
|
+ std::string id = body.value("id", "");
|
|
|
|
|
+ nlohmann::json data = body.contains("data") ? body["data"] : body;
|
|
|
|
|
+ if (data.is_object() && data.contains("id")) data.erase("id");
|
|
|
|
|
+ std::string newId = d->db.client().insert(c, data, id);
|
|
|
|
|
+ if (newId.empty()) throw ApiError(ErrCode::Unavailable, "db_error", "insert failed");
|
|
|
|
|
+ sendJson(res, 201, {{"id", newId}});
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ svr.Get(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/documents/([^/]+))", [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ std::string c = scoped(d, req, 1, 2);
|
|
|
|
|
+ auto doc = d->db.client().get(c, req.matches[3]);
|
|
|
|
|
+ if (!doc) throw ApiError(ErrCode::NotFound, "not_found", "no such document");
|
|
|
|
|
+ sendJson(res, 200, *doc);
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ svr.Get(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/documents)", [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ std::string c = scoped(d, req, 1, 2);
|
|
|
|
|
+ smartbotic::database::Client::QueryOptions opts;
|
|
|
|
|
+ auto range = req.params.equal_range("filter");
|
|
|
|
|
+ for (auto it = range.first; it != range.second; ++it) opts.filters.push_back(parseFilter(it->second));
|
|
|
|
|
+ if (req.has_param("limit")) opts.limit = (uint32_t)std::stoul(req.get_param_value("limit"));
|
|
|
|
|
+ if (req.has_param("offset")) opts.offset = (uint32_t)std::stoul(req.get_param_value("offset"));
|
|
|
|
|
+ if (req.has_param("sort")) opts.sortField = req.get_param_value("sort");
|
|
|
|
|
+ if (req.has_param("desc")) opts.sortDescending = (req.get_param_value("desc") == "true");
|
|
|
|
|
+ auto docs = d->db.client().find(c, opts);
|
|
|
|
|
+ sendJson(res, 200, {{"documents", docs}, {"count", docs.size()}});
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ svr.Patch(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/documents/([^/]+))", [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ std::string c = scoped(d, req, 1, 2); std::string id = req.matches[3];
|
|
|
|
|
+ if (!d->db.client().exists(c, id)) throw ApiError(ErrCode::NotFound, "not_found", "no such document");
|
|
|
|
|
+ uint64_t v = d->db.client().patch(c, id, bodyJson(req));
|
|
|
|
|
+ if (v == 0) throw ApiError(ErrCode::Unavailable, "db_error", "patch failed");
|
|
|
|
|
+ sendJson(res, 200, {{"id", id}, {"version", v}});
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ svr.Put(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/documents/([^/]+))", [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ std::string c = scoped(d, req, 1, 2); std::string id = req.matches[3];
|
|
|
|
|
+ if (!d->db.client().exists(c, id)) throw ApiError(ErrCode::NotFound, "not_found", "no such document");
|
|
|
|
|
+ if (!d->db.client().update(c, id, bodyJson(req))) throw ApiError(ErrCode::Unavailable, "db_error", "update failed");
|
|
|
|
|
+ sendJson(res, 200, {{"id", id}});
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ svr.Delete(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/documents/([^/]+))", [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ std::string c = scoped(d, req, 1, 2); std::string id = req.matches[3];
|
|
|
|
|
+ if (!d->db.client().remove(c, id)) throw ApiError(ErrCode::NotFound, "not_found", "no such document");
|
|
|
|
|
+ sendJson(res, 200, {{"deleted", id}});
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4:** build; `ctest -R ApiFixture`. Document tests PASS.
|
|
|
|
|
+- [ ] **Step 5: Commit** `feat(api): project-scoped JSON document CRUD + find`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 15: Vector + search handlers (project-scoped, with embeddings)
|
|
|
|
|
+
|
|
|
|
|
+**Files:** Replace `src/handlers/vectors.cpp`. Append tests. Mock OpenAI returns `mockDim_`(=4)-length vectors.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Append failing tests**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+TEST_F(ApiFixture, VectorStoreFromTextThenSearch) {
|
|
|
|
|
+ auto c = admin();
|
|
|
|
|
+ std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
|
|
+ ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","rag"},{"kind","vector"},{"vector_dimension",mockDim_}}.dump(),"application/json")->status, 201);
|
|
|
|
|
+ EXPECT_EQ(c.Post((base + "/rag/vectors").c_str(),
|
|
|
|
|
+ nlohmann::json{{"text","dark mode"},{"metadata",{{"src","n8n"}}}}.dump(), "application/json")->status, 201);
|
|
|
|
|
+ auto sr = c.Post((base + "/rag/search").c_str(),
|
|
|
|
|
+ nlohmann::json{{"query_text","dark mode"},{"top_k",5}}.dump(), "application/json");
|
|
|
|
|
+ ASSERT_EQ(sr->status, 200);
|
|
|
|
|
+ EXPECT_GE(nlohmann::json::parse(sr->body)["results"].size(), 1u);
|
|
|
|
|
+}
|
|
|
|
|
+TEST_F(ApiFixture, VectorWrongDimensionIs422) {
|
|
|
|
|
+ auto c = admin();
|
|
|
|
|
+ std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
|
|
+ ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","rag2"},{"kind","vector"},{"vector_dimension",mockDim_}}.dump(),"application/json")->status, 201);
|
|
|
|
|
+ EXPECT_EQ(c.Post((base + "/rag2/vectors").c_str(), nlohmann::json{{"vector",{1.0,2.0}}}.dump(), "application/json")->status, 422);
|
|
|
|
|
+}
|
|
|
|
|
+TEST_F(ApiFixture, VectorOnJsonCollectionIs422) {
|
|
|
|
|
+ auto c = admin();
|
|
|
|
|
+ std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
|
|
+ ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","plain"},{"kind","json"}}.dump(),"application/json")->status, 201);
|
|
|
|
|
+ EXPECT_EQ(c.Post((base + "/plain/vectors").c_str(), nlohmann::json{{"vector",{1,2,3,4}}}.dump(), "application/json")->status, 422);
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2:** Build → fails (stub).
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Replace `src/handlers/vectors.cpp`**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+#include "collection_registry.hpp"
|
|
|
|
|
+#include "embeddings.hpp"
|
|
|
|
|
+#include "errors.hpp"
|
|
|
|
|
+#include "json_http.hpp"
|
|
|
|
|
+#include "server.hpp"
|
|
|
|
|
+namespace svapi {
|
|
|
|
|
+namespace {
|
|
|
|
|
+CollectionMeta requireVectorCollection(ServerDeps* d, const httplib::Request& req,
|
|
|
|
|
+ std::string& projectOut, std::string& nameOut) {
|
|
|
|
|
+ ApiKey k = requireKey(*d, req);
|
|
|
|
|
+ projectOut = req.matches[1]; nameOut = req.matches[2];
|
|
|
|
|
+ requireProjectAccess(k, projectOut);
|
|
|
|
|
+ auto m = d->registry.get(projectOut, nameOut);
|
|
|
|
|
+ if (!m) throw ApiError(ErrCode::NotFound, "not_found", "no such collection");
|
|
|
|
|
+ if (m->kind != "vector") throw ApiError(ErrCode::Unprocessable, "not_vector", "collection is not a vector collection");
|
|
|
|
|
+ return *m;
|
|
|
|
|
+}
|
|
|
|
|
+std::vector<float> resolveVector(ServerDeps* d, const CollectionMeta& meta, const nlohmann::json& body,
|
|
|
|
|
+ const char* vecField, const char* textField) {
|
|
|
|
|
+ std::vector<float> v;
|
|
|
|
|
+ if (body.contains(vecField) && body[vecField].is_array()) {
|
|
|
|
|
+ v = body[vecField].get<std::vector<float>>();
|
|
|
|
|
+ } else if (body.contains(textField) && body[textField].is_string()) {
|
|
|
|
|
+ auto snap = d->settings.snapshot();
|
|
|
|
|
+ std::string model = meta.embeddingModel.empty() ? snap->defaultEmbeddingModel : meta.embeddingModel;
|
|
|
|
|
+ EmbeddingClient emb(snap->openaiApiBase, snap->openaiApiKey);
|
|
|
|
|
+ v = emb.embed(model, body[textField].get<std::string>());
|
|
|
|
|
+ } else {
|
|
|
|
|
+ throw ApiError(ErrCode::Unprocessable, "validation",
|
|
|
|
|
+ std::string("provide '") + vecField + "' or '" + textField + "'");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (v.size() != meta.vectorDimension)
|
|
|
|
|
+ throw ApiError(ErrCode::Unprocessable, "dimension_mismatch",
|
|
|
|
|
+ "expected dimension " + std::to_string(meta.vectorDimension) + ", got " + std::to_string(v.size()));
|
|
|
|
|
+ return v;
|
|
|
|
|
+}
|
|
|
|
|
+}
|
|
|
|
|
+void registerVectorRoutes(ApiServer& s) {
|
|
|
|
|
+ auto& svr = s.raw(); ServerDeps* d = &s.deps();
|
|
|
|
|
+
|
|
|
|
|
+ svr.Post(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/vectors)", [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ std::string project, name; CollectionMeta meta = requireVectorCollection(d, req, project, name);
|
|
|
|
|
+ auto body = bodyJson(req);
|
|
|
|
|
+ std::vector<float> vec = resolveVector(d, meta, body, "vector", "text");
|
|
|
|
|
+ nlohmann::json doc = body.value("metadata", nlohmann::json::object());
|
|
|
|
|
+ if (!doc.is_object()) doc = nlohmann::json::object();
|
|
|
|
|
+ if (body.contains("text")) doc["text"] = body["text"];
|
|
|
|
|
+ doc["_vector"] = vec;
|
|
|
|
|
+ std::string newId = d->db.client().insert(qualify(project, name), doc, body.value("id", ""));
|
|
|
|
|
+ if (newId.empty()) throw ApiError(ErrCode::Unavailable, "db_error", "insert failed");
|
|
|
|
|
+ sendJson(res, 201, {{"id", newId}});
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ svr.Post(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/search)", [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ std::string project, name; CollectionMeta meta = requireVectorCollection(d, req, project, name);
|
|
|
|
|
+ auto body = bodyJson(req);
|
|
|
|
|
+ std::vector<float> q = resolveVector(d, meta, body, "query_vector", "query_text");
|
|
|
|
|
+ uint32_t topK = body.value("top_k", 5u);
|
|
|
|
|
+ float minScore = body.value("min_score", 0.0f);
|
|
|
|
|
+ auto results = d->db.client().similaritySearch(qualify(project, name), q, topK, minScore);
|
|
|
|
|
+ nlohmann::json arr = nlohmann::json::array();
|
|
|
|
|
+ for (const auto& r : results) arr.push_back({{"id", r.id}, {"score", r.score}, {"data", r.data}});
|
|
|
|
|
+ sendJson(res, 200, {{"results", arr}});
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4:** build; `ctest -R ApiFixture`. Vector/search tests PASS.
|
|
|
|
|
+- [ ] **Step 5: Commit** `feat(api): project-scoped vector store + similarity search with embedding generation`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 16: Stats handlers (project + global)
|
|
|
|
|
+
|
|
|
|
|
+**Files:** Replace `src/handlers/stats.cpp`. Append a test.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Append failing test**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+TEST_F(ApiFixture, ProjectStatsAndGlobalStats) {
|
|
|
|
|
+ auto c = admin();
|
|
|
|
|
+ auto p = c.Get(("/api/v1/projects/" + project_ + "/stats").c_str());
|
|
|
|
|
+ ASSERT_EQ(p->status, 200);
|
|
|
|
|
+ EXPECT_TRUE(nlohmann::json::parse(p->body).contains("collections"));
|
|
|
|
|
+ auto g = c.Get("/api/v1/stats");
|
|
|
|
|
+ ASSERT_EQ(g->status, 200);
|
|
|
|
|
+ EXPECT_TRUE(nlohmann::json::parse(g->body).contains("memory_pressure_level"));
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2:** Build → fails (stub).
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Replace `src/handlers/stats.cpp`**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+#include "collection_registry.hpp"
|
|
|
|
|
+#include "errors.hpp"
|
|
|
|
|
+#include "json_http.hpp"
|
|
|
|
|
+#include "server.hpp"
|
|
|
|
|
+namespace svapi {
|
|
|
|
|
+void registerStatsRoutes(ApiServer& s) {
|
|
|
|
|
+ auto& svr = s.raw(); ServerDeps* d = &s.deps();
|
|
|
|
|
+
|
|
|
|
|
+ // Per-project stats (any key granted the project).
|
|
|
|
|
+ svr.Get(R"(/api/v1/projects/([^/]+)/stats)", [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ ApiKey k = requireKey(*d, req); std::string project = req.matches[1];
|
|
|
|
|
+ requireProjectAccess(k, project);
|
|
|
|
|
+ auto cols = d->registry.list(project);
|
|
|
|
|
+ uint64_t docs = 0;
|
|
|
|
|
+ for (const auto& m : cols)
|
|
|
|
|
+ if (auto info = d->db.client().getCollectionInfo(qualify(project, m.name))) docs += info->documentCount;
|
|
|
|
|
+ sendJson(res, 200, {{"project", project}, {"collections", cols.size()}, {"documents", docs}});
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // Global server stats (admin only).
|
|
|
|
|
+ svr.Get("/api/v1/stats", [d](const httplib::Request& req, httplib::Response& res) {
|
|
|
|
|
+ requireAdmin(requireKey(*d, req));
|
|
|
|
|
+ nlohmann::json out;
|
|
|
|
|
+ if (auto st = d->db.client().getStats()) {
|
|
|
|
|
+ out["total_documents"] = st->totalDocuments;
|
|
|
|
|
+ out["total_collections"] = st->totalCollections;
|
|
|
|
|
+ out["memory_used_bytes"] = st->memoryUsedBytes;
|
|
|
|
|
+ out["insert_count"] = st->insertCount;
|
|
|
|
|
+ out["query_count"] = st->queryCount;
|
|
|
|
|
+ } else { out["total_documents"] = 0; }
|
|
|
|
|
+ auto mem = d->db.client().getMemoryStats();
|
|
|
|
|
+ out["memory_pressure_level"] = mem.pressureLevel;
|
|
|
|
|
+ out["memory_pressure_percent"] = mem.pressurePercent;
|
|
|
|
|
+ out["projects"] = d->db.listProjects().size();
|
|
|
|
|
+ sendJson(res, 200, out);
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+> Route note: register the project-scoped `/projects/{p}/stats` and the global `/stats` — both are full-match regexes, no collision.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4:** build; `ctest -R ApiFixture`. Stats tests PASS.
|
|
|
|
|
+- [ ] **Step 5: Commit** `feat(api): project + global stats handlers`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 17: main.cpp full wiring
|
|
|
|
|
+
|
|
|
|
|
+**Files:** Replace `src/main.cpp`.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Replace `src/main.cpp`**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+#include "auth.hpp"
|
|
|
|
|
+#include "collection_registry.hpp"
|
|
|
|
|
+#include "config.hpp"
|
|
|
|
|
+#include "db_gateway.hpp"
|
|
|
|
|
+#include "key_store.hpp"
|
|
|
|
|
+#include "server.hpp"
|
|
|
|
|
+#include "settings_store.hpp"
|
|
|
|
|
+#include <svapi/version.hpp>
|
|
|
|
|
+#include <spdlog/spdlog.h>
|
|
|
|
|
+#ifdef HAVE_SYSTEMD
|
|
|
|
|
+#include <systemd/sd-daemon.h>
|
|
|
|
|
+#else
|
|
|
|
|
+#define sd_notify(u, s) do {} while (0)
|
|
|
|
|
+#endif
|
|
|
|
|
+#include <atomic>
|
|
|
|
|
+#include <chrono>
|
|
|
|
|
+#include <csignal>
|
|
|
|
|
+#include <cstdlib>
|
|
|
|
|
+#include <cstring>
|
|
|
|
|
+#include <iostream>
|
|
|
|
|
+#include <thread>
|
|
|
|
|
+
|
|
|
|
|
+namespace {
|
|
|
|
|
+std::atomic<bool> g_running{true};
|
|
|
|
|
+void onSignal(int) { g_running = false; }
|
|
|
|
|
+std::string envOr(const char* k, const std::string& dflt = "") { const char* v = std::getenv(k); return v ? std::string(v) : dflt; }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+int main(int argc, char** argv) {
|
|
|
|
|
+ std::string configPath = "/etc/smartbotic-vectorapi/config.json";
|
|
|
|
|
+ std::string webuiDirOverride;
|
|
|
|
|
+ std::string shareDir = "/usr/share/smartbotic-vectorapi";
|
|
|
|
|
+ for (int i = 1; i < argc; ++i) {
|
|
|
|
|
+ if (std::strcmp(argv[i], "--config") == 0 && i + 1 < argc) configPath = argv[++i];
|
|
|
|
|
+ else if (std::strcmp(argv[i], "--webui-dir") == 0 && i + 1 < argc) webuiDirOverride = argv[++i];
|
|
|
|
|
+ else if (std::strcmp(argv[i], "--share-dir") == 0 && i + 1 < argc) shareDir = argv[++i];
|
|
|
|
|
+ else if (std::strcmp(argv[i], "--version") == 0 || std::strcmp(argv[i], "-v") == 0) {
|
|
|
|
|
+ std::cout << "smartbotic-vectorapi " << svapi::VERSION << " (" << svapi::GIT_COMMIT << ")\n";
|
|
|
|
|
+ return 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ svapi::Config cfg;
|
|
|
|
|
+ try { cfg = svapi::Config::load(configPath); }
|
|
|
|
|
+ catch (const std::exception& e) { std::cerr << "config error: " << e.what() << "\n"; return 1; }
|
|
|
|
|
+ spdlog::set_level(spdlog::level::from_str(cfg.logLevel));
|
|
|
|
|
+ if (!webuiDirOverride.empty()) cfg.webuiDir = webuiDirOverride;
|
|
|
|
|
+
|
|
|
|
|
+ std::signal(SIGINT, onSignal); std::signal(SIGTERM, onSignal); std::signal(SIGPIPE, SIG_IGN);
|
|
|
|
|
+
|
|
|
|
|
+ svapi::DbGateway db(cfg.dbAddress);
|
|
|
|
|
+ spdlog::info("connecting to smartbotic-database at {}", cfg.dbAddress);
|
|
|
|
|
+ for (int i = 0; i < 30 && !db.connect(); ++i) std::this_thread::sleep_for(std::chrono::seconds(1));
|
|
|
|
|
+ if (!db.healthy()) { spdlog::error("database not reachable at {}", cfg.dbAddress); return 1; }
|
|
|
|
|
+
|
|
|
|
|
+ svapi::SettingsStore settings(db);
|
|
|
|
|
+ settings.bootstrap(envOr("OPENAI_API_KEY"));
|
|
|
|
|
+ settings.startWatch();
|
|
|
|
|
+
|
|
|
|
|
+ svapi::KeyStore keys(db);
|
|
|
|
|
+ keys.bootstrap(envOr("SMARTBOTIC_VECTORAPI_KEY"));
|
|
|
|
|
+ keys.startWatch();
|
|
|
|
|
+
|
|
|
|
|
+ svapi::CollectionRegistry registry(db);
|
|
|
|
|
+ const std::string defProj = settings.snapshot()->defaultProject;
|
|
|
|
|
+ db.ensureProject(defProj);
|
|
|
|
|
+ registry.ensure(defProj);
|
|
|
|
|
+
|
|
|
|
|
+ svapi::SessionStore sessions(std::chrono::minutes(settings.snapshot()->sessionTtlMinutes));
|
|
|
|
|
+
|
|
|
|
|
+ svapi::ServerDeps deps{db, settings, keys, registry, sessions, cfg.webuiDir, shareDir};
|
|
|
|
|
+ svapi::ApiServer server(std::move(deps));
|
|
|
|
|
+ server.registerRoutes();
|
|
|
|
|
+
|
|
|
|
|
+ std::thread http([&] {
|
|
|
|
|
+ spdlog::info("listening on {}:{}", cfg.httpBind, cfg.httpPort);
|
|
|
|
|
+ if (!server.listen(cfg.httpBind, cfg.httpPort)) { spdlog::error("bind failed"); g_running = false; }
|
|
|
|
|
+ });
|
|
|
|
|
+ sd_notify(0, "READY=1");
|
|
|
|
|
+ while (g_running) { sd_notify(0, "WATCHDOG=1"); std::this_thread::sleep_for(std::chrono::milliseconds(200)); }
|
|
|
|
|
+ sd_notify(0, "STOPPING=1");
|
|
|
|
|
+ server.stop();
|
|
|
|
|
+ if (http.joinable()) http.join();
|
|
|
|
|
+ return 0;
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2: Build + manual smoke (DB up)**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+cmake --build build -j"$(nproc)"
|
|
|
|
|
+SMARTBOTIC_VECTORAPI_KEY=devadmin ./build/src/smartbotic-vectorapi --config config/config.json --share-dir "$PWD/api" &
|
|
|
|
|
+PID=$!; sleep 1
|
|
|
|
|
+curl -s localhost:8080/healthz; echo
|
|
|
|
|
+curl -s -H "Authorization: Bearer devadmin" localhost:8080/api/v1/projects; echo
|
|
|
|
|
+curl -s -X POST -H "Authorization: Bearer devadmin" -H 'Content-Type: application/json' \
|
|
|
|
|
+ -d '{"name":"demo"}' localhost:8080/api/v1/projects; echo
|
|
|
|
|
+kill $PID
|
|
|
|
|
+```
|
|
|
|
|
+Expected: `{"status":"ok"}`, a projects list, and `{"name":"demo"}`.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Commit** `feat(main): wire config, DB, settings+keys bootstrap, default project, serve`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Task 18: Author `openapi.json` + `llms.txt`
|
|
|
|
|
+
|
|
|
|
|
+**Files:** Replace `api/openapi.json`, `api/llms.txt`. Append a test.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 1: Append failing test**
|
|
|
|
|
+
|
|
|
|
|
+```cpp
|
|
|
|
|
+TEST_F(ApiFixture, OpenApiDescribesProjectRoutes) {
|
|
|
|
|
+ auto r = noAuth().Get("/openapi.json");
|
|
|
|
|
+ ASSERT_EQ(r->status, 200);
|
|
|
|
|
+ auto j = nlohmann::json::parse(r->body);
|
|
|
|
|
+ EXPECT_EQ(j["openapi"], "3.1.0");
|
|
|
|
|
+ ASSERT_TRUE(j.contains("paths"));
|
|
|
|
|
+ EXPECT_TRUE(j["paths"].contains("/api/v1/projects"));
|
|
|
|
|
+ EXPECT_TRUE(j["paths"].contains("/api/v1/projects/{project}/collections/{name}/search"));
|
|
|
|
|
+ EXPECT_TRUE(j["paths"].contains("/api/v1/keys"));
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 2:** Build → fails (stub openapi has no paths).
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 3: Write full `api/openapi.json`** — OpenAPI 3.1 with `bearerAuth` security scheme and these paths (each with the obvious summaries/params; bodies as in the handlers): `/healthz`, `/readyz` (security: []); `/api/v1/projects` (GET, POST); `/api/v1/projects/{project}` (GET, DELETE); `/api/v1/keys` (GET, POST); `/api/v1/keys/{key}` (PATCH, DELETE); `/api/v1/projects/{project}/collections` (GET, POST); `/api/v1/projects/{project}/collections/{name}` (GET, DELETE); `/api/v1/projects/{project}/collections/{name}/documents` (GET, POST); `/api/v1/projects/{project}/collections/{name}/documents/{id}` (GET, PUT, PATCH, DELETE); `/api/v1/projects/{project}/collections/{name}/vectors` (POST); `/api/v1/projects/{project}/collections/{name}/search` (POST); `/api/v1/projects/{project}/stats` (GET); `/api/v1/stats` (GET). Path params declared; `{name}` is the collection. Mirror the request bodies from the handlers (collection create, vectors `{id?,text?,vector?,metadata?}`, search `{query_text?|query_vector?,top_k,min_score}`, key create `{label,projects[],admin?}`). Top-level `security: [{ "bearerAuth": [] }]`.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 4: Write full `api/llms.txt`** — llmstxt.org format. H1 + summary blockquote stating: general-purpose REST API over smartbotic-database; **multi-project** (project is a URL path segment) and **multi-key** (each key granted projects, MySQL-style; admin keys manage projects/keys); auth via `Authorization: Bearer <KEY>`. Then sections **Projects**, **Keys (admin)**, **Collections**, **JSON documents**, **RAG vectors**, **Stats**, **Ops** listing each endpoint exactly as in Addendum A §A.5, noting the `field:op:value` filter grammar and that `/openapi.json` is the machine-readable spec.
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 5:** build; `ctest --test-dir build --output-on-failure` (full suite). All unit tests PASS; integration tests PASS (DB up).
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **Step 6: Commit** `docs(api): author openapi.json + llms.txt (multi-project, multi-key)`
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## Backend definition of done
|
|
|
|
|
+
|
|
|
|
|
+- `cmake -B build -G Ninja -DBUILD_WEBUI=OFF && cmake --build build` succeeds; `ctest --test-dir build` green.
|
|
|
|
|
+- Binary serves `/healthz`, `/readyz`, `/openapi.json`, `/llms.txt`, `/ui/login`, the project-scoped `/api/v1/projects/{project}/…` surface, and admin `/api/v1/projects` + `/api/v1/keys`.
|
|
|
|
|
+- First run with no keys logs a generated **admin** key once; `SMARTBOTIC_VECTORAPI_KEY` seeds it; `OPENAI_API_KEY` seeds embeddings.
|
|
|
|
|
+- A non-admin key reaches only its granted projects (403 otherwise) and cannot manage projects/keys (403); revoking a key makes its requests 401.
|
|
|
|
|
+- Editing `_vectorapi_settings` / `_vectorapi_keys` in the DB hot-reloads without restart.
|
|
|
|
|
+
|
|
|
|
|
+## Self-Review
|
|
|
|
|
+
|
|
|
|
|
+**Spec (Addendum A) coverage:** A.1 projects (Task 5) · A.2 authorization grants (Tasks 6 ApiKey, 11 helpers, 12/13/14/15/16 enforcement) · A.3 bootstrap admin key (Task 7) · A.4 data scoping: global keys/settings + per-project registry (Tasks 7, 8) · A.5 routes: projects/keys (12), collections (13), documents (14), vectors/search (15), stats (16) · A.6 settings (Tasks 6, 7) · embeddings (9) · meta/login (11) · openapi+llms (18). Web UI (A.7) is plan 2; packaging is plan 3.
|
|
|
|
|
+
|
|
|
|
|
+**Placeholder scan:** All code steps carry full code except Task 18 step 3/4 (openapi.json/llms.txt described by exact endpoint list rather than pasted verbatim — acceptable as these are data artifacts whose contract is fully enumerated) and the SettingsStore/meta references that say "identical to base plan" with the differences spelled out. Implementers have complete interfaces in "Canonical interfaces".
|
|
|
|
|
+
|
|
|
|
|
+**Type consistency:** `qualify`, `DbGateway` (`listProjects/ensureProject/dropProject/client`), `Settings` (no `api_key`), `ApiKey` (`canAccess/toJson/toPublicJson`), `KeyStore` (`bootstrap/lookup/list/create/update/remove/startWatch`), `SettingsStore` (`bootstrap(envOpenAiKey)/snapshot/save/startWatch`), `CollectionRegistry` (project-scoped), `SessionStore` (`create(key,now)/keyFor`), `ServerDeps{db,settings,keys,registry,sessions,webuiDir,shareDir}`, and `requireKey/requireAdmin/requireProjectAccess` are used identically across server.cpp, all handlers, the fixture, and main.cpp. `ErrCode::Forbidden` added in Task 11 step 1.
|
|
|
|
|
+
|
|
|
|
|
+
|