Ver Fonte

docs(plan): rework tasks 5+ for multi-project namespaces + multi-key auth

Companion plan 2026-05-31-...-backend-projects.md supersedes tasks 5-17 of the
base plan: project path segments, API keys with per-project grants + admin,
project/key management endpoints, per-project collection registries.
Fszontagh há 2 meses atrás
pai
commit
37a66ee5a1

+ 1976 - 0
docs/superpowers/plans/2026-05-31-smartbotic-vectorapi-backend-projects.md

@@ -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.
+
+

+ 5 - 0
docs/superpowers/plans/2026-05-31-smartbotic-vectorapi-backend.md

@@ -698,6 +698,11 @@ git commit -m "feat(filters): parse field:op:value query filters into client Fil
 
 ---
 
+> **⚠️ SUPERSEDED FROM HERE.** Tasks 1–4 above were implemented as written. Tasks 5
+> onward are **superseded by** `2026-05-31-smartbotic-vectorapi-backend-projects.md`,
+> which reworks them for DB v2.3 multi-project namespaces + multi-key (per-project
+> grant) authorization (see spec Addendum A). Implement Tasks 5+ from that file.
+
 ## Task 5: DbGateway (owns the gRPC client)
 
 **Files:**